Compare commits

..

No commits in common. "master" and "v0.16.23" have entirely different histories.

641 changed files with 21095 additions and 8723 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

135
.github/sync.py vendored
View File

@ -1,135 +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()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@ -4,189 +4,47 @@ on:
push:
tags:
- "v*"
branches:
- dev
workflow_dispatch:
jobs:
build:
strategy:
fail-fast: true
matrix:
goos: [linux, windows]
goarch: [amd64]
include:
- goos: linux
goarch: s390x
- goos: linux
goarch: arm64
name: Build artifacts
runs-on: ubuntu-latest
container:
image: goreleaser/goreleaser-cross:v1.23
steps:
- run: git config --global --add safe.directory /__w/nezha/nezha
- uses: actions/checkout@v4
- uses: robinraju/release-downloader@v1
with:
repository: nezhahq/admin-frontend
tag: v1.0.11
fileName: dist.zip
latest: true
extract: true
- name: prepare admin-frontend dists
run: |
rm -rf cmd/dashboard/admin-dist
mv dist cmd/dashboard/admin-dist
- uses: robinraju/release-downloader@v1
with:
repository: nezhahq/user-frontend
tag: v1.0.4
fileName: dist.zip
latest: true
extract: true
- name: prepare admin-frontend dists
run: |
rm -rf cmd/dashboard/user-dist
mv dist cmd/dashboard/user-dist
- name: Fetch IPInfo GeoIP Database
env:
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
run: |
rm pkg/geoip/geoip.db
wget -qO pkg/geoip/geoip.db https://ipinfo.io/data/free/country.mmdb?token=${IPINFO_TOKEN}
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.23.x"
- 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 --parseGoList=false
- name: Build with tag
if: contains(github.ref, 'refs/tags/')
uses: goreleaser/goreleaser-action@v6
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
with:
distribution: goreleaser
version: "~> v2"
args: build --single-target --clean --skip=validate
- name: Build snapshot
if: contains(github.ref, 'refs/tags/') == false
uses: goreleaser/goreleaser-action@v6
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
GOARM: ${{ matrix.goarm }}
with:
distribution: goreleaser
version: "~> v2"
args: build --single-target --clean --skip=validate --snapshot
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: dashboard-${{ matrix.goos }}-${{ matrix.goarch }}
path: |
./dist/*/*
release:
runs-on: ubuntu-latest
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
needs: build
name: Release
steps:
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ./assets
- name: Archive and compress binaries
run: |
find assets/*/*/* -type f | while read -r file; do
dir=$(dirname "$file")
filename=$(basename "$file")
fileWithoutExt="${filename%.*}"
zip -jr "$dir/$fileWithoutExt.zip" "$file"
done
- name: Release
uses: ncipollo/release-action@v1
with:
artifacts: "assets/*/*/*.zip"
generateReleaseNotes: true
- name: Purge jsdelivr cache
run: |
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/config.yaml
LOWER_USERNAME=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/config.yaml
- name: Trigger sync
env:
GH_REPO: ${{ github.repository }}
GH_TOKEN: ${{ github.token }}
GH_DEBUG: api
run: |
gh workflow run sync-release.yml
release-docker:
runs-on: ubuntu-latest
if: github.event_name == 'push'
needs: build
name: Release Docker images
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v4
with:
path: ./assets
- name: Fix permissions
- name: Extract branch name
run: |
chmod -R +x ./assets/*
mkdir dist
mv ./assets/*/*/* ./dist
- name: Extract branch name in tag
run: |
if [[ $GITHUB_REF == refs/heads/* ]]; then
export TAG_NAME=$(echo ${GITHUB_REF#refs/heads/})
else
export TAG_NAME=$(echo ${GITHUB_REF#refs/tags/})
fi
export TAG_NAME=$(echo ${GITHUB_REF#refs/tags/})
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
id: extract_branch
- name: Log into GHCR
- name: xgo build
uses: crazy-max/ghaction-xgo@v2
with:
xgo_version: latest
go_version: 1.20.x
dest: dist
pkg: cmd/dashboard
prefix: dashboard
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
v: true
x: false
race: false
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=${{ steps.extract_branch.outputs.tag }}
buildmode: default
- name: fix dist
run: |
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
- name: Log in to the GHCR
uses: docker/login-action@master
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ github.token }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to the AliyunCS
if: contains(github.ref, 'refs/tags/')
uses: docker/login-action@master
with:
registry: registry.cn-shanghai.aliyuncs.com
@ -201,36 +59,50 @@ jobs:
- name: Set up image name
run: |
GHCR_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository_owner }}/nezha" | tr '[:upper:]' '[:lower:]')
if [ ${{ github.repository_owner }} = "nezhahq" ]
GHRC_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
if [ ${{ github.repository_owner }} = "naiba" ]
then ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/naibahq/nezha-dashboard")
else ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
fi
echo "GHCR_IMAGE_NAME=$GHCR_IMAGE_NAME" >> $GITHUB_OUTPUT
echo "ALI_IMAGE_NAME=$ALI_IMAGE_NAME" >> $GITHUB_OUTPUT
echo "::set-output name=GHRC_IMAGE_NAME::$GHRC_IMAGE_NAME"
echo "::set-output name=ALI_IMAGE_NAME::$ALI_IMAGE_NAME"
id: image-name
- name: Build dasbboard image And Push with tag
if: contains(github.ref, 'refs/tags/')
- name: Build dasbboard image And Push
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/s390x
platforms: linux/amd64,linux/arm64,linux/arm,linux/s390x,linux/riscv64 # linux/386,
push: true
tags: |
${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:latest
${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
${{ steps.image-name.outputs.GHRC_IMAGE_NAME }}:latest
${{ steps.image-name.outputs.GHRC_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:latest
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
- name: Build dasbboard image And Push snapshot
if: contains(github.ref, 'refs/tags/') == false
uses: docker/build-push-action@v5
- name: Compress dist files
run: |
for file in dist/*; do
if [ -f "$file" ]; then
zip -r "$file.zip" "$file"
fi
done
- name: Release
uses: ncipollo/release-action@v1
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/s390x
push: true
tags: |
${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
artifacts: "dist/*.zip"
generateReleaseNotes: true
- name: Purge jsdelivr cache
run: |
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/config.yaml
LOWER_USERNAME=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/install.sh
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/nezha-agent.service
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/docker-compose.yaml
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/config.yaml

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: 30
env:
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Sync to Gitee
run: |
pip3 install PyGitHub
python3 .github/sync.py

View File

@ -13,4 +13,4 @@ jobs:
with:
destination_repository: git@gitee.com:naibahq/nezha.git
destination_branch_name: master
destination_ssh_key: ${{ secrets.GITEE_SSH_KEY }}
destination_ssh_key: ${{ secrets.GITLAB_SSH_KEY }}

38
.github/workflows/test-on-pr.yml vendored Normal file
View File

@ -0,0 +1,38 @@
name: Run Tests on PR
on:
pull_request:
branches:
- master
jobs:
tests:
runs-on: ubuntu-latest
env:
GO111MODULE: on
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: "^1.21.3"
- name: Unit test
run: |
go test -v ./...
- name: Run Gosec Security Scanner
uses: securego/gosec@master
with:
args: --exclude=G104,G402 ./...
- name: xgo build
uses: crazy-max/ghaction-xgo@v2
with:
xgo_version: latest
go_version: 1.20.x
dest: dist
pkg: cmd/dashboard
prefix: dashboard
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
v: true
x: false
race: false
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=test
buildmode: default

View File

@ -2,49 +2,43 @@ name: Run Tests
on:
push:
branches:
- master
paths:
- "**.go"
- "go.mod"
- "go.sum"
- "resource/**"
- ".github/workflows/test.yml"
pull_request:
branches:
- master
jobs:
tests:
strategy:
fail-fast: true
matrix:
os: [ubuntu, windows, macos]
runs-on: ${{ matrix.os }}-latest
runs-on: ubuntu-latest
env:
GO111MODULE: on
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/setup-go@v4
with:
go-version: "1.23.x"
- name: generate swagger docs
run: |
go install github.com/swaggo/swag/cmd/swag@latest
touch ./cmd/dashboard/user-dist/a
touch ./cmd/dashboard/admin-dist/a
swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --parseGoList=false
go-version: "^1.21.3"
- name: Unit test
run: |
go test -v ./...
- name: Build test
run: go build -v ./cmd/dashboard
- name: Run Gosec Security Scanner
if: runner.os == 'Linux'
uses: securego/gosec@master
with:
args: --exclude=G104,G402,G115,G203 ./...
args: --exclude=G104,G402 ./...
- name: xgo build
uses: crazy-max/ghaction-xgo@v2
with:
xgo_version: latest
go_version: 1.20.x
dest: dist
pkg: cmd/dashboard
prefix: dashboard
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
v: true
x: false
race: false
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=test
buildmode: default

9
.gitignore vendored
View File

@ -9,19 +9,14 @@
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*~
*.out
*.pprof
.idea
/data
/dist
.DS_Store
/cmd/dashboard/data
/main
/cmd/dashboard/main
/cmd/dashboard/admin-dist/*
/cmd/dashboard/user-dist/*
!/cmd/dashboard/admin-dist/.gitkeep
!/cmd/dashboard/user-dist/.gitkeep
/config.yml
/resource/template/theme-custom
/resource/static/custom
/cmd/dashboard/docs

View File

@ -1,75 +0,0 @@
version: 2
before:
hooks:
- go mod tidy -v
builds:
- id: linux_arm64
env:
- CGO_ENABLED=1
- CC=aarch64-linux-gnu-gcc
ldflags:
- -s -w
- -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
- -buildvcs=false
goos:
- linux
goarch:
- arm64
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
- id: linux_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-linux-gnu-gcc
ldflags:
- -s -w
- -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
- -buildvcs=false
goos:
- linux
goarch:
- amd64
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
- id: linux_s390x
env:
- CGO_ENABLED=1
- CC=s390x-linux-gnu-gcc
ldflags:
- -s -w
- -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
- -buildvcs=false
goos:
- linux
goarch:
- s390x
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
- id: windows_amd64
env:
- CGO_ENABLED=1
- CC=x86_64-w64-mingw32-gcc
ldflags:
- -s -w
- -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
- -extldflags "-static -fpic"
flags:
- -trimpath
- -buildvcs=false
goos:
- windows
goarch:
- amd64
main: ./cmd/dashboard
binary: dashboard-{{ .Os }}-{{ .Arch }}
snapshot:
version_template: "dashboard"

View File

@ -1,12 +1,16 @@
FROM alpine AS certs
RUN apk update && apk add ca-certificates
FROM busybox:stable-musl
# latest 镜像还没有 riscv64 https://hub.docker.com/_/alpine/tags
FROM alpine:edge
ARG TARGETOS
ARG TARGETARCH
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
RUN apk update && \
apk upgrade --no-cache && \
apk add --no-cache tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo 'Asia/Shanghai' >/etc/timezone && \
rm -rf /var/cache/apk/*
COPY ./script/entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
@ -15,6 +19,4 @@ COPY dist/dashboard-${TARGETOS}-${TARGETARCH} ./app
VOLUME ["/dashboard/data"]
EXPOSE 80 5555
ARG TZ=Asia/Shanghai
ENV TZ=$TZ
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -1,18 +1,16 @@
<div align="center">
<br>
<img width="360" style="max-width:80%" src=".github/brand.svg" title="哪吒监控 Nezha Monitoring">
<img width="360" style="max-width:80%" src="resource/static/brand.svg" title="哪吒监控 Nezha Monitoring">
<br>
<small><i>LOGO designed by <a href="https://xio.ng" target="_blank">熊大</a> .</i></small>
<br><br>
<img alt="GitHub release (with filter)" src="https://img.shields.io/github/v/release/nezhahq/nezha?color=brightgreen&style=for-the-badge&logo=github&label=Dashboard">&nbsp;<img src="https://img.shields.io/github/v/release/nezhahq/agent?color=brightgreen&label=Agent&style=for-the-badge&logo=github">&nbsp;<img src="https://img.shields.io/github/actions/workflow/status/nezhahq/agent/agent.yml?label=Agent%20CI&logo=github&style=for-the-badge">&nbsp;<img src="https://img.shields.io/badge/Installer-v0.20.2-brightgreen?style=for-the-badge&logo=linux">
<img alt="GitHub release (with filter)" src="https://img.shields.io/github/v/release/naiba/nezha?color=brightgreen&style=for-the-badge&logo=github&label=Dashboard">&nbsp;<img src="https://img.shields.io/github/v/release/nezhahq/agent?color=brightgreen&label=Agent&style=for-the-badge&logo=github">&nbsp;<img src="https://img.shields.io/github/actions/workflow/status/nezhahq/agent/agent.yml?label=Agent%20CI&logo=github&style=for-the-badge">&nbsp;<img src="https://img.shields.io/badge/Installer-v0.16.2-brightgreen?style=for-the-badge&logo=linux">
<br>
<br>
<p>:trollface: <b>Nezha Monitoring: Self-hostable, lightweight, servers and websites monitoring and O&M tool.</b></p>
<p>Supports <b>monitoring</b> system status, HTTP (SSL certificate change, upcoming expiration, expired), TCP, Ping and supports <b>push alerts</b>, run scheduled tasks and <b>web terminal</b>.</p>
</div>
\>> Telegram Channel: [哪吒监控(中文通知频道)](https://t.me/nezhanews)
\>> Telegram Group: [Nezha Monitoring Global (English Only)](https://t.me/nezhamonitoring_global), [哪吒监控(中文群组)](https://t.me/nezhamonitoring)
\>> [Use Cases | 我们的用户](https://www.google.com/search?q=%22powered+by+Nezha+Monitoring%22+OR+%22powered+by+%E5%93%AA%E5%90%92%E7%9B%91%E6%8E%A7%22) (Google)
@ -24,10 +22,11 @@
## Screenshots
| 用户前台 [@hamster1963](https://github.com/hamster1963) | 管理后台 [@uubulb](https://github.com/uubulb) |
|---|---|
| ![user](.github/user-frontend.20241128.png) | ![admin](.github/admin-frontend.20241128.png) |
| [hamster1963/nezha-dash-react](https://github.com/hamster1963/nezha-dash-react) | [nezhahq/admin-frontend](https://github.com/nezhahq/admin-frontend) |
| Default Theme | DayNight [@JackieSung](https://github.com/JackieSung4ev) | hotaru |
| ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| ![Default Theme](resource/template/theme-default/screenshot.png) | <img src="resource/template/theme-daynight/screenshot.png" width="3000px"/> | <img src="resource/template/theme-hotaru/screenshot.png" width="1500px" /> |
| <div align="center"><b>Neko Mdui <a href="https://github.com/MikoyChinese">@MikoyChinese</a></b></div> | <div align="center"><b>AngelKanade <a href="https://github.com/adminsama">@adminsama</a></b></div> |<div align="center"><b>ServerStatus <a href="https://github.com/unclezs">@unclezs</a></b></div> |
| ![Neko Mdui](resource/template/theme-mdui/screenshot.png) | ![AngelKanade](resource/template/theme-angel-kanade/screenshot.png) | ![默认主题魔改](resource/template/theme-server-status/screenshot.png) |
## Supported Languages
@ -40,16 +39,16 @@ You can change the dashboard language in the settings page (`/setting`) after th
## Contributors
<!--GAMFC_DELIMITER--><a href="https://github.com/naiba" title="naiba"><img src="https://avatars.githubusercontent.com/u/29243953?v=4" width="50;" alt="naiba"/></a>
<a href="https://github.com/uubulb" title="UUBulb"><img src="https://avatars.githubusercontent.com/u/35923940?v=4" width="50;" alt="UUBulb"/></a>
<a href="https://github.com/AkkiaS7" title="Akkia"><img src="https://avatars.githubusercontent.com/u/68485070?v=4" width="50;" alt="Akkia"/></a>
<a href="https://github.com/Erope" title="卖女孩的小火柴"><img src="https://avatars.githubusercontent.com/u/44471469?v=4" width="50;" alt="卖女孩的小火柴"/></a>
<a href="https://github.com/nap0o" title="nap0o"><img src="https://avatars.githubusercontent.com/u/144927971?v=4" width="50;" alt="nap0o"/></a>
<a href="https://github.com/dysf888" title="黑歌"><img src="https://avatars.githubusercontent.com/u/47450409?v=4" width="50;" alt="黑歌"/></a>
<a href="https://github.com/xykt" title="xykt"><img src="https://avatars.githubusercontent.com/u/152045469?v=4" width="50;" alt="xykt"/></a>
<a href="https://github.com/MikoyChinese" title="MikoyChinese"><img src="https://avatars.githubusercontent.com/u/22676744?v=4" width="50;" alt="MikoyChinese"/></a>
<a href="https://github.com/JackieSung4ev" title="JackieSung4ev"><img src="https://avatars.githubusercontent.com/u/24974735?v=4" width="50;" alt="JackieSung4ev"/></a>
<a href="https://github.com/cantoblanco" title="Kris"><img src="https://avatars.githubusercontent.com/u/116849421?v=4" width="50;" alt="Kris"/></a>
<a href="https://github.com/nap0o" title="nap0o"><img src="https://avatars.githubusercontent.com/u/144927971?v=4" width="50;" alt="nap0o"/></a>
<a href="https://github.com/lemoeo" title="Lemoe"><img src="https://avatars.githubusercontent.com/u/18618627?v=4" width="50;" alt="Lemoe"/></a>
<a href="https://github.com/cantoblanco" title="Kris"><img src="https://avatars.githubusercontent.com/u/116849421?v=4" width="50;" alt="Kris"/></a>
<a href="https://github.com/uubulb" title="UUBulb"><img src="https://avatars.githubusercontent.com/u/35923940?v=4" width="50;" alt="UUBulb"/></a>
<a href="https://github.com/spiritLHLS" title="spiritlhl"><img src="https://avatars.githubusercontent.com/u/103393591?v=4" width="50;" alt="spiritlhl"/></a>
<a href="https://github.com/liuyanxi975" title="刘颜溪"><img src="https://avatars.githubusercontent.com/u/24417037?v=4" width="50;" alt="刘颜溪"/></a>
<a href="https://github.com/CosmosZ-code" title="CosmosZ-code"><img src="https://avatars.githubusercontent.com/u/81398224?v=4" width="50;" alt="CosmosZ-code"/></a>
@ -58,7 +57,6 @@ You can change the dashboard language in the settings page (`/setting`) after th
<a href="https://github.com/1ridic" title="1ridic"><img src="https://avatars.githubusercontent.com/u/88495501?v=4" width="50;" alt="1ridic"/></a>
<a href="https://github.com/Mmx233" title="Mmx"><img src="https://avatars.githubusercontent.com/u/36563672?v=4" width="50;" alt="Mmx"/></a>
<a href="https://github.com/rootmelo92118" title="rootmelo92118"><img src="https://avatars.githubusercontent.com/u/32770959?v=4" width="50;" alt="rootmelo92118"/></a>
<a href="https://github.com/zhucaidan" title="zhucaidan"><img src="https://avatars.githubusercontent.com/u/47970938?v=4" width="50;" alt="zhucaidan"/></a>
<a href="https://github.com/iilemon" title="Sean"><img src="https://avatars.githubusercontent.com/u/33201711?v=4" width="50;" alt="Sean"/></a>
<a href="https://github.com/fscarmen" title="fscarmen"><img src="https://avatars.githubusercontent.com/u/62703343?v=4" width="50;" alt="fscarmen"/></a>
<a href="https://github.com/ch8o" title="no-name-now"><img src="https://avatars.githubusercontent.com/u/9103372?v=4" width="50;" alt="no-name-now"/></a>
@ -66,37 +64,26 @@ You can change the dashboard language in the settings page (`/setting`) after th
<a href="https://github.com/DarcJC" title="Darc Z."><img src="https://avatars.githubusercontent.com/u/53445798?v=4" width="50;" alt="Darc Z."/></a>
<a href="https://github.com/Creling" title="Creling"><img src="https://avatars.githubusercontent.com/u/43109504?v=4" width="50;" alt="Creling"/></a>
<a href="https://github.com/coreff" title="Core F"><img src="https://avatars.githubusercontent.com/u/38347122?v=4" width="50;" alt="Core F"/></a>
<a href="https://github.com/nickfox-taterli" title="Tater Li"><img src="https://avatars.githubusercontent.com/u/19658596?v=4" width="50;" alt="Tater Li"/></a>
<a href="https://github.com/hmsjy2017" title="Tony"><img src="https://avatars.githubusercontent.com/u/42692274?v=4" width="50;" alt="Tony"/></a>
<a href="https://github.com/adminsama" title="adminsama"><img src="https://avatars.githubusercontent.com/u/60880076?v=4" width="50;" alt="adminsama"/></a>
<a href="https://github.com/acgpiano" title="Acgpiano"><img src="https://avatars.githubusercontent.com/u/15900800?v=4" width="50;" alt="Acgpiano"/></a>
<a href="https://github.com/eya46" title="eya46"><img src="https://avatars.githubusercontent.com/u/61458340?v=4" width="50;" alt="eya46"/></a>
<a href="https://github.com/guoyongchang" title="guoyongchang"><img src="https://avatars.githubusercontent.com/u/10484506?v=4" width="50;" alt="guoyongchang"/></a>
<a href="https://github.com/hiDandelion" title="hiDandelion"><img src="https://avatars.githubusercontent.com/u/77157418?v=4" width="50;" alt="hiDandelion"/></a>
<a href="https://github.com/yuanweize" title="I"><img src="https://avatars.githubusercontent.com/u/30067203?v=4" width="50;" alt="I"/></a>
<a href="https://github.com/lvyaoting" title="lvyaoting"><img src="https://avatars.githubusercontent.com/u/166296299?v=4" width="50;" alt="lvyaoting"/></a>
<a href="https://github.com/lyj0309" title="lyj"><img src="https://avatars.githubusercontent.com/u/50474995?v=4" width="50;" alt="lyj"/></a>
<a href="https://github.com/unclezs" title="unclezs"><img src="https://avatars.githubusercontent.com/u/42318775?v=4" width="50;" alt="unclezs"/></a>
<a href="https://github.com/ysicing" title="缘生"><img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="50;" alt="缘生"/></a>
<a href="https://github.com/arkylin" title="凌"><img src="https://avatars.githubusercontent.com/u/35104502?v=4" width="50;" alt="凌"/></a>
<a href="https://github.com/colour93" title="玖叁"><img src="https://avatars.githubusercontent.com/u/64313711?v=4" width="50;" alt="玖叁"/></a>
<a href="https://github.com/IamTaoChen" title="Tao Chen"><img src="https://avatars.githubusercontent.com/u/42793494?v=4" width="50;" alt="Tao Chen"/></a>
<a href="https://github.com/Septrum101" title="Spetrum"><img src="https://avatars.githubusercontent.com/u/11692994?v=4" width="50;" alt="Spetrum"/></a>
<a href="https://github.com/arkylin" title="凌"><img src="https://avatars.githubusercontent.com/u/35104502?v=4" width="50;" alt="凌"/></a>
<a href="https://github.com/zhucaidan" title="zhucaidan"><img src="https://avatars.githubusercontent.com/u/47970938?v=4" width="50;" alt="zhucaidan"/></a>
<a href="https://github.com/ysicing" title="缘生"><img src="https://avatars.githubusercontent.com/u/8605565?v=4" width="50;" alt="缘生"/></a>
<a href="https://github.com/unclezs" title="unclezs"><img src="https://avatars.githubusercontent.com/u/42318775?v=4" width="50;" alt="unclezs"/></a>
<a href="https://github.com/lvyaoting" title="lvyaoting"><img src="https://avatars.githubusercontent.com/u/166296299?v=4" width="50;" alt="lvyaoting"/></a>
<a href="https://github.com/yuanweize" title="I"><img src="https://avatars.githubusercontent.com/u/30067203?v=4" width="50;" alt="I"/></a>
<a href="https://github.com/guoyongchang" title="guoyongchang"><img src="https://avatars.githubusercontent.com/u/10484506?v=4" width="50;" alt="guoyongchang"/></a>
<a href="https://github.com/acgpiano" title="Acgpiano"><img src="https://avatars.githubusercontent.com/u/15900800?v=4" width="50;" alt="Acgpiano"/></a>
<a href="https://github.com/adminsama" title="adminsama"><img src="https://avatars.githubusercontent.com/u/60880076?v=4" width="50;" alt="adminsama"/></a>
<a href="https://github.com/hmsjy2017" title="Tony"><img src="https://avatars.githubusercontent.com/u/42692274?v=4" width="50;" alt="Tony"/></a>
<a href="https://github.com/nickfox-taterli" title="Tater Li"><img src="https://avatars.githubusercontent.com/u/19658596?v=4" width="50;" alt="Tater Li"/></a>
<a href="https://github.com/dreamingsleeping" title="Nanjing Hopefun Network Technology Co. Ltd."><img src="https://avatars.githubusercontent.com/u/13828658?v=4" width="50;" alt="Nanjing Hopefun Network Technology Co. Ltd."/></a>
<a href="https://github.com/Moraxyc" title="Moraxyc"><img src="https://avatars.githubusercontent.com/u/69713071?v=4" width="50;" alt="Moraxyc"/></a>
<a href="https://github.com/silver-ymz" title="Mingzhuo Yin"><img src="https://avatars.githubusercontent.com/u/78400701?v=4" width="50;" alt="Mingzhuo Yin"/></a>
<a href="https://github.com/MartijnLindeman" title="Martijn Lindeman"><img src="https://avatars.githubusercontent.com/u/78365708?v=4" width="50;" alt="Martijn Lindeman"/></a>
<a href="https://github.com/funnyzak" title="Leon"><img src="https://avatars.githubusercontent.com/u/2562087?v=4" width="50;" alt="Leon"/></a>
<a href="https://github.com/KorenKrita" title="KorenKrita"><img src="https://avatars.githubusercontent.com/u/22239339?v=4" width="50;" alt="KorenKrita"/></a>
<a href="https://github.com/techotaku" title="Ian Li"><img src="https://avatars.githubusercontent.com/u/1948179?v=4" width="50;" alt="Ian Li"/></a>
<a href="https://github.com/GreenTeodoro839" title="GreenTeodoro839"><img src="https://avatars.githubusercontent.com/u/77104800?v=4" width="50;" alt="GreenTeodoro839"/></a>
<a href="https://github.com/Es-dese" title="Esdese"><img src="https://avatars.githubusercontent.com/u/71542548?v=4" width="50;" alt="Esdese"/></a>
<a href="https://github.com/wwng2333" title=":D"><img src="https://avatars.githubusercontent.com/u/17147265?v=4" width="50;" alt=":D"/></a>
<a href="https://github.com/wellcoming" title="Coming"><img src="https://avatars.githubusercontent.com/u/74850890?v=4" width="50;" alt="Coming"/></a><!--GAMFC_DELIMITER_END-->
## Special Thanks
- [IPInfo](https://ipinfo.io/) for providing an accurate GeoIP Database.
<a href="https://github.com/wwng2333" title="Crazy"><img src="https://avatars.githubusercontent.com/u/17147265?v=4" width="50;" alt="Crazy"/></a><!--GAMFC_DELIMITER_END-->
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=nezhahq/nezha&type=Timeline)](https://star-history.com/#nezhahq/nezha&Timeline)
[![Star History Chart](https://api.star-history.com/svg?repos=naiba/nezha&type=Timeline)](https://star-history.com/#naiba/nezha&Timeline)

View File

@ -1,173 +0,0 @@
package controller
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List Alert rules
// @Summary List Alert rules
// @Security BearerAuth
// @Schemes
// @Description List Alert rules
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.AlertRule]
// @Router /alert-rule [get]
func listAlertRule(c *gin.Context) ([]*model.AlertRule, error) {
singleton.AlertsLock.RLock()
defer singleton.AlertsLock.RUnlock()
var ar []*model.AlertRule
if err := copier.Copy(&ar, &singleton.Alerts); err != nil {
return nil, err
}
return ar, nil
}
// Add Alert Rule
// @Summary Add Alert Rule
// @Security BearerAuth
// @Schemes
// @Description Add Alert Rule
// @Tags auth required
// @Accept json
// @param request body model.AlertRuleForm true "AlertRuleForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /alert-rule [post]
func createAlertRule(c *gin.Context) (uint64, error) {
var arf model.AlertRuleForm
var r model.AlertRule
if err := c.ShouldBindJSON(&arf); err != nil {
return 0, err
}
r.Name = arf.Name
r.Rules = arf.Rules
r.FailTriggerTasks = arf.FailTriggerTasks
r.RecoverTriggerTasks = arf.RecoverTriggerTasks
r.NotificationGroupID = arf.NotificationGroupID
enable := arf.Enable
r.TriggerMode = arf.TriggerMode
r.Enable = &enable
if err := validateRule(&r); err != nil {
return 0, err
}
if err := singleton.DB.Create(&r).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddAlert(&r)
return r.ID, nil
}
// Update Alert Rule
// @Summary Update Alert Rule
// @Security BearerAuth
// @Schemes
// @Description Update Alert Rule
// @Tags auth required
// @Accept json
// @param id path uint true "Alert ID"
// @param request body model.AlertRuleForm true "AlertRuleForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /alert-rule/{id} [patch]
func updateAlertRule(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var arf model.AlertRuleForm
if err := c.ShouldBindJSON(&arf); err != nil {
return 0, err
}
var r model.AlertRule
if err := singleton.DB.First(&r, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("alert id %d does not exist", id)
}
r.Name = arf.Name
r.Rules = arf.Rules
r.FailTriggerTasks = arf.FailTriggerTasks
r.RecoverTriggerTasks = arf.RecoverTriggerTasks
r.NotificationGroupID = arf.NotificationGroupID
enable := arf.Enable
r.TriggerMode = arf.TriggerMode
r.Enable = &enable
if err := validateRule(&r); err != nil {
return 0, err
}
if err := singleton.DB.Save(&r).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddAlert(&r)
return r.ID, nil
}
// Batch delete Alert rules
// @Summary Batch delete Alert rules
// @Security BearerAuth
// @Schemes
// @Description Batch delete Alert rules
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/alert-rule [post]
func batchDeleteAlertRule(c *gin.Context) (any, error) {
var ar []uint64
if err := c.ShouldBindJSON(&ar); err != nil {
return nil, err
}
if err := singleton.DB.Unscoped().Delete(&model.AlertRule{}, "id in (?)", ar).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDeleteAlert(ar)
return nil, nil
}
func validateRule(r *model.AlertRule) error {
if len(r.Rules) > 0 {
for _, rule := range r.Rules {
if !rule.IsTransferDurationRule() {
if rule.Duration < 3 {
return singleton.Localizer.ErrorT("duration need to be at least 3")
}
} else {
if rule.CycleInterval < 1 {
return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
}
if rule.CycleStart == nil {
return singleton.Localizer.ErrorT("cycle_start is not set")
}
if rule.CycleStart.After(time.Now()) {
return singleton.Localizer.ErrorT("cycle_start is a future value")
}
}
}
} else {
return singleton.Localizer.ErrorT("need to configure at least a single rule")
}
return nil
}

View File

@ -0,0 +1,112 @@
package controller
import (
"strconv"
"strings"
"github.com/gin-gonic/gin"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/service/singleton"
)
type apiV1 struct {
r gin.IRouter
}
func (v *apiV1) serve() {
r := v.r.Group("")
// 强制认证的 API
r.Use(mygin.Authorize(mygin.AuthorizeOption{
MemberOnly: true,
AllowAPI: true,
IsPage: false,
Msg: "访问此接口需要认证",
Btn: "点此登录",
Redirect: "/login",
}))
r.GET("/server/list", v.serverList)
r.GET("/server/details", v.serverDetails)
// 不强制认证的 API
mr := v.r.Group("monitor")
mr.Use(mygin.Authorize(mygin.AuthorizeOption{
MemberOnly: false,
IsPage: false,
AllowAPI: true,
Msg: "访问此接口需要认证",
Btn: "点此登录",
Redirect: "/login",
}))
mr.Use(mygin.ValidateViewPassword(mygin.ValidateViewPasswordOption{
IsPage: false,
AbortWhenFail: true,
}))
mr.GET("/:id", v.monitorHistoriesById)
}
// serverList 获取服务器列表 不传入Query参数则获取全部
// header: Authorization: Token
// query: tag (服务器分组)
func (v *apiV1) serverList(c *gin.Context) {
tag := c.Query("tag")
if tag != "" {
c.JSON(200, singleton.ServerAPI.GetListByTag(tag))
return
}
c.JSON(200, singleton.ServerAPI.GetAllList())
}
// serverDetails 获取服务器信息 不传入Query参数则获取全部
// header: Authorization: Token
// query: id (服务器ID逗号分隔优先级高于tag查询)
// query: tag (服务器分组)
func (v *apiV1) serverDetails(c *gin.Context) {
var idList []uint64
idListStr := strings.Split(c.Query("id"), ",")
if c.Query("id") != "" {
idList = make([]uint64, len(idListStr))
for i, v := range idListStr {
id, _ := strconv.ParseUint(v, 10, 64)
idList[i] = id
}
}
tag := c.Query("tag")
if tag != "" {
c.JSON(200, singleton.ServerAPI.GetStatusByTag(tag))
return
}
if len(idList) != 0 {
c.JSON(200, singleton.ServerAPI.GetStatusByIDList(idList))
return
}
c.JSON(200, singleton.ServerAPI.GetAllStatus())
}
func (v *apiV1) monitorHistoriesById(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.AbortWithStatusJSON(400, gin.H{"code": 400, "message": "id参数错误"})
return
}
server, ok := singleton.ServerList[id]
if !ok {
c.AbortWithStatusJSON(404, gin.H{
"code": 404,
"message": "id不存在",
})
return
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
_, isViewPasswordVerfied := c.Get(model.CtxKeyViewPasswordVerified)
authorized := isMember || isViewPasswordVerfied
if server.HideForGuest && !authorized {
c.AbortWithStatusJSON(403, gin.H{"code": 403, "message": "需要认证"})
return
}
c.JSON(200, singleton.MonitorAPI.GetMonitorHistories(map[string]any{"server_id": server.ID}))
}

View File

@ -0,0 +1,608 @@
package controller
import (
"errors"
"log"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/hashicorp/go-uuid"
"github.com/jinzhu/copier"
"github.com/nicksnyder/go-i18n/v2/i18n"
"golang.org/x/crypto/bcrypt"
"golang.org/x/sync/singleflight"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/pkg/utils"
"github.com/naiba/nezha/pkg/websocketx"
"github.com/naiba/nezha/proto"
"github.com/naiba/nezha/service/singleton"
)
type terminalContext struct {
agentConn *websocketx.Conn
userConn *websocketx.Conn
serverID uint64
host string
useSSL bool
}
type commonPage struct {
r *gin.Engine
terminals map[string]*terminalContext
terminalsLock *sync.Mutex
requestGroup singleflight.Group
}
func (cp *commonPage) serve() {
cr := cp.r.Group("")
cr.Use(mygin.Authorize(mygin.AuthorizeOption{}))
cr.Use(mygin.PreferredTheme)
cr.POST("/view-password", cp.issueViewPassword)
cr.GET("/terminal/:id", cp.terminal)
cr.Use(mygin.ValidateViewPassword(mygin.ValidateViewPasswordOption{
IsPage: true,
AbortWhenFail: true,
}))
cr.GET("/", cp.home)
cr.GET("/service", cp.service)
// TODO: 界面直接跳转使用该接口
cr.GET("/network/:id", cp.network)
cr.GET("/network", cp.network)
cr.GET("/ws", cp.ws)
cr.POST("/terminal", cp.createTerminal)
}
type viewPasswordForm struct {
Password string
}
func (p *commonPage) issueViewPassword(c *gin.Context) {
var vpf viewPasswordForm
err := c.ShouldBind(&vpf)
log.Println("bingo", vpf)
var hash []byte
if err == nil && vpf.Password != singleton.Conf.Site.ViewPassword {
err = errors.New(singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "WrongAccessPassword"}))
}
if err == nil {
hash, err = bcrypt.GenerateFromPassword([]byte(vpf.Password), bcrypt.DefaultCost)
}
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusOK,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "AnErrorEccurred",
}),
Msg: err.Error(),
}, true)
c.Abort()
return
}
c.SetCookie(singleton.Conf.Site.CookieName+"-vp", string(hash), 60*60*24, "", "", false, false)
c.Redirect(http.StatusFound, c.Request.Referer())
}
func (p *commonPage) service(c *gin.Context) {
res, _, _ := p.requestGroup.Do("servicePage", func() (interface{}, error) {
singleton.AlertsLock.RLock()
defer singleton.AlertsLock.RUnlock()
var stats map[uint64]model.ServiceItemResponse
var statsStore map[uint64]model.CycleTransferStats
copier.Copy(&stats, singleton.ServiceSentinelShared.LoadStats())
copier.Copy(&statsStore, singleton.AlertsCycleTransferStatsStore)
for k, service := range stats {
if !service.Monitor.EnableShowInService {
delete(stats, k)
}
}
return []interface {
}{
stats, statsStore,
}, nil
})
c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/service"), mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ServicesStatus"}),
"Services": res.([]interface{})[0],
"CycleTransferStats": res.([]interface{})[1],
"CustomCode": singleton.Conf.Site.CustomCode,
}))
}
func (cp *commonPage) network(c *gin.Context) {
var (
monitorHistory *model.MonitorHistory
servers []*model.Server
serverIdsWithMonitor []uint64
monitorInfos = []byte("{}")
id uint64
)
if len(singleton.SortedServerList) > 0 {
id = singleton.SortedServerList[0].ID
}
if err := singleton.DB.Model(&model.MonitorHistory{}).Select("monitor_id, server_id").
Where("monitor_id != 0 and server_id != 0").Limit(1).First(&monitorHistory).Error; err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "请求参数有误:" + "server monitor history not found",
Link: "/",
Btn: "返回重试",
}, true)
return
} else {
if monitorHistory == nil || monitorHistory.ServerID == 0 {
if len(singleton.SortedServerList) > 0 {
id = singleton.SortedServerList[0].ID
}
} else {
id = monitorHistory.ServerID
}
}
idStr := c.Param("id")
if idStr != "" {
var err error
id, err = strconv.ParseUint(idStr, 10, 64)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "请求参数有误:" + err.Error(),
Link: "/",
Btn: "返回重试",
}, true)
return
}
_, ok := singleton.ServerList[id]
if !ok {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "请求参数有误:" + "server id not found",
Link: "/",
Btn: "返回重试",
}, true)
return
}
}
monitorHistories := singleton.MonitorAPI.GetMonitorHistories(map[string]any{"server_id": id})
monitorInfos, _ = utils.Json.Marshal(monitorHistories)
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
_, isViewPasswordVerfied := c.Get(model.CtxKeyViewPasswordVerified)
if err := singleton.DB.Model(&model.MonitorHistory{}).
Select("distinct(server_id)").
Where("server_id != 0").
Find(&serverIdsWithMonitor).
Error; err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "请求参数有误:" + "no server with monitor histories",
Link: "/",
Btn: "返回重试",
}, true)
return
}
if isMember || isViewPasswordVerfied {
for _, server := range singleton.SortedServerList {
for _, id := range serverIdsWithMonitor {
if server.ID == id {
servers = append(servers, server)
}
}
}
} else {
for _, server := range singleton.SortedServerListForGuest {
for _, id := range serverIdsWithMonitor {
if server.ID == id {
servers = append(servers, server)
}
}
}
}
serversBytes, _ := utils.Json.Marshal(Data{
Now: time.Now().Unix() * 1000,
Servers: servers,
})
c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/network"), mygin.CommonEnvironment(c, gin.H{
"Servers": string(serversBytes),
"MonitorInfos": string(monitorInfos),
"CustomCode": singleton.Conf.Site.CustomCode,
"MaxTCPPingValue": singleton.Conf.MaxTCPPingValue,
}))
}
func (cp *commonPage) getServerStat(c *gin.Context) ([]byte, error) {
v, err, _ := cp.requestGroup.Do("serverStats", func() (any, error) {
singleton.SortedServerLock.RLock()
defer singleton.SortedServerLock.RUnlock()
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
_, isViewPasswordVerfied := c.Get(model.CtxKeyViewPasswordVerified)
var servers []*model.Server
if isMember || isViewPasswordVerfied {
servers = singleton.SortedServerList
} else {
servers = singleton.SortedServerListForGuest
}
return utils.Json.Marshal(Data{
Now: time.Now().Unix() * 1000,
Servers: servers,
})
})
return v.([]byte), err
}
func (cp *commonPage) home(c *gin.Context) {
stat, err := cp.getServerStat(c)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "SystemError",
}),
Msg: "服务器状态获取失败",
Link: "/",
Btn: "返回首页",
}, true)
return
}
c.HTML(http.StatusOK, mygin.GetPreferredTheme(c, "/home"), mygin.CommonEnvironment(c, gin.H{
"Servers": string(stat),
"CustomCode": singleton.Conf.Site.CustomCode,
}))
}
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
type Data struct {
Now int64 `json:"now,omitempty"`
Servers []*model.Server `json:"servers,omitempty"`
}
var cloudflareCookiesValidator = regexp.MustCompile("^[A-Za-z0-9-_]+$")
func (cp *commonPage) ws(c *gin.Context) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "NetworkError",
}),
Msg: "Websocket协议切换失败",
Link: "/",
Btn: "返回首页",
}, true)
return
}
defer conn.Close()
count := 0
for {
stat, err := cp.getServerStat(c)
if err != nil {
continue
}
if err := conn.WriteMessage(websocket.TextMessage, stat); err != nil {
break
}
count += 1
if count%4 == 0 {
err = conn.WriteMessage(websocket.PingMessage, []byte{})
if err != nil {
break
}
}
time.Sleep(time.Second * 2)
}
}
func (cp *commonPage) terminal(c *gin.Context) {
terminalID := c.Param("id")
cp.terminalsLock.Lock()
if terminalID == "" || cp.terminals[terminalID] == nil {
cp.terminalsLock.Unlock()
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "无权访问",
Msg: "终端会话不存在",
Link: "/",
Btn: "返回首页",
}, true)
return
}
terminal := cp.terminals[terminalID]
cp.terminalsLock.Unlock()
defer func() {
// 清理 context
cp.terminalsLock.Lock()
defer cp.terminalsLock.Unlock()
delete(cp.terminals, terminalID)
}()
var isAgent bool
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
singleton.ServerLock.RLock()
_, hasID := singleton.SecretToID[c.Request.Header.Get("Secret")]
singleton.ServerLock.RUnlock()
if !hasID {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "无权访问",
Msg: "用户未登录或非法终端",
Link: "/",
Btn: "返回首页",
}, true)
return
}
if terminal.userConn == nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "无权访问",
Msg: "用户不在线",
Link: "/",
Btn: "返回首页",
}, true)
return
}
if terminal.agentConn != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: "连接已存在",
Msg: "Websocket协议切换失败",
Link: "/",
Btn: "返回首页",
}, true)
return
}
isAgent = true
} else {
singleton.ServerLock.RLock()
server := singleton.ServerList[terminal.serverID]
singleton.ServerLock.RUnlock()
if server == nil || server.TaskStream == nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "服务器不存在或处于离线状态",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
cloudflareCookies, _ := c.Cookie("CF_Authorization")
// Cloudflare Cookies 合法性验证
// 其应该包含.分隔的三组BASE64-URL编码
if cloudflareCookies != "" {
encodedCookies := strings.Split(cloudflareCookies, ".")
if len(encodedCookies) == 3 {
for i := 0; i < 3; i++ {
if !cloudflareCookiesValidator.MatchString(encodedCookies[i]) {
cloudflareCookies = ""
break
}
}
} else {
cloudflareCookies = ""
}
}
terminalData, _ := utils.Json.Marshal(&model.TerminalTask{
Host: terminal.host,
UseSSL: terminal.useSSL,
Session: terminalID,
Cookie: cloudflareCookies,
})
if err := server.TaskStream.Send(&proto.Task{
Type: model.TaskTypeTerminal,
Data: string(terminalData),
}); err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "Agent信令下发失败",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
}
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "NetworkError",
}),
Msg: "Websocket协议切换失败",
Link: "/",
Btn: "返回首页",
}, true)
return
}
defer wsConn.Close()
conn := &websocketx.Conn{Conn: wsConn}
log.Printf("NEZHA>> terminal connected %t %q", isAgent, c.Request.URL)
defer log.Printf("NEZHA>> terminal disconnected %t %q", isAgent, c.Request.URL)
if isAgent {
terminal.agentConn = conn
defer func() {
// Agent断开链接时断开用户连接
if terminal.userConn != nil {
terminal.userConn.Close()
}
}()
} else {
terminal.userConn = conn
defer func() {
// 用户断开链接时断开 Agent 连接
if terminal.agentConn != nil {
terminal.agentConn.Close()
}
}()
}
deadlineCh := make(chan interface{})
go func() {
// 对方连接超时
connectDeadline := time.NewTimer(time.Second * 15)
<-connectDeadline.C
deadlineCh <- struct{}{}
}()
go func() {
// PING 保活
for {
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
time.Sleep(time.Second * 10)
}
}()
dataCh := make(chan []byte)
errorCh := make(chan error)
go func() {
for {
msgType, data, err := conn.ReadMessage()
if err != nil {
errorCh <- err
return
}
// 将文本消息转换为命令输入
if msgType == websocket.TextMessage {
data = append([]byte{0}, data...)
}
dataCh <- data
}
}()
var dataBuffer [][]byte
var distConn *websocketx.Conn
checkDistConn := func() {
if distConn == nil {
if isAgent {
distConn = terminal.userConn
} else {
distConn = terminal.agentConn
}
}
}
for {
select {
case <-deadlineCh:
checkDistConn()
if distConn == nil {
return
}
case <-errorCh:
return
case data := <-dataCh:
dataBuffer = append(dataBuffer, data)
checkDistConn()
if distConn != nil {
for i := 0; i < len(dataBuffer); i++ {
err = distConn.WriteMessage(websocket.BinaryMessage, dataBuffer[i])
if err != nil {
return
}
}
dataBuffer = dataBuffer[:0]
}
}
}
}
type createTerminalRequest struct {
Host string
Protocol string
ID uint64
}
func (cp *commonPage) createTerminal(c *gin.Context) {
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "无权访问",
Msg: "用户未登录",
Link: "/login",
Btn: "去登录",
}, true)
return
}
var createTerminalReq createTerminalRequest
if err := c.ShouldBind(&createTerminalReq); err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "请求参数有误:" + err.Error(),
Link: "/server",
Btn: "返回重试",
}, true)
return
}
id, err := uuid.GenerateUUID()
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusInternalServerError,
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
MessageID: "SystemError",
}),
Msg: "生成会话ID失败",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
singleton.ServerLock.RLock()
server := singleton.ServerList[createTerminalReq.ID]
singleton.ServerLock.RUnlock()
if server == nil || server.TaskStream == nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusForbidden,
Title: "请求失败",
Msg: "服务器不存在或处于离线状态",
Link: "/server",
Btn: "返回重试",
}, true)
return
}
cp.terminalsLock.Lock()
defer cp.terminalsLock.Unlock()
cp.terminals[id] = &terminalContext{
serverID: createTerminalReq.ID,
host: createTerminalReq.Host,
useSSL: createTerminalReq.Protocol == "https:",
}
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
"SessionID": id,
"ServerName": server.Name,
}))
}

View File

@ -1,259 +1,241 @@
package controller
import (
"errors"
"fmt"
"io"
"html/template"
"io/fs"
"log"
"net/http"
"os"
"path"
"strconv"
"strings"
"sync"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
"code.cloudfoundry.org/bytefmt"
"github.com/gin-contrib/pprof"
"github.com/gin-gonic/gin"
swaggerfiles "github.com/swaggo/files"
ginSwagger "github.com/swaggo/gin-swagger"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
docs "github.com/nezhahq/nezha/cmd/dashboard/docs"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
"github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/resource"
"github.com/naiba/nezha/service/singleton"
)
func ServeWeb(adminFrontend, userFrontend fs.FS) http.Handler {
func ServeWeb(port uint) *http.Server {
gin.SetMode(gin.ReleaseMode)
r := gin.Default()
tmpl := template.New("").Funcs(funcMap)
var err error
tmpl, err = tmpl.ParseFS(resource.TemplateFS, "template/**/*.html")
if err != nil {
panic(err)
}
tmpl = loadThirdPartyTemplates(tmpl)
r.SetHTMLTemplate(tmpl)
if singleton.Conf.Debug {
gin.SetMode(gin.DebugMode)
pprof.Register(r)
}
if singleton.Conf.Debug {
log.Printf("NEZHA>> Swagger(%s) UI available at http://localhost:%d/swagger/index.html", docs.SwaggerInfo.Version, singleton.Conf.ListenPort)
r.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerfiles.Handler))
}
r.Use(waf.RealIp)
r.Use(waf.Waf)
r.Use(recordPath)
routers(r, adminFrontend, userFrontend)
return r
}
func routers(r *gin.Engine, adminFrontend, userFrontend fs.FS) {
authMiddleware, err := jwt.New(initParams())
r.Use(mygin.RecordPath)
staticFs, err := fs.Sub(resource.StaticFS, "static")
if err != nil {
log.Fatal("JWT Error:" + err.Error())
panic(err)
}
if err := authMiddleware.MiddlewareInit(); err != nil {
log.Fatal("authMiddleware.MiddlewareInit Error:" + err.Error())
r.StaticFS("/static", http.FS(staticFs))
r.Static("/static-custom", "resource/static/custom")
routers(r)
page404 := func(c *gin.Context) {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusNotFound,
Title: "该页面不存在",
Msg: "该页面内容可能已着陆火星",
Link: "/",
Btn: "返回首页",
}, true)
}
api := r.Group("api/v1")
api.POST("/login", authMiddleware.LoginHandler)
r.NoRoute(page404)
r.NoMethod(page404)
optionalAuth := api.Group("", optionalAuthMiddleware(authMiddleware))
optionalAuth.GET("/ws/server", commonHandler(serverStream))
optionalAuth.GET("/server-group", commonHandler(listServerGroup))
optionalAuth.GET("/service", commonHandler(listService))
optionalAuth.GET("/service/:id", commonHandler(listServiceHistory))
optionalAuth.GET("/service/server", commonHandler(listServerWithServices))
optionalAuth.GET("/setting", commonHandler(listConfig))
auth := api.Group("", authMiddleware.MiddlewareFunc())
auth.GET("/refresh-token", authMiddleware.RefreshHandler)
auth.POST("/terminal", commonHandler(createTerminal))
auth.GET("/ws/terminal/:id", commonHandler(terminalStream))
auth.GET("/file", commonHandler(createFM))
auth.GET("/ws/file/:id", commonHandler(fmStream))
auth.GET("/profile", commonHandler(getProfile))
auth.POST("/profile", commonHandler(updateProfile))
auth.GET("/user", commonHandler(listUser))
auth.POST("/user", commonHandler(createUser))
auth.POST("/batch-delete/user", commonHandler(batchDeleteUser))
auth.POST("/service", commonHandler(createService))
auth.PATCH("/service/:id", commonHandler(updateService))
auth.POST("/batch-delete/service", commonHandler(batchDeleteService))
auth.POST("/server-group", commonHandler(createServerGroup))
auth.PATCH("/server-group/:id", commonHandler(updateServerGroup))
auth.POST("/batch-delete/server-group", commonHandler(batchDeleteServerGroup))
auth.GET("/notification-group", commonHandler(listNotificationGroup))
auth.POST("/notification-group", commonHandler(createNotificationGroup))
auth.PATCH("/notification-group/:id", commonHandler(updateNotificationGroup))
auth.POST("/batch-delete/notification-group", commonHandler(batchDeleteNotificationGroup))
auth.GET("/server", commonHandler(listServer))
auth.PATCH("/server/:id", commonHandler(updateServer))
auth.POST("/batch-delete/server", commonHandler(batchDeleteServer))
auth.POST("/force-update/server", commonHandler(forceUpdateServer))
auth.GET("/notification", commonHandler(listNotification))
auth.POST("/notification", commonHandler(createNotification))
auth.PATCH("/notification/:id", commonHandler(updateNotification))
auth.POST("/batch-delete/notification", commonHandler(batchDeleteNotification))
auth.GET("/alert-rule", commonHandler(listAlertRule))
auth.POST("/alert-rule", commonHandler(createAlertRule))
auth.PATCH("/alert-rule/:id", commonHandler(updateAlertRule))
auth.POST("/batch-delete/alert-rule", commonHandler(batchDeleteAlertRule))
auth.GET("/cron", commonHandler(listCron))
auth.POST("/cron", commonHandler(createCron))
auth.PATCH("/cron/:id", commonHandler(updateCron))
auth.GET("/cron/:id/manual", commonHandler(manualTriggerCron))
auth.POST("/batch-delete/cron", commonHandler(batchDeleteCron))
auth.GET("/ddns", commonHandler(listDDNS))
auth.GET("/ddns/providers", commonHandler(listProviders))
auth.POST("/ddns", commonHandler(createDDNS))
auth.PATCH("/ddns/:id", commonHandler(updateDDNS))
auth.POST("/batch-delete/ddns", commonHandler(batchDeleteDDNS))
auth.GET("/nat", commonHandler(listNAT))
auth.POST("/nat", commonHandler(createNAT))
auth.PATCH("/nat/:id", commonHandler(updateNAT))
auth.POST("/batch-delete/nat", commonHandler(batchDeleteNAT))
auth.GET("/waf", commonHandler(listBlockedAddress))
auth.POST("/batch-delete/waf", commonHandler(batchDeleteBlockedAddress))
auth.PATCH("/setting", commonHandler(updateConfig))
r.NoRoute(fallbackToFrontend(adminFrontend, userFrontend))
srv := &http.Server{
Addr: fmt.Sprintf(":%d", port),
ReadHeaderTimeout: time.Second * 5,
Handler: r,
}
return srv
}
func recordPath(c *gin.Context) {
url := c.Request.URL.String()
for _, p := range c.Params {
url = strings.Replace(url, p.Value, ":"+p.Key, 1)
}
c.Set("MatchedPath", url)
}
func newErrorResponse(err error) model.CommonResponse[any] {
return model.CommonResponse[any]{
Success: false,
Error: err.Error(),
func routers(r *gin.Engine) {
// 通用页面
cp := commonPage{r: r, terminals: make(map[string]*terminalContext), terminalsLock: new(sync.Mutex)}
cp.serve()
// 游客页面
gp := guestPage{r}
gp.serve()
// 会员页面
mp := &memberPage{r}
mp.serve()
// API
api := r.Group("api")
{
ma := &memberAPI{api}
ma.serve()
}
}
type handlerFunc[T any] func(c *gin.Context) (T, error)
// There are many error types in gorm, so create a custom type to represent all
// gorm errors here instead
type gormError struct {
msg string
a []interface{}
}
func newGormError(format string, args ...interface{}) error {
return &gormError{
msg: format,
a: args,
func loadThirdPartyTemplates(tmpl *template.Template) *template.Template {
var ret = tmpl
themes, err := os.ReadDir("resource/template")
if err != nil {
log.Printf("NEZHA>> Error reading themes folder: %v", err)
return ret
}
}
func (ge *gormError) Error() string {
return fmt.Sprintf(ge.msg, ge.a...)
}
type wsError struct {
msg string
a []interface{}
}
func newWsError(format string, args ...interface{}) error {
return &wsError{
msg: format,
a: args,
}
}
func (we *wsError) Error() string {
return fmt.Sprintf(we.msg, we.a...)
}
func commonHandler[T any](handler handlerFunc[T]) func(*gin.Context) {
return func(c *gin.Context) {
data, err := handler(c)
if err == nil {
c.JSON(http.StatusOK, model.CommonResponse[T]{Success: true, Data: data})
return
for _, theme := range themes {
if !theme.IsDir() {
continue
}
switch err.(type) {
case *gormError:
log.Printf("NEZHA>> gorm error: %v", err)
c.JSON(http.StatusOK, newErrorResponse(singleton.Localizer.ErrorT("database error")))
return
case *wsError:
// Connection is upgraded to WebSocket, so c.Writer is no longer usable
if msg := err.Error(); msg != "" {
log.Printf("NEZHA>> websocket error: %v", err)
}
return
default:
c.JSON(http.StatusOK, newErrorResponse(err))
return
}
}
}
func fallbackToFrontend(adminFrontend, userFrontend fs.FS) func(*gin.Context) {
checkLocalFileOrFs := func(c *gin.Context, fs fs.FS, path string) bool {
if _, err := os.Stat(path); err == nil {
c.File(path)
return true
}
f, err := fs.Open(path)
// load templates
t, err := ret.ParseGlob(fmt.Sprintf("resource/template/%s/*.html", theme.Name()))
if err != nil {
return false
}
defer f.Close()
fileStat, err := f.Stat()
if err != nil {
return false
}
if fileStat.IsDir() {
return false
}
http.ServeContent(c.Writer, c.Request, path, fileStat.ModTime(), f.(io.ReadSeeker))
return true
}
return func(c *gin.Context) {
if strings.HasPrefix(c.Request.URL.Path, "/api") {
c.JSON(http.StatusOK, newErrorResponse(errors.New("404 Not Found")))
return
}
if strings.HasPrefix(c.Request.URL.Path, "/dashboard") {
stripPath := strings.TrimPrefix(c.Request.URL.Path, "/dashboard")
localFilePath := path.Join("admin-dist", stripPath)
if checkLocalFileOrFs(c, adminFrontend, localFilePath) {
return
}
if !checkLocalFileOrFs(c, adminFrontend, "admin-dist/index.html") {
c.JSON(http.StatusOK, newErrorResponse(errors.New("404 Not Found")))
}
return
}
localFilePath := path.Join("user-dist", c.Request.URL.Path)
if checkLocalFileOrFs(c, userFrontend, localFilePath) {
return
}
if !checkLocalFileOrFs(c, userFrontend, "user-dist/index.html") {
c.JSON(http.StatusOK, newErrorResponse(errors.New("404 Not Found")))
log.Printf("NEZHA>> Error parsing templates %s error: %v", theme.Name(), err)
continue
}
ret = t
}
return ret
}
var funcMap = template.FuncMap{
"tr": func(id string, dataAndCount ...interface{}) string {
conf := i18n.LocalizeConfig{
MessageID: id,
}
if len(dataAndCount) > 0 {
conf.TemplateData = dataAndCount[0]
}
if len(dataAndCount) > 1 {
conf.PluralCount = dataAndCount[1]
}
return singleton.Localizer.MustLocalize(&conf)
},
"toValMap": func(val interface{}) map[string]interface{} {
return map[string]interface{}{
"Value": val,
}
},
"tf": func(t time.Time) string {
return t.In(singleton.Loc).Format("01/02/2006 15:04:05")
},
"len": func(slice []interface{}) string {
return strconv.Itoa(len(slice))
},
"safe": func(s string) template.HTML {
return template.HTML(s) // #nosec
},
"tag": func(s string) template.HTML {
return template.HTML(`<` + s + `>`) // #nosec
},
"stf": func(s uint64) string {
return time.Unix(int64(s), 0).In(singleton.Loc).Format("01/02/2006 15:04")
},
"sf": func(duration uint64) string {
return time.Duration(time.Duration(duration) * time.Second).String()
},
"sft": func(future time.Time) string {
return time.Until(future).Round(time.Second).String()
},
"bf": func(b uint64) string {
return bytefmt.ByteSize(b)
},
"ts": func(s string) string {
return strings.TrimSpace(s)
},
"float32f": func(f float32) string {
return fmt.Sprintf("%.3f", f)
},
"divU64": func(a, b uint64) float32 {
if b == 0 {
if a > 0 {
return 100
}
return 0
}
if a == 0 {
// 这是从未在线的情况
return 0.00001 / float32(b) * 100
}
return float32(a) / float32(b) * 100
},
"div": func(a, b int) float32 {
if b == 0 {
if a > 0 {
return 100
}
return 0
}
if a == 0 {
// 这是从未在线的情况
return 0.00001 / float32(b) * 100
}
return float32(a) / float32(b) * 100
},
"addU64": func(a, b uint64) uint64 {
return a + b
},
"add": func(a, b int) int {
return a + b
},
"TransLeftPercent": func(a, b float64) (n float64) {
n, _ = strconv.ParseFloat(fmt.Sprintf("%.2f", (100-(a/b)*100)), 64)
if n < 0 {
n = 0
}
return
},
"TransLeft": func(a, b uint64) string {
if a < b {
return "0B"
}
return bytefmt.ByteSize(a - b)
},
"TransClassName": func(a float64) string {
if a == 0 {
return "offline"
}
if a > 50 {
return "fine"
}
if a > 20 {
return "warning"
}
if a > 0 {
return "error"
}
return "offline"
},
"UintToFloat": func(a uint64) (n float64) {
n, _ = strconv.ParseFloat((strconv.FormatUint(a, 10)), 64)
return
},
"dayBefore": func(i int) string {
year, month, day := time.Now().Date()
today := time.Date(year, month, day, 0, 0, 0, 0, singleton.Loc)
return today.AddDate(0, 0, i-29).Format("01/02")
},
"className": func(percent float32) string {
if percent == 0 {
return ""
}
if percent > 95 {
return "good"
}
if percent > 80 {
return "warning"
}
return "danger"
},
"statusName": func(val float32) string {
return singleton.StatusCodeToString(singleton.GetStatusCode(val))
},
}

View File

@ -1,193 +0,0 @@
package controller
import (
"fmt"
"strconv"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List schedule tasks
// @Summary List schedule tasks
// @Security BearerAuth
// @Schemes
// @Description List schedule tasks
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.Cron]
// @Router /cron [get]
func listCron(c *gin.Context) ([]*model.Cron, error) {
singleton.CronLock.RLock()
defer singleton.CronLock.RUnlock()
var cr []*model.Cron
if err := copier.Copy(&cr, &singleton.CronList); err != nil {
return nil, err
}
return cr, nil
}
// Create new schedule task
// @Summary Create new schedule task
// @Security BearerAuth
// @Schemes
// @Description Create new schedule task
// @Tags auth required
// @Accept json
// @param request body model.CronForm true "CronForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /cron [post]
func createCron(c *gin.Context) (uint64, error) {
var cf model.CronForm
var cr model.Cron
if err := c.ShouldBindJSON(&cf); err != nil {
return 0, err
}
cr.TaskType = cf.TaskType
cr.Name = cf.Name
cr.Scheduler = cf.Scheduler
cr.Command = cf.Command
cr.Servers = cf.Servers
cr.PushSuccessful = cf.PushSuccessful
cr.NotificationGroupID = cf.NotificationGroupID
cr.Cover = cf.Cover
if cr.TaskType == model.CronTypeCronTask && cr.Cover == model.CronCoverAlertTrigger {
return 0, singleton.Localizer.ErrorT("scheduled tasks cannot be triggered by alarms")
}
// 对于计划任务类型需要更新CronJob
var err error
if cf.TaskType == model.CronTypeCronTask {
if cr.CronJobID, err = singleton.Cron.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil {
return 0, err
}
}
if err = singleton.DB.Create(&cr).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddCron(&cr)
singleton.UpdateCronList()
return cr.ID, nil
}
// Update schedule task
// @Summary Update schedule task
// @Security BearerAuth
// @Schemes
// @Description Update schedule task
// @Tags auth required
// @Accept json
// @param id path uint true "Task ID"
// @param request body model.CronForm true "CronForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /cron/{id} [patch]
func updateCron(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var cf model.CronForm
if err := c.ShouldBindJSON(&cf); err != nil {
return 0, err
}
var cr model.Cron
if err := singleton.DB.First(&cr, id).Error; err != nil {
return nil, fmt.Errorf("task id %d does not exist", id)
}
cr.TaskType = cf.TaskType
cr.Name = cf.Name
cr.Scheduler = cf.Scheduler
cr.Command = cf.Command
cr.Servers = cf.Servers
cr.PushSuccessful = cf.PushSuccessful
cr.NotificationGroupID = cf.NotificationGroupID
cr.Cover = cf.Cover
if cr.TaskType == model.CronTypeCronTask && cr.Cover == model.CronCoverAlertTrigger {
return nil, singleton.Localizer.ErrorT("scheduled tasks cannot be triggered by alarms")
}
// 对于计划任务类型需要更新CronJob
if cf.TaskType == model.CronTypeCronTask {
if cr.CronJobID, err = singleton.Cron.AddFunc(cr.Scheduler, singleton.CronTrigger(&cr)); err != nil {
return nil, err
}
}
if err = singleton.DB.Save(&cr).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnRefreshOrAddCron(&cr)
singleton.UpdateCronList()
return nil, nil
}
// Trigger schedule task
// @Summary Trigger schedule task
// @Security BearerAuth
// @Schemes
// @Description Trigger schedule task
// @Tags auth required
// @Accept json
// @param id path uint true "Task ID"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /cron/{id}/manual [get]
func manualTriggerCron(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var cr model.Cron
if err := singleton.DB.First(&cr, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("task id %d does not exist", id)
}
singleton.ManualTrigger(&cr)
return nil, nil
}
// Batch delete schedule tasks
// @Summary Batch delete schedule tasks
// @Security BearerAuth
// @Schemes
// @Description Batch delete schedule tasks
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/cron [post]
func batchDeleteCron(c *gin.Context) (any, error) {
var cr []uint64
if err := c.ShouldBindJSON(&cr); err != nil {
return nil, err
}
if err := singleton.DB.Unscoped().Delete(&model.Cron{}, "id in (?)", cr).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDeleteCron(cr)
singleton.UpdateCronList()
return nil, nil
}

View File

@ -1,202 +0,0 @@
package controller
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"golang.org/x/net/idna"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List DDNS Profiles
// @Summary List DDNS profiles
// @Schemes
// @Description List DDNS profiles
// @Security BearerAuth
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.DDNSProfile]
// @Router /ddns [get]
func listDDNS(c *gin.Context) ([]*model.DDNSProfile, error) {
var ddnsProfiles []*model.DDNSProfile
singleton.DDNSCacheLock.RLock()
defer singleton.DDNSCacheLock.RUnlock()
if err := copier.Copy(&ddnsProfiles, &singleton.DDNSList); err != nil {
return nil, err
}
return ddnsProfiles, nil
}
// Add DDNS profile
// @Summary Add DDNS profile
// @Security BearerAuth
// @Schemes
// @Description Add DDNS profile
// @Tags auth required
// @Accept json
// @param request body model.DDNSForm true "DDNS Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /ddns [post]
func createDDNS(c *gin.Context) (uint64, error) {
var df model.DDNSForm
var p model.DDNSProfile
if err := c.ShouldBindJSON(&df); err != nil {
return 0, err
}
if df.MaxRetries < 1 || df.MaxRetries > 10 {
return 0, singleton.Localizer.ErrorT("the retry count must be an integer between 1 and 10")
}
p.Name = df.Name
enableIPv4 := df.EnableIPv4
enableIPv6 := df.EnableIPv6
p.EnableIPv4 = &enableIPv4
p.EnableIPv6 = &enableIPv6
p.MaxRetries = df.MaxRetries
p.Provider = df.Provider
p.Domains = df.Domains
p.AccessID = df.AccessID
p.AccessSecret = df.AccessSecret
p.WebhookURL = df.WebhookURL
p.WebhookMethod = df.WebhookMethod
p.WebhookRequestType = df.WebhookRequestType
p.WebhookRequestBody = df.WebhookRequestBody
p.WebhookHeaders = df.WebhookHeaders
for n, domain := range p.Domains {
// IDN to ASCII
domainValid, domainErr := idna.Lookup.ToASCII(domain)
if domainErr != nil {
return 0, singleton.Localizer.ErrorT("error parsing %s: %v", domain, domainErr)
}
p.Domains[n] = domainValid
}
if err := singleton.DB.Create(&p).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnDDNSUpdate(&p)
singleton.UpdateDDNSList()
return p.ID, nil
}
// Edit DDNS profile
// @Summary Edit DDNS profile
// @Security BearerAuth
// @Schemes
// @Description Edit DDNS profile
// @Tags auth required
// @Accept json
// @param id path uint true "Profile ID"
// @param request body model.DDNSForm true "DDNS Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /ddns/{id} [patch]
func updateDDNS(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var df model.DDNSForm
if err := c.ShouldBindJSON(&df); err != nil {
return nil, err
}
if df.MaxRetries < 1 || df.MaxRetries > 10 {
return nil, singleton.Localizer.ErrorT("the retry count must be an integer between 1 and 10")
}
var p model.DDNSProfile
if err = singleton.DB.First(&p, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id)
}
p.Name = df.Name
enableIPv4 := df.EnableIPv4
enableIPv6 := df.EnableIPv6
p.EnableIPv4 = &enableIPv4
p.EnableIPv6 = &enableIPv6
p.MaxRetries = df.MaxRetries
p.Provider = df.Provider
p.Domains = df.Domains
p.AccessID = df.AccessID
p.AccessSecret = df.AccessSecret
p.WebhookURL = df.WebhookURL
p.WebhookMethod = df.WebhookMethod
p.WebhookRequestType = df.WebhookRequestType
p.WebhookRequestBody = df.WebhookRequestBody
p.WebhookHeaders = df.WebhookHeaders
for n, domain := range p.Domains {
// IDN to ASCII
domainValid, domainErr := idna.Lookup.ToASCII(domain)
if domainErr != nil {
return nil, singleton.Localizer.ErrorT("error parsing %s: %v", domain, domainErr)
}
p.Domains[n] = domainValid
}
if err = singleton.DB.Save(&p).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDDNSUpdate(&p)
singleton.UpdateDDNSList()
return nil, nil
}
// Batch delete DDNS configurations
// @Summary Batch delete DDNS configurations
// @Security BearerAuth
// @Schemes
// @Description Batch delete DDNS configurations
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/ddns [post]
func batchDeleteDDNS(c *gin.Context) (any, error) {
var ddnsConfigs []uint64
if err := c.ShouldBindJSON(&ddnsConfigs); err != nil {
return nil, err
}
if err := singleton.DB.Unscoped().Delete(&model.DDNSProfile{}, "id in (?)", ddnsConfigs).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDDNSDelete(ddnsConfigs)
singleton.UpdateDDNSList()
return nil, nil
}
// List DDNS Providers
// @Summary List DDNS providers
// @Schemes
// @Description List DDNS providers
// @Security BearerAuth
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]string]
// @Router /ddns/providers [get]
func listProviders(c *gin.Context) ([]string, error) {
return model.ProviderList, nil
}

View File

@ -1,103 +0,0 @@
package controller
import (
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/hashicorp/go-uuid"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/pkg/websocketx"
"github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// Create FM session
// @Summary Create FM session
// @Description Create an "attached" FM. It is advised to only call this within a terminal session.
// @Tags auth required
// @Accept json
// @Param id query uint true "Server ID"
// @Produce json
// @Success 200 {object} model.CreateFMResponse
// @Router /file [get]
func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
idStr := c.Query("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
streamId, err := uuid.GenerateUUID()
if err != nil {
return nil, err
}
rpc.NezhaHandlerSingleton.CreateStream(streamId)
singleton.ServerLock.RLock()
server := singleton.ServerList[id]
singleton.ServerLock.RUnlock()
if server == nil || server.TaskStream == nil {
return nil, singleton.Localizer.ErrorT("server not found or not connected")
}
fmData, _ := utils.Json.Marshal(&model.TaskFM{
StreamID: streamId,
})
if err := server.TaskStream.Send(&proto.Task{
Type: model.TaskTypeFM,
Data: string(fmData),
}); err != nil {
return nil, err
}
return &model.CreateFMResponse{
SessionID: streamId,
}, nil
}
// Start FM stream
// @Summary Start FM stream
// @Description Start FM stream
// @Tags auth required
// @Param id path string true "Stream UUID"
// @Success 200 {object} model.CommonResponse[any]
// @Router /ws/file/{id} [get]
func fmStream(c *gin.Context) (any, error) {
streamId := c.Param("id")
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
return nil, err
}
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return nil, newWsError("%v", err)
}
defer wsConn.Close()
conn := websocketx.NewConn(wsConn)
go func() {
// PING 保活
for {
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
time.Sleep(time.Second * 10)
}
}()
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
return nil, newWsError("%v", err)
}
if err = rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10); err != nil {
return nil, newWsError("%v", err)
}
return nil, newWsError("")
}

View File

@ -0,0 +1,58 @@
package controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"github.com/nicksnyder/go-i18n/v2/i18n"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/service/singleton"
)
type guestPage struct {
r *gin.Engine
}
func (gp *guestPage) serve() {
gr := gp.r.Group("")
gr.Use(mygin.Authorize(mygin.AuthorizeOption{
GuestOnly: true,
IsPage: true,
Msg: "您已登录",
Btn: "返回首页",
Redirect: "/",
}))
gr.GET("/login", gp.login)
oauth := &oauth2controller{
r: gr,
}
oauth.serve()
}
func (gp *guestPage) login(c *gin.Context) {
LoginType := "GitHub"
RegistrationLink := "https://github.com/join"
if singleton.Conf.Oauth2.Type == model.ConfigTypeGitee {
LoginType = "Gitee"
RegistrationLink = "https://gitee.com/signup"
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitlab {
LoginType = "Gitlab"
RegistrationLink = "https://gitlab.com/users/sign_up"
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeJihulab {
LoginType = "Jihulab"
RegistrationLink = "https://jihulab.com/users/sign_up"
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitea {
LoginType = "Gitea"
RegistrationLink = fmt.Sprintf("%s/user/sign_up", singleton.Conf.Oauth2.Endpoint)
}
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/login", mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Login"}),
"LoginType": LoginType,
"RegistrationLink": RegistrationLink,
}))
}

View File

@ -1,180 +0,0 @@
package controller
import (
"encoding/json"
"net/http"
"time"
jwt "github.com/appleboy/gin-jwt/v2"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
func initParams() *jwt.GinJWTMiddleware {
return &jwt.GinJWTMiddleware{
Realm: singleton.Conf.SiteName,
Key: []byte(singleton.Conf.JWTSecretKey),
CookieName: "nz-jwt",
SendCookie: true,
Timeout: time.Hour,
MaxRefresh: time.Hour,
IdentityKey: model.CtxKeyAuthorizedUser,
PayloadFunc: payloadFunc(),
IdentityHandler: identityHandler(),
Authenticator: authenticator(),
Authorizator: authorizator(),
Unauthorized: unauthorized(),
TokenLookup: "header: Authorization, query: token, cookie: nz-jwt",
TokenHeadName: "Bearer",
TimeFunc: time.Now,
LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) {
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
Success: true,
Data: model.LoginResponse{
Token: token,
Expire: expire.Format(time.RFC3339),
},
})
},
RefreshResponse: refreshResponse,
}
}
func payloadFunc() func(data interface{}) jwt.MapClaims {
return func(data interface{}) jwt.MapClaims {
if v, ok := data.(string); ok {
return jwt.MapClaims{
model.CtxKeyAuthorizedUser: v,
}
}
return jwt.MapClaims{}
}
}
func identityHandler() func(c *gin.Context) interface{} {
return func(c *gin.Context) interface{} {
claims := jwt.ExtractClaims(c)
userId := claims[model.CtxKeyAuthorizedUser].(string)
var user model.User
if err := singleton.DB.First(&user, userId).Error; err != nil {
return nil
}
return &user
}
}
// User Login
// @Summary user login
// @Schemes
// @Description user login
// @Accept json
// @param loginRequest body model.LoginRequest true "Login Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.LoginResponse]
// @Router /login [post]
func authenticator() func(c *gin.Context) (interface{}, error) {
return func(c *gin.Context) (interface{}, error) {
var loginVals model.LoginRequest
if err := c.ShouldBind(&loginVals); err != nil {
return "", jwt.ErrMissingLoginValues
}
var user model.User
if err := singleton.DB.Select("id", "password").Where("username = ?", loginVals.Username).First(&user).Error; err != nil {
model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeLoginFail)
return nil, jwt.ErrFailedAuthentication
}
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(loginVals.Password)); err != nil {
model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeLoginFail)
return nil, jwt.ErrFailedAuthentication
}
return utils.Itoa(user.ID), nil
}
}
func authorizator() func(data interface{}, c *gin.Context) bool {
return func(data interface{}, c *gin.Context) bool {
_, ok := data.(*model.User)
return ok
}
}
func unauthorized() func(c *gin.Context, code int, message string) {
return func(c *gin.Context, code int, message string) {
c.JSON(http.StatusOK, model.CommonResponse[any]{
Success: false,
Error: "ApiErrorUnauthorized",
})
}
}
// Refresh token
// @Summary Refresh token
// @Security BearerAuth
// @Schemes
// @Description Refresh token
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[model.LoginResponse]
// @Router /refresh-token [get]
func refreshResponse(c *gin.Context, code int, token string, expire time.Time) {
c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
Success: true,
Data: model.LoginResponse{
Token: token,
Expire: expire.Format(time.RFC3339),
},
})
}
func optionalAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) {
return func(c *gin.Context) {
claims, err := mw.GetClaimsFromJWT(c)
if err != nil {
return
}
switch v := claims["exp"].(type) {
case nil:
return
case float64:
if int64(v) < mw.TimeFunc().Unix() {
return
}
case json.Number:
n, err := v.Int64()
if err != nil {
return
}
if n < mw.TimeFunc().Unix() {
return
}
default:
return
}
c.Set("JWT_PAYLOAD", claims)
identity := mw.IdentityHandler(c)
if identity != nil {
model.ClearIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr))
c.Set(mw.IdentityKey, identity)
} else {
if err := model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeBruteForceToken); err != nil {
waf.ShowBlockPage(c, err)
return
}
}
c.Next()
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,86 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/service/singleton"
"github.com/nicksnyder/go-i18n/v2/i18n"
)
type memberPage struct {
r *gin.Engine
}
func (mp *memberPage) serve() {
mr := mp.r.Group("")
mr.Use(mygin.Authorize(mygin.AuthorizeOption{
MemberOnly: true,
IsPage: true,
Msg: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "YouAreNotAuthorized"}),
Btn: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Login"}),
Redirect: "/login",
}))
mr.GET("/server", mp.server)
mr.GET("/monitor", mp.monitor)
mr.GET("/cron", mp.cron)
mr.GET("/notification", mp.notification)
mr.GET("/setting", mp.setting)
mr.GET("/api", mp.api)
}
func (mp *memberPage) api(c *gin.Context) {
singleton.ApiLock.RLock()
defer singleton.ApiLock.RUnlock()
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/api", mygin.CommonEnvironment(c, gin.H{
"title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ApiManagement"}),
"Tokens": singleton.ApiTokenList,
}))
}
func (mp *memberPage) server(c *gin.Context) {
singleton.SortedServerLock.RLock()
defer singleton.SortedServerLock.RUnlock()
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/server", mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ServersManagement"}),
"Servers": singleton.SortedServerList,
}))
}
func (mp *memberPage) monitor(c *gin.Context) {
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/monitor", mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ServicesManagement"}),
"Monitors": singleton.ServiceSentinelShared.Monitors(),
}))
}
func (mp *memberPage) cron(c *gin.Context) {
var crons []model.Cron
singleton.DB.Find(&crons)
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/cron", mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "ScheduledTasks"}),
"Crons": crons,
}))
}
func (mp *memberPage) notification(c *gin.Context) {
var nf []model.Notification
singleton.DB.Find(&nf)
var ar []model.AlertRule
singleton.DB.Find(&ar)
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/notification", mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Notification"}),
"Notifications": nf,
"AlertRules": ar,
}))
}
func (mp *memberPage) setting(c *gin.Context) {
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/setting", mygin.CommonEnvironment(c, gin.H{
"Title": singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{MessageID: "Settings"}),
"Languages": model.Languages,
"DashboardThemes": model.DashboardThemes,
}))
}

View File

@ -1,137 +0,0 @@
package controller
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List NAT Profiles
// @Summary List NAT profiles
// @Schemes
// @Description List NAT profiles
// @Security BearerAuth
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.NAT]
// @Router /nat [get]
func listNAT(c *gin.Context) ([]*model.NAT, error) {
var n []*model.NAT
singleton.NATCacheRwLock.RLock()
defer singleton.NATCacheRwLock.RUnlock()
if err := copier.Copy(&n, &singleton.NATList); err != nil {
return nil, err
}
return n, nil
}
// Add NAT profile
// @Summary Add NAT profile
// @Security BearerAuth
// @Schemes
// @Description Add NAT profile
// @Tags auth required
// @Accept json
// @param request body model.NATForm true "NAT Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /nat [post]
func createNAT(c *gin.Context) (uint64, error) {
var nf model.NATForm
var n model.NAT
if err := c.ShouldBindJSON(&nf); err != nil {
return 0, err
}
n.Name = nf.Name
n.Domain = nf.Domain
n.Host = nf.Host
n.ServerID = nf.ServerID
if err := singleton.DB.Create(&n).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnNATUpdate(&n)
singleton.UpdateNATList()
return n.ID, nil
}
// Edit NAT profile
// @Summary Edit NAT profile
// @Security BearerAuth
// @Schemes
// @Description Edit NAT profile
// @Tags auth required
// @Accept json
// @param id path uint true "Profile ID"
// @param request body model.NATForm true "NAT Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /nat/{id} [patch]
func updateNAT(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var nf model.NATForm
if err := c.ShouldBindJSON(&nf); err != nil {
return nil, err
}
var n model.NAT
if err = singleton.DB.First(&n, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id)
}
n.Name = nf.Name
n.Domain = nf.Domain
n.Host = nf.Host
n.ServerID = nf.ServerID
if err := singleton.DB.Save(&n).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnNATUpdate(&n)
singleton.UpdateNATList()
return nil, nil
}
// Batch delete NAT configurations
// @Summary Batch delete NAT configurations
// @Security BearerAuth
// @Schemes
// @Description Batch delete NAT configurations
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/nat [post]
func batchDeleteNAT(c *gin.Context) (any, error) {
var n []uint64
if err := c.ShouldBindJSON(&n); err != nil {
return nil, err
}
if err := singleton.DB.Unscoped().Delete(&model.NAT{}, "id in (?)", n).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnNATDelete(n)
singleton.UpdateNATList()
return nil, nil
}

View File

@ -1,174 +0,0 @@
package controller
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
"gorm.io/gorm"
)
// List notification
// @Summary List notification
// @Security BearerAuth
// @Schemes
// @Description List notification
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.Notification]
// @Router /notification [get]
func listNotification(c *gin.Context) ([]*model.Notification, error) {
singleton.NotificationsLock.RLock()
defer singleton.NotificationsLock.RUnlock()
var notifications []*model.Notification
if err := copier.Copy(&notifications, &singleton.NotificationListSorted); err != nil {
return nil, err
}
return notifications, nil
}
// Add notification
// @Summary Add notification
// @Security BearerAuth
// @Schemes
// @Description Add notification
// @Tags auth required
// @Accept json
// @param request body model.NotificationForm true "NotificationForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /notification [post]
func createNotification(c *gin.Context) (uint64, error) {
var nf model.NotificationForm
if err := c.ShouldBindJSON(&nf); err != nil {
return 0, err
}
var n model.Notification
n.Name = nf.Name
n.RequestMethod = nf.RequestMethod
n.RequestType = nf.RequestType
n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody
n.URL = nf.URL
verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS
ns := model.NotificationServerBundle{
Notification: &n,
Server: nil,
Loc: singleton.Loc,
}
// 未勾选跳过检查
if !nf.SkipCheck {
if err := ns.Send(singleton.Localizer.T("a test message")); err != nil {
return 0, err
}
}
if err := singleton.DB.Create(&n).Error; err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddNotification(&n)
singleton.UpdateNotificationList()
return n.ID, nil
}
// Edit notification
// @Summary Edit notification
// @Security BearerAuth
// @Schemes
// @Description Edit notification
// @Tags auth required
// @Accept json
// @Param id path uint true "Notification ID"
// @Param body body model.NotificationForm true "NotificationForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /notification/{id} [patch]
func updateNotification(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var nf model.NotificationForm
if err := c.ShouldBindJSON(&nf); err != nil {
return nil, err
}
var n model.Notification
if err := singleton.DB.First(&n, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("notification id %d does not exist", id)
}
n.Name = nf.Name
n.RequestMethod = nf.RequestMethod
n.RequestType = nf.RequestType
n.RequestHeader = nf.RequestHeader
n.RequestBody = nf.RequestBody
n.URL = nf.URL
verifyTLS := nf.VerifyTLS
n.VerifyTLS = &verifyTLS
ns := model.NotificationServerBundle{
Notification: &n,
Server: nil,
Loc: singleton.Loc,
}
// 未勾选跳过检查
if !nf.SkipCheck {
if err := ns.Send(singleton.Localizer.T("a test message")); err != nil {
return nil, err
}
}
if err := singleton.DB.Save(&n).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.OnRefreshOrAddNotification(&n)
singleton.UpdateNotificationList()
return nil, nil
}
// Batch delete notifications
// @Summary Batch delete notifications
// @Security BearerAuth
// @Schemes
// @Description Batch delete notifications
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/notification [post]
func batchDeleteNotification(c *gin.Context) (any, error) {
var n []uint64
if err := c.ShouldBindJSON(&n); err != nil {
return nil, err
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.Notification{}, "id in (?)", n).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.NotificationGroupNotification{}, "notification_id in (?)", n).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDeleteNotification(n)
singleton.UpdateNotificationList()
return nil, nil
}

View File

@ -1,204 +0,0 @@
package controller
import (
"slices"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List notification group
// @Summary List notification group
// @Schemes
// @Description List notification group
// @Security BearerAuth
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.NotificationGroupResponseItem]
// @Router /notification-group [get]
func listNotificationGroup(c *gin.Context) ([]model.NotificationGroupResponseItem, error) {
var ng []model.NotificationGroup
if err := singleton.DB.Find(&ng).Error; err != nil {
return nil, err
}
var ngn []model.NotificationGroupNotification
if err := singleton.DB.Find(&ngn).Error; err != nil {
return nil, err
}
groupNotifications := make(map[uint64][]uint64, len(ng))
for _, n := range ngn {
if _, ok := groupNotifications[n.NotificationGroupID]; !ok {
groupNotifications[n.NotificationGroupID] = make([]uint64, 0)
}
groupNotifications[n.NotificationGroupID] = append(groupNotifications[n.NotificationGroupID], n.NotificationID)
}
ngRes := make([]model.NotificationGroupResponseItem, 0, len(ng))
for _, n := range ng {
ngRes = append(ngRes, model.NotificationGroupResponseItem{
Group: n,
Notifications: groupNotifications[n.ID],
})
}
return ngRes, nil
}
// New notification group
// @Summary New notification group
// @Schemes
// @Description New notification group
// @Security BearerAuth
// @Tags auth required
// @Accept json
// @Param body body model.NotificationGroupForm true "NotificationGroupForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /notification-group [post]
func createNotificationGroup(c *gin.Context) (uint64, error) {
var ngf model.NotificationGroupForm
if err := c.ShouldBindJSON(&ngf); err != nil {
return 0, err
}
ngf.Notifications = slices.Compact(ngf.Notifications)
var ng model.NotificationGroup
ng.Name = ngf.Name
var count int64
if err := singleton.DB.Model(&model.Notification{}).Where("id in (?)", ngf.Notifications).Count(&count).Error; err != nil {
return 0, newGormError("%v", err)
}
if count != int64(len(ngf.Notifications)) {
return 0, singleton.Localizer.ErrorT("have invalid notification id")
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&ng).Error; err != nil {
return err
}
for _, n := range ngf.Notifications {
if err := tx.Create(&model.NotificationGroupNotification{
NotificationGroupID: ng.ID,
NotificationID: n,
}).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, newGormError("%v", err)
}
singleton.OnRefreshOrAddNotificationGroup(&ng, ngf.Notifications)
return ng.ID, nil
}
// Edit notification group
// @Summary Edit notification group
// @Schemes
// @Description Edit notification group
// @Security BearerAuth
// @Tags auth required
// @Accept json
// @Param id path uint true "ID"
// @Param body body model.NotificationGroupForm true "NotificationGroupForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /notification-group/{id} [patch]
func updateNotificationGroup(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var ngf model.NotificationGroupForm
if err := c.ShouldBindJSON(&ngf); err != nil {
return nil, err
}
var ngDB model.NotificationGroup
if err := singleton.DB.First(&ngDB, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("group id %d does not exist", id)
}
ngDB.Name = ngf.Name
ngf.Notifications = slices.Compact(ngf.Notifications)
var count int64
if err := singleton.DB.Model(&model.Server{}).Where("id in (?)", ngf.Notifications).Count(&count).Error; err != nil {
return nil, newGormError("%v", err)
}
if count != int64(len(ngf.Notifications)) {
return nil, singleton.Localizer.ErrorT("have invalid notification id")
}
err = singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&ngDB).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.NotificationGroupNotification{}, "notification_group_id = ?", id).Error; err != nil {
return err
}
for _, n := range ngf.Notifications {
if err := tx.Create(&model.NotificationGroupNotification{
NotificationGroupID: ngDB.ID,
NotificationID: n,
}).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, newGormError("%v", err)
}
singleton.OnRefreshOrAddNotificationGroup(&ngDB, ngf.Notifications)
return nil, nil
}
// Batch delete notification group
// @Summary Batch delete notification group
// @Security BearerAuth
// @Schemes
// @Description Batch delete notification group
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/notification-group [post]
func batchDeleteNotificationGroup(c *gin.Context) (any, error) {
var ngn []uint64
if err := c.ShouldBindJSON(&ngn); err != nil {
return nil, err
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.NotificationGroup{}, "id in (?)", ngn).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.NotificationGroupNotification{}, "notification_group_id in (?)", ngn).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, newGormError("%v", err)
}
singleton.OnDeleteNotificationGroup(ngn)
return nil, nil
}

View File

@ -0,0 +1,222 @@
package controller
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"code.gitea.io/sdk/gitea"
"github.com/gin-gonic/gin"
GitHubAPI "github.com/google/go-github/v47/github"
"github.com/patrickmn/go-cache"
"github.com/xanzy/go-gitlab"
"golang.org/x/oauth2"
GitHubOauth2 "golang.org/x/oauth2/github"
GitlabOauth2 "golang.org/x/oauth2/gitlab"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/pkg/mygin"
"github.com/naiba/nezha/pkg/utils"
"github.com/naiba/nezha/service/singleton"
)
type oauth2controller struct {
r gin.IRoutes
}
func (oa *oauth2controller) serve() {
oa.r.GET("/oauth2/login", oa.login)
oa.r.GET("/oauth2/callback", oa.callback)
}
func (oa *oauth2controller) getCommonOauth2Config(c *gin.Context) *oauth2.Config {
if singleton.Conf.Oauth2.Type == model.ConfigTypeGitee {
return &oauth2.Config{
ClientID: singleton.Conf.Oauth2.ClientID,
ClientSecret: singleton.Conf.Oauth2.ClientSecret,
Scopes: []string{},
Endpoint: oauth2.Endpoint{
AuthURL: "https://gitee.com/oauth/authorize",
TokenURL: "https://gitee.com/oauth/token",
},
RedirectURL: oa.getRedirectURL(c),
}
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitlab {
return &oauth2.Config{
ClientID: singleton.Conf.Oauth2.ClientID,
ClientSecret: singleton.Conf.Oauth2.ClientSecret,
Scopes: []string{"read_user", "read_api"},
Endpoint: GitlabOauth2.Endpoint,
RedirectURL: oa.getRedirectURL(c),
}
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeJihulab {
return &oauth2.Config{
ClientID: singleton.Conf.Oauth2.ClientID,
ClientSecret: singleton.Conf.Oauth2.ClientSecret,
Scopes: []string{"read_user", "read_api"},
Endpoint: oauth2.Endpoint{
AuthURL: "https://jihulab.com/oauth/authorize",
TokenURL: "https://jihulab.com/oauth/token",
},
RedirectURL: oa.getRedirectURL(c),
}
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitea {
return &oauth2.Config{
ClientID: singleton.Conf.Oauth2.ClientID,
ClientSecret: singleton.Conf.Oauth2.ClientSecret,
Endpoint: oauth2.Endpoint{
AuthURL: fmt.Sprintf("%s/login/oauth/authorize", singleton.Conf.Oauth2.Endpoint),
TokenURL: fmt.Sprintf("%s/login/oauth/access_token", singleton.Conf.Oauth2.Endpoint),
},
RedirectURL: oa.getRedirectURL(c),
}
} else {
return &oauth2.Config{
ClientID: singleton.Conf.Oauth2.ClientID,
ClientSecret: singleton.Conf.Oauth2.ClientSecret,
Scopes: []string{},
Endpoint: GitHubOauth2.Endpoint,
}
}
}
func (oa *oauth2controller) getRedirectURL(c *gin.Context) string {
scheme := "http://"
if strings.HasPrefix(c.Request.Referer(), "https://") {
scheme = "https://"
}
return scheme + c.Request.Host + "/oauth2/callback"
}
func (oa *oauth2controller) login(c *gin.Context) {
randomString, err := utils.GenerateRandomString(32)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusBadRequest,
Title: "Something Wrong",
Msg: err.Error(),
}, true)
return
}
state, stateKey := randomString[:16], randomString[16:]
singleton.Cache.Set(fmt.Sprintf("%s%s", model.CacheKeyOauth2State, stateKey), state, cache.DefaultExpiration)
url := oa.getCommonOauth2Config(c).AuthCodeURL(state, oauth2.AccessTypeOnline)
c.SetCookie(singleton.Conf.Site.CookieName+"-sk", stateKey, 60*5, "", "", false, false)
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/redirect", mygin.CommonEnvironment(c, gin.H{
"URL": url,
}))
}
func (oa *oauth2controller) callback(c *gin.Context) {
var err error
// 验证登录跳转时的 State
stateKey, err := c.Cookie(singleton.Conf.Site.CookieName + "-sk")
if err == nil {
state, ok := singleton.Cache.Get(fmt.Sprintf("%s%s", model.CacheKeyOauth2State, stateKey))
if !ok || state.(string) != c.Query("state") {
err = errors.New("非法的登录方式")
}
}
oauth2Config := oa.getCommonOauth2Config(c)
ctx := context.Background()
var otk *oauth2.Token
if err == nil {
otk, err = oauth2Config.Exchange(ctx, c.Query("code"))
}
var user model.User
if err == nil {
if singleton.Conf.Oauth2.Type == model.ConfigTypeGitlab || singleton.Conf.Oauth2.Type == model.ConfigTypeJihulab {
var gitlabApiClient *gitlab.Client
if singleton.Conf.Oauth2.Type == model.ConfigTypeGitlab {
gitlabApiClient, err = gitlab.NewOAuthClient(otk.AccessToken)
} else {
gitlabApiClient, err = gitlab.NewOAuthClient(otk.AccessToken, gitlab.WithBaseURL("https://jihulab.com/api/v4/"))
}
var u *gitlab.User
if err == nil {
u, _, err = gitlabApiClient.Users.CurrentUser()
}
if err == nil {
user = model.NewUserFromGitlab(u)
}
} else if singleton.Conf.Oauth2.Type == model.ConfigTypeGitea {
var giteaApiClient *gitea.Client
giteaApiClient, err = gitea.NewClient(singleton.Conf.Oauth2.Endpoint, gitea.SetToken(otk.AccessToken))
var u *gitea.User
if err == nil {
u, _, err = giteaApiClient.GetMyUserInfo()
}
if err == nil {
user = model.NewUserFromGitea(u)
}
} else {
var client *GitHubAPI.Client
oc := oauth2Config.Client(ctx, otk)
if singleton.Conf.Oauth2.Type == model.ConfigTypeGitee {
baseURL, _ := url.Parse("https://gitee.com/api/v5/")
uploadURL, _ := url.Parse("https://gitee.com/api/v5/uploads/")
client = GitHubAPI.NewClient(oc)
client.BaseURL = baseURL
client.UploadURL = uploadURL
} else {
client = GitHubAPI.NewClient(oc)
}
var gu *GitHubAPI.User
if err == nil {
gu, _, err = client.Users.Get(ctx, "")
}
if err == nil {
user = model.NewUserFromGitHub(gu)
}
}
}
if err == nil && user.Login == "" {
err = errors.New("获取用户信息失败")
}
if err != nil || user.Login == "" {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusBadRequest,
Title: "登录失败",
Msg: fmt.Sprintf("错误信息:%s", err),
}, true)
return
}
var isAdmin bool
for _, admin := range strings.Split(singleton.Conf.Oauth2.Admin, ",") {
if admin != "" && strings.EqualFold(user.Login, admin) {
isAdmin = true
break
}
}
if !isAdmin {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusBadRequest,
Title: "登录失败",
Msg: fmt.Sprintf("错误信息:%s", "该用户不是本站点管理员,无法登录"),
}, true)
return
}
user.Token, err = utils.GenerateRandomString(32)
if err != nil {
mygin.ShowErrorPage(c, mygin.ErrInfo{
Code: http.StatusBadRequest,
Title: "Something wrong",
Msg: err.Error(),
}, true)
return
}
user.TokenExpired = time.Now().AddDate(0, 2, 0)
singleton.DB.Save(&user)
c.SetCookie(singleton.Conf.Site.CookieName, user.Token, 60*60*24, "", "", false, false)
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/redirect", mygin.CommonEnvironment(c, gin.H{
"URL": "/",
}))
}

View File

@ -1,166 +0,0 @@
package controller
import (
"strconv"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
pb "github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton"
)
// List server
// @Summary List server
// @Security BearerAuth
// @Schemes
// @Description List server
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.Server]
// @Router /server [get]
func listServer(c *gin.Context) ([]*model.Server, error) {
singleton.SortedServerLock.RLock()
defer singleton.SortedServerLock.RUnlock()
var ssl []*model.Server
if err := copier.Copy(&ssl, &singleton.SortedServerList); err != nil {
return nil, err
}
return ssl, nil
}
// Edit server
// @Summary Edit server
// @Security BearerAuth
// @Schemes
// @Description Edit server
// @Tags auth required
// @Accept json
// @Param id path uint true "Server ID"
// @Param body body model.ServerForm true "ServerForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /server/{id} [patch]
func updateServer(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var sf model.ServerForm
if err := c.ShouldBindJSON(&sf); err != nil {
return nil, err
}
var s model.Server
if err := singleton.DB.First(&s, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("server id %d does not exist", id)
}
s.Name = sf.Name
s.DisplayIndex = sf.DisplayIndex
s.Note = sf.Note
s.PublicNote = sf.PublicNote
s.HideForGuest = sf.HideForGuest
s.EnableDDNS = sf.EnableDDNS
s.DDNSProfiles = sf.DDNSProfiles
ddnsProfilesRaw, err := utils.Json.Marshal(s.DDNSProfiles)
if err != nil {
return nil, err
}
s.DDNSProfilesRaw = string(ddnsProfilesRaw)
if err := singleton.DB.Save(&s).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.ServerLock.Lock()
s.CopyFromRunningServer(singleton.ServerList[s.ID])
singleton.ServerList[s.ID] = &s
singleton.ServerLock.Unlock()
singleton.ReSortServer()
return nil, nil
}
// Batch delete server
// @Summary Batch delete server
// @Security BearerAuth
// @Schemes
// @Description Batch delete server
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/server [post]
func batchDeleteServer(c *gin.Context) (any, error) {
var servers []uint64
if err := c.ShouldBindJSON(&servers); err != nil {
return nil, err
}
if err := singleton.DB.Unscoped().Delete(&model.Server{}, "id in (?)", servers).Error; err != nil {
return nil, newGormError("%v", err)
}
singleton.AlertsLock.Lock()
for _, sid := range servers {
for _, alert := range singleton.Alerts {
if singleton.AlertsCycleTransferStatsStore[alert.ID] != nil {
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].ServerName, sid)
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].Transfer, sid)
delete(singleton.AlertsCycleTransferStatsStore[alert.ID].NextUpdate, sid)
}
}
}
singleton.DB.Unscoped().Delete(&model.Transfer{}, "server_id in (?)", servers)
singleton.AlertsLock.Unlock()
singleton.OnServerDelete(servers)
singleton.ReSortServer()
return nil, nil
}
// Force update Agent
// @Summary Force update Agent
// @Security BearerAuth
// @Schemes
// @Description Force update Agent
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ForceUpdateResponse]
// @Router /force-update/server [post]
func forceUpdateServer(c *gin.Context) (*model.ForceUpdateResponse, error) {
var forceUpdateServers []uint64
if err := c.ShouldBindJSON(&forceUpdateServers); err != nil {
return nil, err
}
forceUpdateResp := new(model.ForceUpdateResponse)
for _, sid := range forceUpdateServers {
singleton.ServerLock.RLock()
server := singleton.ServerList[sid]
singleton.ServerLock.RUnlock()
if server != nil && server.TaskStream != nil {
if err := server.TaskStream.Send(&pb.Task{
Type: model.TaskTypeUpgrade,
}); err != nil {
forceUpdateResp.Failure = append(forceUpdateResp.Failure, sid)
} else {
forceUpdateResp.Success = append(forceUpdateResp.Success, sid)
}
} else {
forceUpdateResp.Offline = append(forceUpdateResp.Offline, sid)
}
}
return forceUpdateResp, nil
}

View File

@ -1,199 +0,0 @@
package controller
import (
"slices"
"strconv"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List server group
// @Summary List server group
// @Schemes
// @Description List server group
// @Security BearerAuth
// @Tags common
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.ServerGroupResponseItem]
// @Router /server-group [get]
func listServerGroup(c *gin.Context) ([]model.ServerGroupResponseItem, error) {
var sg []model.ServerGroup
if err := singleton.DB.Find(&sg).Error; err != nil {
return nil, err
}
groupServers := make(map[uint64][]uint64, 0)
var sgs []model.ServerGroupServer
if err := singleton.DB.Find(&sgs).Error; err != nil {
return nil, err
}
for _, s := range sgs {
if _, ok := groupServers[s.ServerGroupId]; !ok {
groupServers[s.ServerGroupId] = make([]uint64, 0)
}
groupServers[s.ServerGroupId] = append(groupServers[s.ServerGroupId], s.ServerId)
}
var sgRes []model.ServerGroupResponseItem
for _, s := range sg {
sgRes = append(sgRes, model.ServerGroupResponseItem{
Group: s,
Servers: groupServers[s.ID],
})
}
return sgRes, nil
}
// New server group
// @Summary New server group
// @Schemes
// @Description New server group
// @Security BearerAuth
// @Tags auth required
// @Accept json
// @Param body body model.ServerGroupForm true "ServerGroupForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /server-group [post]
func createServerGroup(c *gin.Context) (uint64, error) {
var sgf model.ServerGroupForm
if err := c.ShouldBindJSON(&sgf); err != nil {
return 0, err
}
sgf.Servers = slices.Compact(sgf.Servers)
var sg model.ServerGroup
sg.Name = sgf.Name
var count int64
if err := singleton.DB.Model(&model.Server{}).Where("id in (?)", sgf.Servers).Count(&count).Error; err != nil {
return 0, newGormError("%v", err)
}
if count != int64(len(sgf.Servers)) {
return 0, singleton.Localizer.ErrorT("have invalid server id")
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&sg).Error; err != nil {
return err
}
for _, s := range sgf.Servers {
if err := tx.Create(&model.ServerGroupServer{
ServerGroupId: sg.ID,
ServerId: s,
}).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return 0, newGormError("%v", err)
}
return sg.ID, nil
}
// Edit server group
// @Summary Edit server group
// @Schemes
// @Description Edit server group
// @Security BearerAuth
// @Tags auth required
// @Accept json
// @Param id path uint true "ID"
// @Param body body model.ServerGroupForm true "ServerGroupForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /server-group/{id} [patch]
func updateServerGroup(c *gin.Context) (any, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
var sg model.ServerGroupForm
if err := c.ShouldBindJSON(&sg); err != nil {
return nil, err
}
sg.Servers = slices.Compact(sg.Servers)
var sgDB model.ServerGroup
if err := singleton.DB.First(&sgDB, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("group id %d does not exist", id)
}
sgDB.Name = sg.Name
var count int64
if err := singleton.DB.Model(&model.Server{}).Where("id in (?)", sg.Servers).Count(&count).Error; err != nil {
return nil, err
}
if count != int64(len(sg.Servers)) {
return nil, singleton.Localizer.ErrorT("have invalid server id")
}
err = singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Save(&sgDB).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.ServerGroupServer{}, "server_group_id = ?", id).Error; err != nil {
return err
}
for _, s := range sg.Servers {
if err := tx.Create(&model.ServerGroupServer{
ServerGroupId: sgDB.ID,
ServerId: s,
}).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, newGormError("%v", err)
}
return nil, nil
}
// Batch delete server group
// @Summary Batch delete server group
// @Security BearerAuth
// @Schemes
// @Description Batch delete server group
// @Tags auth required
// @Accept json
// @param request body []uint64 true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/server-group [post]
func batchDeleteServerGroup(c *gin.Context) (any, error) {
var sgs []uint64
if err := c.ShouldBindJSON(&sgs); err != nil {
return nil, err
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.ServerGroup{}, "id in (?)", sgs).Error; err != nil {
return err
}
if err := tx.Unscoped().Delete(&model.ServerGroupServer{}, "server_group_id in (?)", sgs).Error; err != nil {
return err
}
return nil
})
if err != nil {
return nil, newGormError("%v", err)
}
return nil, nil
}

View File

@ -1,313 +0,0 @@
package controller
import (
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/jinzhu/copier"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
"gorm.io/gorm"
)
// List service
// @Summary List service
// @Security BearerAuth
// @Schemes
// @Description List service
// @Tags common
// @Produce json
// @Success 200 {object} model.CommonResponse[model.ServiceResponse]
// @Router /service [get]
func listService(c *gin.Context) (*model.ServiceResponse, error) {
res, err, _ := requestGroup.Do("list-service", func() (interface{}, error) {
singleton.AlertsLock.RLock()
defer singleton.AlertsLock.RUnlock()
var stats map[uint64]model.ServiceResponseItem
var statsStore map[uint64]model.CycleTransferStats
copier.Copy(&stats, singleton.ServiceSentinelShared.LoadStats())
copier.Copy(&statsStore, singleton.AlertsCycleTransferStatsStore)
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied
for k, service := range stats {
if !authorized {
if !service.Service.EnableShowInService {
delete(stats, k)
}
service.Service = &model.Service{Name: service.Service.Name}
stats[k] = service
}
}
return []interface {
}{
stats, statsStore,
}, nil
})
if err != nil {
return nil, err
}
return &model.ServiceResponse{
Services: res.([]interface{})[0].(map[uint64]model.ServiceResponseItem),
CycleTransferStats: res.([]interface{})[1].(map[uint64]model.CycleTransferStats),
}, nil
}
// List service histories by server id
// @Summary List service histories by server id
// @Security BearerAuth
// @Schemes
// @Description List service histories by server id
// @Tags common
// @param id path uint true "Server ID"
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.ServiceInfos]
// @Router /service/{id} [get]
func listServiceHistory(c *gin.Context) ([]*model.ServiceInfos, error) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
return nil, err
}
singleton.ServerLock.RLock()
server, ok := singleton.ServerList[id]
if !ok {
return nil, singleton.Localizer.ErrorT("server not found")
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied
if server.HideForGuest && !authorized {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
singleton.ServerLock.RUnlock()
var serviceHistories []*model.ServiceHistory
if err := singleton.DB.Model(&model.ServiceHistory{}).Select("service_id, created_at, server_id, avg_delay").
Where("server_id = ?", id).Where("created_at >= ?", time.Now().Add(-24*time.Hour)).Order("service_id, created_at").
Scan(&serviceHistories).Error; err != nil {
return nil, err
}
singleton.ServiceSentinelShared.ServicesLock.RLock()
defer singleton.ServiceSentinelShared.ServicesLock.RUnlock()
singleton.ServerLock.RLock()
defer singleton.ServerLock.RUnlock()
var sortedServiceIDs []uint64
resultMap := make(map[uint64]*model.ServiceInfos)
for _, history := range serviceHistories {
infos, ok := resultMap[history.ServiceID]
if !ok {
infos = &model.ServiceInfos{
ServiceID: history.ServiceID,
ServerID: history.ServerID,
ServiceName: singleton.ServiceSentinelShared.Services[history.ServiceID].Name,
ServerName: singleton.ServerList[history.ServerID].Name,
}
resultMap[history.ServiceID] = infos
sortedServiceIDs = append(sortedServiceIDs, history.ServiceID)
}
infos.CreatedAt = append(infos.CreatedAt, history.CreatedAt.Truncate(time.Minute).Unix()*1000)
infos.AvgDelay = append(infos.AvgDelay, history.AvgDelay)
}
ret := make([]*model.ServiceInfos, 0, len(sortedServiceIDs))
for _, id := range sortedServiceIDs {
ret = append(ret, resultMap[id])
}
return ret, nil
}
// List server with service
// @Summary List server with service
// @Security BearerAuth
// @Schemes
// @Description List server with service
// @Tags common
// @Produce json
// @Success 200 {object} model.CommonResponse[[]uint64]
// @Router /service/server [get]
func listServerWithServices(c *gin.Context) ([]uint64, error) {
var serverIdsWithService []uint64
if err := singleton.DB.Model(&model.ServiceHistory{}).
Select("distinct(server_id)").
Where("server_id != 0").
Find(&serverIdsWithService).Error; err != nil {
return nil, newGormError("%v", err)
}
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied
var ret []uint64
for _, id := range serverIdsWithService {
singleton.ServerLock.RLock()
server, ok := singleton.ServerList[id]
if !ok {
singleton.ServerLock.RUnlock()
return nil, singleton.Localizer.ErrorT("server not found")
}
if !server.HideForGuest || authorized {
ret = append(ret, id)
}
singleton.ServerLock.RUnlock()
}
return ret, nil
}
// Create service
// @Summary Create service
// @Security BearerAuth
// @Schemes
// @Description Create service
// @Tags auth required
// @Accept json
// @param request body model.ServiceForm true "Service Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /service [post]
func createService(c *gin.Context) (uint64, error) {
var mf model.ServiceForm
if err := c.ShouldBindJSON(&mf); err != nil {
return 0, err
}
var m model.Service
m.Name = mf.Name
m.Target = strings.TrimSpace(mf.Target)
m.Type = mf.Type
m.SkipServers = mf.SkipServers
m.Cover = mf.Cover
m.Notify = mf.Notify
m.NotificationGroupID = mf.NotificationGroupID
m.Duration = mf.Duration
m.LatencyNotify = mf.LatencyNotify
m.MinLatency = mf.MinLatency
m.MaxLatency = mf.MaxLatency
m.EnableShowInService = mf.EnableShowInService
m.EnableTriggerTask = mf.EnableTriggerTask
m.RecoverTriggerTasks = mf.RecoverTriggerTasks
m.FailTriggerTasks = mf.FailTriggerTasks
if err := singleton.DB.Create(&m).Error; err != nil {
return 0, newGormError("%v", err)
}
var skipServers []uint64
for k := range m.SkipServers {
skipServers = append(skipServers, k)
}
var err error
if m.Cover == 0 {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id in (?)", m.ID, skipServers).Error
} else {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id not in (?)", m.ID, skipServers).Error
}
if err != nil {
return 0, err
}
return m.ID, singleton.ServiceSentinelShared.OnServiceUpdate(m)
}
// Update service
// @Summary Update service
// @Security BearerAuth
// @Schemes
// @Description Update service
// @Tags auth required
// @Accept json
// @param id path uint true "Service ID"
// @param request body model.ServiceForm true "Service Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /service/{id} [patch]
func updateService(c *gin.Context) (any, error) {
strID := c.Param("id")
id, err := strconv.ParseUint(strID, 10, 64)
if err != nil {
return nil, err
}
var mf model.ServiceForm
if err := c.ShouldBindJSON(&mf); err != nil {
return nil, err
}
var m model.Service
if err := singleton.DB.First(&m, id).Error; err != nil {
return nil, singleton.Localizer.ErrorT("service id %d does not exist", id)
}
m.Name = mf.Name
m.Target = strings.TrimSpace(mf.Target)
m.Type = mf.Type
m.SkipServers = mf.SkipServers
m.Cover = mf.Cover
m.Notify = mf.Notify
m.NotificationGroupID = mf.NotificationGroupID
m.Duration = mf.Duration
m.LatencyNotify = mf.LatencyNotify
m.MinLatency = mf.MinLatency
m.MaxLatency = mf.MaxLatency
m.EnableShowInService = mf.EnableShowInService
m.EnableTriggerTask = mf.EnableTriggerTask
m.RecoverTriggerTasks = mf.RecoverTriggerTasks
m.FailTriggerTasks = mf.FailTriggerTasks
if err := singleton.DB.Save(&m).Error; err != nil {
return nil, newGormError("%v", err)
}
var skipServers []uint64
for k := range m.SkipServers {
skipServers = append(skipServers, k)
}
if m.Cover == 0 {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id in (?)", m.ID, skipServers).Error
} else {
err = singleton.DB.Unscoped().Delete(&model.ServiceHistory{}, "service_id = ? and server_id not in (?)", m.ID, skipServers).Error
}
if err != nil {
return nil, err
}
return nil, singleton.ServiceSentinelShared.OnServiceUpdate(m)
}
// Batch delete service
// @Summary Batch delete service
// @Security BearerAuth
// @Schemes
// @Description Batch delete service
// @Tags auth required
// @Accept json
// @param request body []uint true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/service [post]
func batchDeleteService(c *gin.Context) (any, error) {
var ids []uint64
if err := c.ShouldBindJSON(&ids); err != nil {
return nil, err
}
err := singleton.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Delete(&model.Service{}, "id in (?)", ids).Error; err != nil {
return err
}
return tx.Unscoped().Delete(&model.ServiceHistory{}, "service_id in (?)", ids).Error
})
if err != nil {
return nil, err
}
singleton.ServiceSentinelShared.OnServiceDelete(ids)
return nil, nil
}

View File

@ -1,73 +0,0 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List settings
// @Summary List settings
// @Schemes
// @Description List settings
// @Security BearerAuth
// @Tags common
// @Produce json
// @Success 200 {object} model.CommonResponse[model.Config]
// @Router /setting [get]
func listConfig(c *gin.Context) (model.Config, error) {
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied
conf := *singleton.Conf
if !authorized {
conf = model.Config{
SiteName: conf.SiteName,
Language: conf.Language,
CustomCode: conf.CustomCode,
CustomCodeDashboard: conf.CustomCodeDashboard,
}
}
return conf, nil
}
// Edit config
// @Summary Edit config
// @Security BearerAuth
// @Schemes
// @Description Edit config
// @Tags auth required
// @Accept json
// @Param body body model.SettingForm true "SettingForm"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /setting [patch]
func updateConfig(c *gin.Context) (any, error) {
var sf model.SettingForm
if err := c.ShouldBindJSON(&sf); err != nil {
return nil, err
}
singleton.Conf.Language = sf.Language
singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification
singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
singleton.Conf.Cover = sf.Cover
singleton.Conf.InstallHost = sf.InstallHost
singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification
singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID
singleton.Conf.SiteName = sf.SiteName
singleton.Conf.DNSServers = sf.CustomNameservers
singleton.Conf.CustomCode = sf.CustomCode
singleton.Conf.CustomCodeDashboard = sf.CustomCodeDashboard
singleton.Conf.RealIPHeader = sf.RealIPHeader
if err := singleton.Conf.Save(); err != nil {
return nil, newGormError("%v", err)
}
singleton.OnNameserverUpdate()
singleton.OnUpdateLang(singleton.Conf.Language)
return nil, nil
}

View File

@ -1,103 +0,0 @@
package controller
import (
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"github.com/hashicorp/go-uuid"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/pkg/websocketx"
"github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
)
// Create web ssh terminal
// @Summary Create web ssh terminal
// @Description Create web ssh terminal
// @Tags auth required
// @Accept json
// @Param terminal body model.TerminalForm true "TerminalForm"
// @Produce json
// @Success 200 {object} model.CreateTerminalResponse
// @Router /terminal [post]
func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {
var createTerminalReq model.TerminalForm
if err := c.ShouldBind(&createTerminalReq); err != nil {
return nil, err
}
streamId, err := uuid.GenerateUUID()
if err != nil {
return nil, err
}
rpc.NezhaHandlerSingleton.CreateStream(streamId)
singleton.ServerLock.RLock()
server := singleton.ServerList[createTerminalReq.ServerID]
singleton.ServerLock.RUnlock()
if server == nil || server.TaskStream == nil {
return nil, singleton.Localizer.ErrorT("server not found or not connected")
}
terminalData, _ := utils.Json.Marshal(&model.TerminalTask{
StreamID: streamId,
})
if err := server.TaskStream.Send(&proto.Task{
Type: model.TaskTypeTerminalGRPC,
Data: string(terminalData),
}); err != nil {
return nil, err
}
return &model.CreateTerminalResponse{
SessionID: streamId,
ServerID: server.ID,
ServerName: server.Name,
}, nil
}
// TerminalStream web ssh terminal stream
// @Summary Terminal stream
// @Description Terminal stream
// @Tags auth required
// @Param id path string true "Stream UUID"
// @Success 200 {object} model.CommonResponse[any]
// @Router /ws/terminal/{id} [get]
func terminalStream(c *gin.Context) (any, error) {
streamId := c.Param("id")
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
return nil, err
}
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return nil, newWsError("%v", err)
}
defer wsConn.Close()
conn := websocketx.NewConn(wsConn)
go func() {
// PING 保活
for {
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
return
}
time.Sleep(time.Second * 10)
}
}()
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
return nil, newWsError("%v", err)
}
if err = rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10); err != nil {
return nil, newWsError("%v", err)
}
return nil, newWsError("")
}

View File

@ -1,153 +0,0 @@
package controller
import (
"slices"
"github.com/gin-gonic/gin"
"golang.org/x/crypto/bcrypt"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// Get profile
// @Summary Get profile
// @Security BearerAuth
// @Schemes
// @Description Get profile
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[model.Profile]
// @Router /profile [get]
func getProfile(c *gin.Context) (*model.Profile, error) {
auth, ok := c.Get(model.CtxKeyAuthorizedUser)
if !ok {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
return &model.Profile{
User: *auth.(*model.User),
LoginIP: c.GetString(model.CtxKeyRealIPStr),
}, nil
}
// Update password for current user
// @Summary Update password for current user
// @Security BearerAuth
// @Schemes
// @Description Update password for current user
// @Tags auth required
// @Accept json
// @param request body model.ProfileForm true "password"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /profile [post]
func updateProfile(c *gin.Context) (any, error) {
var pf model.ProfileForm
if err := c.ShouldBindJSON(&pf); err != nil {
return 0, err
}
auth, ok := c.Get(model.CtxKeyAuthorizedUser)
if !ok {
return nil, singleton.Localizer.ErrorT("unauthorized")
}
user := *auth.(*model.User)
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(pf.OriginalPassword)); err != nil {
return nil, singleton.Localizer.ErrorT("incorrect password")
}
hash, err := bcrypt.GenerateFromPassword([]byte(pf.NewPassword), bcrypt.DefaultCost)
if err != nil {
return nil, err
}
user.Username = pf.NewUsername
user.Password = string(hash)
if err := singleton.DB.Save(&user).Error; err != nil {
return nil, newGormError("%v", err)
}
return nil, nil
}
// List user
// @Summary List user
// @Security BearerAuth
// @Schemes
// @Description List user
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.User]
// @Router /user [get]
func listUser(c *gin.Context) ([]model.User, error) {
var users []model.User
if err := singleton.DB.Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// Create user
// @Summary Create user
// @Security BearerAuth
// @Schemes
// @Description Create user
// @Tags auth required
// @Accept json
// @param request body model.UserForm true "User Request"
// @Produce json
// @Success 200 {object} model.CommonResponse[uint64]
// @Router /user [post]
func createUser(c *gin.Context) (uint64, error) {
var uf model.UserForm
if err := c.ShouldBindJSON(&uf); err != nil {
return 0, err
}
if len(uf.Password) < 6 {
return 0, singleton.Localizer.ErrorT("password length must be greater than 6")
}
if uf.Username == "" {
return 0, singleton.Localizer.ErrorT("username can't be empty")
}
var u model.User
u.Username = uf.Username
hash, err := bcrypt.GenerateFromPassword([]byte(uf.Password), bcrypt.DefaultCost)
if err != nil {
return 0, err
}
u.Password = string(hash)
if err := singleton.DB.Create(&u).Error; err != nil {
return 0, err
}
return u.ID, nil
}
// Batch delete users
// @Summary Batch delete users
// @Security BearerAuth
// @Schemes
// @Description Batch delete users
// @Tags auth required
// @Accept json
// @param request body []uint true "id list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/user [post]
func batchDeleteUser(c *gin.Context) (any, error) {
var ids []uint64
if err := c.ShouldBindJSON(&ids); err != nil {
return nil, err
}
auth := c.MustGet(model.CtxKeyAuthorizedUser).(*model.User)
if slices.Contains(ids, auth.ID) {
return nil, singleton.Localizer.ErrorT("can't delete yourself")
}
return nil, singleton.DB.Where("id IN (?)", ids).Delete(&model.User{}).Error
}

View File

@ -1,50 +0,0 @@
package controller
import (
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/service/singleton"
)
// List blocked addresses
// @Summary List blocked addresses
// @Security BearerAuth
// @Schemes
// @Description List server
// @Tags auth required
// @Produce json
// @Success 200 {object} model.CommonResponse[[]model.WAFApiMock]
// @Router /waf [get]
func listBlockedAddress(c *gin.Context) ([]*model.WAF, error) {
var waf []*model.WAF
if err := singleton.DB.Find(&waf).Error; err != nil {
return nil, err
}
return waf, nil
}
// Batch delete blocked addresses
// @Summary Edit server
// @Security BearerAuth
// @Schemes
// @Description Edit server
// @Tags auth required
// @Accept json
// @Param request body []string true "block list"
// @Produce json
// @Success 200 {object} model.CommonResponse[any]
// @Router /batch-delete/waf [patch]
func batchDeleteBlockedAddress(c *gin.Context) (any, error) {
var list []string
if err := c.ShouldBindJSON(&list); err != nil {
return nil, err
}
if err := model.BatchClearIP(singleton.DB, list); err != nil {
return nil, newGormError("%v", err)
}
return nil, nil
}

View File

@ -1,57 +0,0 @@
package waf
import (
_ "embed"
"net/http"
"strings"
"github.com/gin-gonic/gin"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
//go:embed waf.html
var errorPageTemplate string
func RealIp(c *gin.Context) {
if singleton.Conf.RealIPHeader == "" {
c.Next()
return
}
if singleton.Conf.RealIPHeader == model.ConfigUsePeerIP {
c.Set(model.CtxKeyRealIPStr, c.RemoteIP())
c.Next()
return
}
vals := c.Request.Header.Get(singleton.Conf.RealIPHeader)
if vals == "" {
c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: "real ip header not found"})
return
}
ip, err := utils.GetIPFromHeader(vals)
if err != nil {
c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: err.Error()})
return
}
c.Set(model.CtxKeyRealIPStr, ip)
c.Next()
}
func Waf(c *gin.Context) {
if err := model.CheckIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr)); err != nil {
ShowBlockPage(c, err)
return
}
c.Next()
}
func ShowBlockPage(c *gin.Context, err error) {
c.Writer.WriteHeader(http.StatusForbidden)
c.Header("Content-Type", "text/html; charset=utf-8")
c.Writer.WriteString(strings.Replace(errorPageTemplate, "{error}", err.Error(), 1))
c.Abort()
}

View File

@ -1,46 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Blocked</title>
<style>
body {
display: flex;
justify-content: center;
align-items: center;
height: 90vh;
font-weight: bolder;
font-family: 'Courier New', Courier, monospace;
}
main {
text-align: center;
}
.emoji {
font-size: 200px;
}
p.secondary {
font-size: 12px;
color: #888;
}
@media (prefers-color-scheme: dark) {
body {
background-color: #111;
color: #007C41
}
}
</style>
</head>
<body>
<main>
<div class="emoji">🤡</div>
<h1>Blocked</h1>
<p>{error}</p>
<p class="secondary">nezha WAF</p>
</main>
</body>
</html>

View File

@ -1,134 +0,0 @@
package controller
import (
"fmt"
"net"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/gorilla/websocket"
"golang.org/x/sync/singleflight"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/service/singleton"
)
var upgrader *websocket.Upgrader
func InitUpgrader() {
var checkOrigin func(r *http.Request) bool
// Allow CORS from loopback addresses in debug mode
if singleton.Conf.Debug {
checkOrigin = func(r *http.Request) bool {
hostAddr := r.Host
host, _, err := net.SplitHostPort(hostAddr)
if err != nil {
return false
}
if ip := net.ParseIP(host); ip != nil {
if ip.IsLoopback() {
return true
}
} else {
// Handle domains like "localhost"
ip, err := net.LookupHost(host)
if err != nil || len(ip) == 0 {
return false
}
if netIP := net.ParseIP(ip[0]); netIP != nil && netIP.IsLoopback() {
return true
}
}
return false
}
}
upgrader = &websocket.Upgrader{
ReadBufferSize: 32768,
WriteBufferSize: 32768,
CheckOrigin: checkOrigin,
}
}
// Websocket server stream
// @Summary Websocket server stream
// @tags common
// @Schemes
// @Description Websocket server stream
// @security BearerAuth
// @Produce json
// @Success 200 {object} model.StreamServerData
// @Router /ws/server [get]
func serverStream(c *gin.Context) (any, error) {
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
return nil, newWsError("%v", err)
}
defer conn.Close()
count := 0
for {
stat, err := getServerStat(c, count == 0)
if err != nil {
continue
}
if err := conn.WriteMessage(websocket.TextMessage, stat); err != nil {
break
}
count += 1
if count%4 == 0 {
err = conn.WriteMessage(websocket.PingMessage, []byte{})
if err != nil {
break
}
}
time.Sleep(time.Second * 2)
}
return nil, newWsError("")
}
var requestGroup singleflight.Group
func getServerStat(c *gin.Context, withPublicNote bool) ([]byte, error) {
_, isMember := c.Get(model.CtxKeyAuthorizedUser)
authorized := isMember // TODO || isViewPasswordVerfied
v, err, _ := requestGroup.Do(fmt.Sprintf("serverStats::%t", authorized), func() (interface{}, error) {
singleton.SortedServerLock.RLock()
defer singleton.SortedServerLock.RUnlock()
var serverList []*model.Server
if authorized {
serverList = singleton.SortedServerList
} else {
serverList = singleton.SortedServerListForGuest
}
servers := make([]model.StreamServer, 0, len(serverList))
for _, server := range serverList {
var countryCode string
if server.GeoIP != nil {
countryCode = server.GeoIP.CountryCode
}
servers = append(servers, model.StreamServer{
ID: server.ID,
Name: server.Name,
PublicNote: utils.IfOr(withPublicNote, server.PublicNote, ""),
DisplayIndex: server.DisplayIndex,
Host: server.Host,
State: server.State,
CountryCode: countryCode,
LastActive: server.LastActive,
})
}
return utils.Json.Marshal(model.StreamServerData{
Now: time.Now().Unix() * 1000,
Servers: servers,
})
})
return v.([]byte), err
}

View File

@ -2,28 +2,15 @@ package main
import (
"context"
"embed"
_ "embed"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"strings"
"time"
_ "time/tzdata"
"github.com/naiba/nezha/cmd/dashboard/controller"
"github.com/naiba/nezha/cmd/dashboard/rpc"
"github.com/naiba/nezha/model"
"github.com/naiba/nezha/service/singleton"
"github.com/ory/graceful"
"golang.org/x/crypto/bcrypt"
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
"github.com/nezhahq/nezha/cmd/dashboard/controller"
"github.com/nezhahq/nezha/cmd/dashboard/rpc"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/proto"
"github.com/nezhahq/nezha/service/singleton"
flag "github.com/spf13/pflag"
)
type DashboardCliParam struct {
@ -34,37 +21,29 @@ type DashboardCliParam struct {
var (
dashboardCliParam DashboardCliParam
//go:embed admin-dist
adminFrontend embed.FS
//go:embed user-dist
userFrontend embed.FS
)
func initSystem() {
// 初始化管理员账户
var usersCount int64
if err := singleton.DB.Model(&model.User{}).Count(&usersCount).Error; err != nil {
panic(err)
}
if usersCount == 0 {
hash, err := bcrypt.GenerateFromPassword([]byte("admin"), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
admin := model.User{
Username: "admin",
Password: string(hash),
}
if err := singleton.DB.Create(&admin).Error; err != nil {
panic(err)
}
}
func init() {
flag.CommandLine.ParseErrorsWhitelist.UnknownFlags = true
flag.BoolVarP(&dashboardCliParam.Version, "version", "v", false, "查看当前版本号")
flag.StringVarP(&dashboardCliParam.ConfigFile, "config", "c", "data/config.yaml", "配置文件路径")
flag.StringVar(&dashboardCliParam.DatebaseLocation, "db", "data/sqlite.db", "Sqlite3数据库文件路径")
flag.Parse()
// 初始化 dao 包
singleton.InitConfigFromPath(dashboardCliParam.ConfigFile)
singleton.InitTimezoneAndCache()
singleton.InitDBFromPath(dashboardCliParam.DatebaseLocation)
singleton.InitLocalizer()
initSystem()
}
func initSystem() {
// 启动 singleton 包下的所有服务
singleton.LoadSingleton()
// 每天的3:30 对 监控记录 和 流量记录 进行清理
if _, err := singleton.Cron.AddFunc("0 30 3 * * *", singleton.CleanServiceHistory); err != nil {
if _, err := singleton.Cron.AddFunc("0 30 3 * * *", singleton.CleanMonitorHistory); err != nil {
panic(err)
}
@ -74,106 +53,29 @@ func initSystem() {
}
}
// @title Nezha Monitoring API
// @version 1.0
// @description Nezha Monitoring API
// @termsOfService http://nezhahq.github.io
// @contact.name API Support
// @contact.url http://nezhahq.github.io
// @contact.email hi@nai.ba
// @license.name Apache 2.0
// @license.url http://www.apache.org/licenses/LICENSE-2.0.html
// @host localhost:8008
// @BasePath /api/v1
// @securityDefinitions.apikey BearerAuth
// @in header
// @name Authorization
// @externalDocs.description OpenAPI
// @externalDocs.url https://swagger.io/resources/open-api/
func main() {
flag.BoolVar(&dashboardCliParam.Version, "v", false, "查看当前版本号")
flag.StringVar(&dashboardCliParam.ConfigFile, "c", "data/config.yaml", "配置文件路径")
flag.StringVar(&dashboardCliParam.DatebaseLocation, "db", "data/sqlite.db", "Sqlite3数据库文件路径")
flag.Parse()
if dashboardCliParam.Version {
fmt.Println(singleton.Version)
os.Exit(0)
return
}
// 初始化 dao 包
singleton.InitConfigFromPath(dashboardCliParam.ConfigFile)
singleton.InitTimezoneAndCache()
singleton.InitDBFromPath(dashboardCliParam.DatebaseLocation)
initSystem()
l, err := net.Listen("tcp", fmt.Sprintf(":%d", singleton.Conf.ListenPort))
if err != nil {
log.Fatal(err)
}
singleton.CleanServiceHistory()
serviceSentinelDispatchBus := make(chan model.Service) // 用于传递服务监控任务信息的channel
singleton.CleanMonitorHistory()
go rpc.ServeRPC(singleton.Conf.GRPCPort)
serviceSentinelDispatchBus := make(chan model.Monitor) // 用于传递服务监控任务信息的channel
go rpc.DispatchTask(serviceSentinelDispatchBus)
go rpc.DispatchKeepalive()
go singleton.AlertSentinelStart()
singleton.NewServiceSentinel(serviceSentinelDispatchBus)
grpcHandler := rpc.ServeRPC()
httpHandler := controller.ServeWeb(adminFrontend, userFrontend)
controller.InitUpgrader()
muxHandler := newHTTPandGRPCMux(httpHandler, grpcHandler)
http2Server := &http2.Server{}
muxServer := &http.Server{Handler: h2c.NewHandler(muxHandler, http2Server), ReadHeaderTimeout: time.Second * 5}
go dispatchReportInfoTask()
srv := controller.ServeWeb(singleton.Conf.HTTPPort)
if err := graceful.Graceful(func() error {
log.Println("NEZHA>> Dashboard::START", singleton.Conf.ListenPort)
return muxServer.Serve(l)
return srv.ListenAndServe()
}, func(c context.Context) error {
log.Println("NEZHA>> Graceful::START")
singleton.RecordTransferHourlyUsage()
log.Println("NEZHA>> Graceful::END")
return muxServer.Shutdown(c)
srv.Shutdown(c)
return nil
}); err != nil {
log.Printf("NEZHA>> ERROR: %v", err)
}
}
func dispatchReportInfoTask() {
time.Sleep(time.Second * 15)
singleton.ServerLock.RLock()
defer singleton.ServerLock.RUnlock()
for _, server := range singleton.ServerList {
if server == nil || server.TaskStream == nil {
continue
}
server.TaskStream.Send(&proto.Task{
Type: model.TaskTypeReportHostInfo,
Data: "",
})
}
}
func newHTTPandGRPCMux(httpHandler http.Handler, grpcHandler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
natConfig := singleton.GetNATConfigByDomain(r.Host)
if natConfig != nil {
rpc.ServeNAT(w, r, natConfig)
return
}
if r.ProtoMajor == 2 && r.Header.Get("Content-Type") == "application/grpc" &&
strings.HasPrefix(r.URL.Path, "/"+proto.NezhaService_ServiceDesc.ServiceName) {
grpcHandler.ServeHTTP(w, r)
return
}
httpHandler.ServeHTTP(w, r)
})
}

View File

@ -1,78 +1,30 @@
package rpc
import (
"context"
"fmt"
"log"
"net/http"
"net/netip"
"time"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"github.com/hashicorp/go-uuid"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/nezhahq/nezha/proto"
rpcService "github.com/nezhahq/nezha/service/rpc"
"github.com/nezhahq/nezha/service/singleton"
"github.com/naiba/nezha/model"
pb "github.com/naiba/nezha/proto"
rpcService "github.com/naiba/nezha/service/rpc"
"github.com/naiba/nezha/service/singleton"
)
func ServeRPC() *grpc.Server {
server := grpc.NewServer(grpc.ChainUnaryInterceptor(getRealIp, waf))
rpcService.NezhaHandlerSingleton = rpcService.NewNezhaHandler()
proto.RegisterNezhaServiceServer(server, rpcService.NezhaHandlerSingleton)
return server
func ServeRPC(port uint) {
server := grpc.NewServer()
pb.RegisterNezhaServiceServer(server, &rpcService.NezhaHandler{
Auth: &rpcService.AuthHandler{},
})
listen, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
if err != nil {
panic(err)
}
server.Serve(listen)
}
func waf(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
realip, _ := ctx.Value(model.CtxKeyRealIP{}).(string)
if err := model.CheckIP(singleton.DB, realip); err != nil {
return nil, err
}
return handler(ctx, req)
}
func getRealIp(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
if singleton.Conf.RealIPHeader == "" {
return handler(ctx, req)
}
var ip string
if singleton.Conf.RealIPHeader == model.ConfigUsePeerIP {
p, ok := peer.FromContext(ctx)
if !ok {
return nil, fmt.Errorf("peer not found")
}
addrPort, err := netip.ParseAddrPort(p.Addr.String())
if err != nil {
return nil, err
}
ip = addrPort.Addr().String()
} else {
vals := metadata.ValueFromIncomingContext(ctx, singleton.Conf.RealIPHeader)
if len(vals) == 0 {
return nil, fmt.Errorf("real ip header not found")
}
var err error
ip, err = utils.GetIPFromHeader(vals[0])
if err != nil {
return nil, err
}
}
if singleton.Conf.Debug {
log.Printf("NEZHA>> gRPC Real IP: %s", ip)
}
ctx = context.WithValue(ctx, model.CtxKeyRealIP{}, ip)
return handler(ctx, req)
}
func DispatchTask(serviceSentinelDispatchBus <-chan model.Service) {
func DispatchTask(serviceSentinelDispatchBus <-chan model.Monitor) {
workedServerIndex := 0
for task := range serviceSentinelDispatchBus {
round := 0
@ -92,17 +44,17 @@ func DispatchTask(serviceSentinelDispatchBus <-chan model.Service) {
continue
}
// 如果此任务不可使用此服务器请求,跳过这个服务器(有些 IPv6 only 开了 NAT64 的机器请求 IPv4 总会出问题)
if (task.Cover == model.ServiceCoverAll && task.SkipServers[singleton.SortedServerList[workedServerIndex].ID]) ||
(task.Cover == model.ServiceCoverIgnoreAll && !task.SkipServers[singleton.SortedServerList[workedServerIndex].ID]) {
if (task.Cover == model.MonitorCoverAll && task.SkipServers[singleton.SortedServerList[workedServerIndex].ID]) ||
(task.Cover == model.MonitorCoverIgnoreAll && !task.SkipServers[singleton.SortedServerList[workedServerIndex].ID]) {
workedServerIndex++
continue
}
if task.Cover == model.ServiceCoverIgnoreAll && task.SkipServers[singleton.SortedServerList[workedServerIndex].ID] {
if task.Cover == model.MonitorCoverIgnoreAll && task.SkipServers[singleton.SortedServerList[workedServerIndex].ID] {
singleton.SortedServerList[workedServerIndex].TaskStream.Send(task.PB())
workedServerIndex++
continue
}
if task.Cover == model.ServiceCoverAll && !task.SkipServers[singleton.SortedServerList[workedServerIndex].ID] {
if task.Cover == model.MonitorCoverAll && !task.SkipServers[singleton.SortedServerList[workedServerIndex].ID] {
singleton.SortedServerList[workedServerIndex].TaskStream.Send(task.PB())
workedServerIndex++
continue
@ -125,62 +77,7 @@ func DispatchKeepalive() {
continue
}
singleton.SortedServerList[i].TaskStream.Send(&proto.Task{Type: model.TaskTypeKeepalive})
singleton.SortedServerList[i].TaskStream.Send(&pb.Task{Type: model.TaskTypeKeepalive})
}
})
}
func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) {
singleton.ServerLock.RLock()
server := singleton.ServerList[natConfig.ServerID]
singleton.ServerLock.RUnlock()
if server == nil || server.TaskStream == nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte("server not found or not connected"))
return
}
streamId, err := uuid.GenerateUUID()
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(fmt.Sprintf("stream id error: %v", err)))
return
}
rpcService.NezhaHandlerSingleton.CreateStream(streamId)
defer rpcService.NezhaHandlerSingleton.CloseStream(streamId)
taskData, err := utils.Json.Marshal(model.TaskNAT{
StreamID: streamId,
Host: natConfig.Host,
})
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(fmt.Sprintf("task data error: %v", err)))
return
}
if err := server.TaskStream.Send(&proto.Task{
Type: model.TaskTypeNAT,
Data: string(taskData),
}); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(fmt.Sprintf("send task error: %v", err)))
return
}
wWrapped, err := utils.NewRequestWrapper(r, w)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(fmt.Sprintf("request wrapper error: %v", err)))
return
}
if err := rpcService.NezhaHandlerSingleton.UserConnected(streamId, wWrapped); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(fmt.Sprintf("user connected error: %v", err)))
return
}
rpcService.NezhaHandlerSingleton.StartStream(streamId, time.Second*10)
}

127
go.mod
View File

@ -1,91 +1,82 @@
module github.com/nezhahq/nezha
module github.com/naiba/nezha
go 1.22.7
toolchain go1.23.1
go 1.20
require (
github.com/appleboy/gin-jwt/v2 v2.10.0
github.com/chai2010/gettext-go v1.0.3
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
github.com/gin-contrib/pprof v1.5.1
github.com/gin-gonic/gin v1.10.0
github.com/gorilla/websocket v1.5.3
code.cloudfoundry.org/bytefmt v0.0.0-20240425163905-bcdc1ad063ea
code.gitea.io/sdk/gitea v0.18.0
github.com/BurntSushi/toml v1.3.2
github.com/gin-contrib/pprof v1.4.0
github.com/gin-gonic/gin v1.9.1
github.com/google/go-github/v47 v47.1.0
github.com/gorilla/websocket v1.5.1
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/json-iterator/go v1.1.12
github.com/knadh/koanf/parsers/yaml v0.1.0
github.com/knadh/koanf/providers/env v1.0.0
github.com/knadh/koanf/providers/file v1.1.2
github.com/knadh/koanf/v2 v2.1.2
github.com/libdns/cloudflare v0.1.1
github.com/libdns/libdns v0.2.2
github.com/miekg/dns v1.1.62
github.com/nezhahq/libdns-tencentcloud v0.0.0-20241029120103-889957240fff
github.com/nicksnyder/go-i18n/v2 v2.4.0
github.com/ory/graceful v0.1.3
github.com/oschwald/maxminddb-golang v1.13.1
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/robfig/cron/v3 v3.0.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.4
github.com/tidwall/gjson v1.18.0
golang.org/x/crypto v0.29.0
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f
golang.org/x/net v0.31.0
golang.org/x/sync v0.9.0
google.golang.org/grpc v1.68.0
google.golang.org/protobuf v1.35.2
gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.6
gorm.io/gorm v1.25.12
github.com/samber/lo v1.39.0
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.18.2
github.com/xanzy/go-gitlab v0.103.0
golang.org/x/crypto v0.22.0
golang.org/x/net v0.24.0
golang.org/x/oauth2 v0.19.0
golang.org/x/sync v0.7.0
golang.org/x/text v0.14.0
google.golang.org/grpc v1.63.0
google.golang.org/protobuf v1.33.0
gorm.io/driver/sqlite v1.5.5
gorm.io/gorm v1.25.10
sigs.k8s.io/yaml v1.4.0
)
require (
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/bytedance/sonic v1.12.4 // indirect
github.com/bytedance/sonic/loader v0.2.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/jsonreference v0.21.0 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.22.1 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/knadh/koanf/maps v0.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.22 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
golang.org/x/arch v0.12.0 // indirect
golang.org/x/mod v0.22.0 // indirect
golang.org/x/sys v0.27.0 // indirect
golang.org/x/text v0.20.0 // indirect
golang.org/x/tools v0.27.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.19.0 // indirect
golang.org/x/time v0.5.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

334
go.sum
View File

@ -1,230 +1,246 @@
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/appleboy/gin-jwt/v2 v2.10.0 h1:vOlGSly8oIGQiT8AcEh1nYMLYI1K9YvsZNVWM612xN0=
github.com/appleboy/gin-jwt/v2 v2.10.0/go.mod h1:DvCh3V1Ma32/7kAsAHYQVyjsQMwG+wMXGpyCYLfHOJU=
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
github.com/bytedance/sonic v1.12.4 h1:9Csb3c9ZJhfUWeMtpCDCq6BUoH5ogfDFLUgQ/jG+R0k=
github.com/bytedance/sonic v1.12.4/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.1 h1:1GgorWTqf12TA8mma4DDSbaQigE2wOgQo7iCjjJv3+E=
github.com/bytedance/sonic/loader v0.2.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/chai2010/gettext-go v1.0.3 h1:9liNh8t+u26xl5ddmWLmsOsdNLwkdRTg5AG+JnTiM80=
github.com/chai2010/gettext-go v1.0.3/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
code.cloudfoundry.org/bytefmt v0.0.0-20240425163905-bcdc1ad063ea h1:1tgMNDgo8PjpsHhlaxdibj28C0WyLeOW2SPJ7GGdc9A=
code.cloudfoundry.org/bytefmt v0.0.0-20240425163905-bcdc1ad063ea/go.mod h1:3+xXJBOD8PsGHDqHedtCLalbaVJ+yi1OW+mXx9IcNxI=
code.gitea.io/sdk/gitea v0.18.0 h1:+zZrwVmujIrgobt6wVBWCqITz6bn1aBjnCUHmpZrerI=
code.gitea.io/sdk/gitea v0.18.0/go.mod h1:IG9xZJoltDNeDSW0qiF2Vqx5orMWa7OhVWrjvrd5NpI=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0 h1:aYo8nnk3ojoQkP5iErif5Xxv0Mo0Ga/FR5+ffl/7+Nk=
github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0/go.mod h1:8AuBTZBRSFqEYBPYULd+NN474/zZBLP+6WeT5S9xlAc=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.6 h1:3+PzJTKLkvgjeTbts6msPJt4DixhT4YtFNf1gtGe3zc=
github.com/gabriel-vasile/mimetype v1.4.6/go.mod h1:JX1qVKqZd40hUPpAfiNTe0Sne7hdfKSbOqqmkq8GCXc=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/pprof v1.5.1 h1:Mzy+3HHtHbtwr4VewBTXZp/hR7pS6ZuZkueBIrQiLL4=
github.com/gin-contrib/pprof v1.5.1/go.mod h1:uwzoF6FxdzJJGyMdcZB+VSuVjOBe1kSH+KMIvKGwvCQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/pprof v1.4.0 h1:XxiBSf5jWZ5i16lNOPbMTVdgHBdhfGRD5PZ1LWazzvg=
github.com/gin-contrib/pprof v1.4.0/go.mod h1:RrehPJasUVBPK6yTUwOl8/NP6i0vbUgmxtis+Z5KE90=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ=
github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4=
github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY=
github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/gin-gonic/gin v1.8.1/go.mod h1:ji8BvRH1azfM+SYow9zQ6SZMvR8qOMZHmsCuWR9tTTk=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI=
github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.0/go.mod h1:UvRDBj+xPUEGrFYl+lu/H90nyDXpg0fqeB/AQUGNTVA=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/go-playground/validator/v10 v10.10.0/go.mod h1:74x4gJWsvQexRdW8Pn3dXSGrTK4nAUsbPlLADvpJkos=
github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg/+t63MyGU2n5js=
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI=
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-github/v47 v47.1.0 h1:Cacm/WxQBOa9lF0FT0EMjZ2BWMetQ1TQfyurn4yF1z8=
github.com/google/go-github/v47 v47.1.0/go.mod h1:VPZBXNbFSJGjyjFRUKo9vZGawTajnWzC/YjGw/oFKi0=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/google/pprof v0.0.0-20240424215950-a892ee059fd6 h1:k7nVchz72niMH6YLQNvHSdIE7iqsQxK1P41mySCvssg=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v1.5.0 h1:bI2ocEMgcVlz55Oj1xZNBsVi900c7II+fWDyV9o+13c=
github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs=
github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v0.1.0 h1:ZZ8/iGfRLvKSaMEECEBPM1HQslrZADk8fP1XFUxVI5w=
github.com/knadh/koanf/parsers/yaml v0.1.0/go.mod h1:cvbUDC7AL23pImuQP0oRw/hPuccrNBS2bps8asS0CwY=
github.com/knadh/koanf/providers/env v1.0.0 h1:ufePaI9BnWH+ajuxGGiJ8pdTG0uLEUWC7/HDDPGLah0=
github.com/knadh/koanf/providers/env v1.0.0/go.mod h1:mzFyRZueYhb37oPmC1HAv/oGEEuyvJDA98r3XAa8Gak=
github.com/knadh/koanf/providers/file v1.1.2 h1:aCC36YGOgV5lTtAFz2qkgtWdeQsgfxUkxDOe+2nQY3w=
github.com/knadh/koanf/providers/file v1.1.2/go.mod h1:/faSBcv2mxPVjFrXck95qeoyoZ5myJ6uxN8OOVNJJCI=
github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ=
github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/libdns/cloudflare v0.1.1 h1:FVPfWwP8zZCqj268LZjmkDleXlHPlFU9KC4OJ3yn054=
github.com/libdns/cloudflare v0.1.1/go.mod h1:9VK91idpOjg6v7/WbjkEW49bSCxj00ALesIFDhJ8PBU=
github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
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-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/miekg/dns v1.1.62 h1:cN8OuEF1/x5Rq6Np+h1epln8OiyPWV+lROx9LxcGgIQ=
github.com/miekg/dns v1.1.62/go.mod h1:mvDlcItzm+br7MToIKqkglaGhlFMHJ9DTNNWONWXbNQ=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/leodido/go-urn v1.2.1/go.mod h1:zt4jvISO2HfUBqxjfIshjdMTYS56ZS/qv49ictyFfxY=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nezhahq/libdns-tencentcloud v0.0.0-20241029120103-889957240fff h1:3WDsbsg3dmsRENYLUPGPTkEcWWmTvk41i+rM91AIIbY=
github.com/nezhahq/libdns-tencentcloud v0.0.0-20241029120103-889957240fff/go.mod h1:k+cDtTbUY+UV56Aqv4Ahmc9bWmhpga9JJXZlAIPKBEc=
github.com/nicksnyder/go-i18n/v2 v2.4.0 h1:3IcvPOAvnCKwNm0TB0dLDTuawWEj+ax/RERNC+diLMM=
github.com/nicksnyder/go-i18n/v2 v2.4.0/go.mod h1:nxYSZE9M0bf3Y70gPQjN9ha7XNHX7gMc814+6wVyEI4=
github.com/onsi/ginkgo/v2 v2.17.1 h1:V++EzdbhI4ZV4ev0UTIj0PzhzOcReJFyJaLjtSF55M8=
github.com/onsi/gomega v1.33.0 h1:snPCflnZrpMsy94p4lXVEkHo12lmPnc3vY5XBbreexE=
github.com/ory/graceful v0.1.3 h1:FaeXcHZh168WzS+bqruqWEw/HgXWLdNv2nJ+fbhxbhc=
github.com/ory/graceful v0.1.3/go.mod h1:4zFz687IAF7oNHHiB586U4iL+/4aV09o/PYLE34t2bA=
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.0.1/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA=
github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg=
github.com/swaggo/gin-swagger v1.6.0 h1:y8sxvQ3E20/RCyrXeFfg60r6H0Z+SwpTjMYsMm+zy8M=
github.com/swaggo/gin-swagger v1.6.0/go.mod h1:BG00cCEy294xtVpyIAHG6+e2Qzj/xKlRdOqDkvq0uzo=
github.com/swaggo/swag v1.16.4 h1:clWJtd9LStiG3VeijiCfOVODP6VpHtKdQy9ELFG3s1A=
github.com/swaggo/swag v1.16.4/go.mod h1:VBsHJRsDvfYvqoiMKnsdwhNV9LEMHgEDZcyVYX0sxPg=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/arch v0.12.0 h1:UsYJhbzPYGsT0HbEdmYcqtCv8UNGvnaL561NnIUvaKg=
golang.org/x/arch v0.12.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
github.com/ugorji/go v1.2.7/go.mod h1:nF9osbDWLy6bDVv/Rtoh6QgnvNDpmCalQV5urGCCS6M=
github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/xanzy/go-gitlab v0.103.0 h1:J9pTQoq0GsEFqzd6srCM1QfdfKAxSNz6mT6ntrpNF2w=
github.com/xanzy/go-gitlab v0.103.0/go.mod h1:ETg8tcj4OhrB84UEgeE8dSuV/0h4BBL1uOV/qK0vlyI=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30=
golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/net v0.24.0 h1:1PcaxkF854Fu3+lvBIx5SYn9wRlBzzcnHZSiaFFAb0w=
golang.org/x/net v0.24.0/go.mod h1:2Q7sJY5mzlzWjKtYUEXSlBWCdyaioyXzRB2RtU8KVE8=
golang.org/x/oauth2 v0.19.0 h1:9+E/EZBCbTLNrbN35fHv/a/d/mOBatymz1zbtQrXpIg=
golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8=
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.27.0 h1:qEKojBykQkQ4EynWy4S8Weg69NumxKdn40Fce3uc/8o=
golang.org/x/tools v0.27.0/go.mod h1:sUi0ZgbwW9ZPAq26Ekut+weQPR5eIM6GQLQ1Yjm1H0Q=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38 h1:zciRKQ4kBpFgpfC5QQCVtnnNAcLIqweL7plyZRQHVpI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241021214115-324edc3d5d38/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI=
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
golang.org/x/tools v0.20.0 h1:hz/CVckiOxybQvFw6h7b/q80NTr9IUQb4s1IIzW7KNY=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY=
google.golang.org/grpc v1.63.0 h1:WjKe+dnvABXyPJMD7KDNLxtoGk5tgk+YFWN6cBWjZE8=
google.golang.org/grpc v1.63.0/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=

View File

@ -1,7 +1,9 @@
package model
import (
"github.com/nezhahq/nezha/pkg/utils"
"time"
"github.com/naiba/nezha/pkg/utils"
"gorm.io/gorm"
)
@ -10,18 +12,29 @@ const (
ModeOnetimeTrigger = 1
)
type CycleTransferStats struct {
Name string
From time.Time
To time.Time
Max uint64
Min uint64
ServerName map[uint64]string
Transfer map[uint64]uint64
NextUpdate map[uint64]time.Time
}
type AlertRule struct {
Common
Name string `json:"name"`
RulesRaw string `json:"-"`
Enable *bool `json:"enable,omitempty"`
TriggerMode uint8 `gorm:"default:0" json:"trigger_mode"` // 触发模式: 0-始终触发(默认) 1-单次触发
NotificationGroupID uint64 `json:"notification_group_id"` // 该报警规则所在的通知组
FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
Rules []Rule `gorm:"-" json:"rules"`
FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id
RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id
Name string
RulesRaw string
Enable *bool
TriggerMode int `gorm:"default:0"` // 触发模式: 0-始终触发(默认) 1-单次触发
NotificationTag string // 该报警规则所在的通知组
FailTriggerTasksRaw string `gorm:"default:'[]'"`
RecoverTriggerTasksRaw string `gorm:"default:'[]'"`
Rules []Rule `gorm:"-" json:"-"`
FailTriggerTasks []uint64 `gorm:"-" json:"-"` // 失败时执行的触发任务id
RecoverTriggerTasks []uint64 `gorm:"-" json:"-"` // 恢复时执行的触发任务id
}
func (r *AlertRule) BeforeSave(tx *gorm.DB) error {
@ -61,55 +74,55 @@ func (r *AlertRule) Enabled() bool {
return r.Enable != nil && *r.Enable
}
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
point := make([]bool, 0, len(r.Rules))
for _, rule := range r.Rules {
point = append(point, rule.Snapshot(cycleTransferStats, server, db))
// Snapshot 对传入的Server进行该报警规则下所有type的检查 返回包含每项检查结果的空接口
func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []interface{} {
var point []interface{}
for i := 0; i < len(r.Rules); i++ {
point = append(point, r.Rules[i].Snapshot(cycleTransferStats, server, db))
}
return point
}
// Check 传入包含当前报警规则下所有type检查结果 返回报警持续时间与是否通过报警检查(通过则返回true)
func (r *AlertRule) Check(points [][]bool) (maxDuration int, passed bool) {
failCount := 0 // 检查未通过的个数
for i, rule := range r.Rules {
if rule.IsTransferDurationRule() {
// Check 传入包含当前报警规则下所有type检查结果的空接口 返回报警持续时间与是否通过报警检查(通过则返回true)
func (r *AlertRule) Check(points [][]interface{}) (int, bool) {
var maxNum int // 报警持续时间
var count int // 检查未通过的个数
for i := 0; i < len(r.Rules); i++ {
if r.Rules[i].IsTransferDurationRule() {
// 循环区间流量报警
if maxDuration < 1 {
maxDuration = 1
if maxNum < 1 {
maxNum = 1
}
for j := len(points[i]) - 1; j >= 0; j-- {
if !points[i][j] {
failCount++
if points[i][j] != nil {
count++
break
}
}
} else {
// 常规报警
duration := int(rule.Duration)
if duration > maxDuration {
maxDuration = duration
total := 0.0
fail := 0.0
num := int(r.Rules[i].Duration)
if num > maxNum {
maxNum = num
}
if len(points) < duration {
if len(points) < num {
continue
}
total, fail := 0.0, 0.0
for j := len(points) - duration; j < len(points); j++ {
for j := len(points) - 1; j >= 0 && len(points)-num <= j; j-- {
total++
if !points[j][i] {
if points[j][i] != nil {
fail++
}
}
// 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
if fail/total > 0.7 {
failCount++
count++
break
}
}
}
// 仅当所有检查均未通过时 返回false
return maxDuration, failCount != len(r.Rules)
return maxNum, count != len(r.Rules)
}

View File

@ -1,11 +0,0 @@
package model
type AlertRuleForm struct {
Name string `json:"name" minLength:"1"`
Rules []Rule `json:"rules"`
FailTriggerTasks []uint64 `json:"fail_trigger_tasks"` // 失败时触发的任务id
RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks"` // 恢复时触发的任务id
NotificationGroupID uint64 `json:"notification_group_id"`
TriggerMode uint8 `json:"trigger_mode" default:"0"`
Enable bool `json:"enable" validate:"optional"`
}

View File

@ -1,21 +1,19 @@
package model
const (
ApiErrorUnauthorized = 10001
)
type LoginRequest struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
type ServiceItemResponse struct {
Monitor *Monitor
CurrentUp uint64
CurrentDown uint64
TotalUp uint64
TotalDown uint64
Delay *[30]float32
Up *[30]int
Down *[30]int
}
type CommonResponse[T any] struct {
Success bool `json:"success,omitempty"`
Data T `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
type LoginResponse struct {
Token string `json:"token,omitempty"`
Expire string `json:"expire,omitempty"`
func (r ServiceItemResponse) TotalUptime() float32 {
if r.TotalUp+r.TotalDown == 0 {
return 0
}
return float32(r.TotalUp) / (float32(r.TotalUp + r.TotalDown)) * 100
}

8
model/api_token.go Normal file
View File

@ -0,0 +1,8 @@
package model
type ApiToken struct {
Common
UserID uint64 `json:"user_id"`
Token string `json:"token"`
Note string `json:"note"`
}

View File

@ -2,21 +2,20 @@ package model
import (
"time"
"gorm.io/gorm"
)
const (
CtxKeyAuthorizedUser = "ckau"
CtxKeyRealIPStr = "ckri"
)
type CtxKeyRealIP struct{}
const CtxKeyAuthorizedUser = "ckau"
const CtxKeyViewPasswordVerified = "ckvpv"
const CtxKeyPreferredTheme = "ckpt"
const CacheKeyOauth2State = "p:a:state"
type Common struct {
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
CreatedAt time.Time `gorm:"index;<-:create" json:"created_at,omitempty"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
// Do not use soft deletion
// DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
ID uint64 `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"index;<-:create"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
}
type Response struct {

View File

@ -2,111 +2,183 @@ package model
import (
"os"
"path/filepath"
"strconv"
"strings"
kyaml "github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"gopkg.in/yaml.v3"
"github.com/spf13/viper"
"sigs.k8s.io/yaml"
)
"github.com/nezhahq/nezha/pkg/utils"
var Languages = map[string]string{
"zh-CN": "简体中文",
"zh-TW": "繁體中文",
"en-US": "English",
"es-ES": "Español",
}
var Themes = map[string]string{
"default": "Default",
"daynight": "JackieSung DayNight",
"mdui": "Neko Mdui",
"hotaru": "Hotaru",
"angel-kanade": "AngelKanade",
"server-status": "SeverStatus",
"custom": "Custom(local)",
}
var DashboardThemes = map[string]string{
"default": "Default",
"custom": "Custom(local)",
}
const (
ConfigTypeGitHub = "github"
ConfigTypeGitee = "gitee"
ConfigTypeGitlab = "gitlab"
ConfigTypeJihulab = "jihulab"
ConfigTypeGitea = "gitea"
)
const (
ConfigUsePeerIP = "NZ::Use-Peer-IP"
ConfigCoverAll = iota
ConfigCoverAll = iota
ConfigCoverIgnoreAll
)
type AgentConfig struct {
HardDrivePartitionAllowlist []string
NICAllowlist map[string]bool
v *viper.Viper
}
// Read 从给定的文件目录加载配置文件
func (c *AgentConfig) Read(path string) error {
c.v = viper.New()
c.v.SetConfigFile(path)
err := c.v.ReadInConfig()
if err != nil {
return err
}
err = c.v.Unmarshal(c)
if err != nil {
return err
}
return nil
}
func (c *AgentConfig) Save() error {
data, err := yaml.Marshal(c)
if err != nil {
return err
}
return os.WriteFile(c.v.ConfigFileUsed(), data, os.ModePerm)
}
// Config 站点配置
type Config struct {
Debug bool `mapstructure:"debug" json:"debug,omitempty"` // debug模式开关
RealIPHeader string `mapstructure:"real_ip_header" json:"real_ip_header,omitempty"` // 真实IP
Debug bool // debug模式开关
Language string // 系统语言,默认 zh-CN
Site struct {
Brand string // 站点名称
CookieName string // 浏览器 Cookie 名称
Theme string
DashboardTheme string
CustomCode string
ViewPassword string // 前台查看密码
}
Oauth2 struct {
Type string
Admin string // 管理员用户名列表
ClientID string
ClientSecret string
Endpoint string
}
HTTPPort uint
GRPCPort uint
GRPCHost string
ProxyGRPCPort uint
TLS bool
Language string `mapstructure:"language" json:"language"` // 系统语言,默认 zh_CN
SiteName string `mapstructure:"site_name" json:"site_name"`
JWTSecretKey string `mapstructure:"jwt_secret_key" json:"jwt_secret_key,omitempty"`
AgentSecretKey string `mapstructure:"agent_secret_key" json:"agent_secret_key,omitempty"`
ListenPort uint `mapstructure:"listen_port" json:"listen_port,omitempty"`
InstallHost string `mapstructure:"install_host" json:"install_host,omitempty"`
TLS bool `mapstructure:"tls" json:"tls,omitempty"`
Location string `mapstructure:"location" json:"location,omitempty"` // 时区,默认为 Asia/Shanghai
EnablePlainIPInNotification bool `mapstructure:"enable_plain_ip_in_notification" json:"enable_plain_ip_in_notification,omitempty"` // 通知信息IP不打码
EnablePlainIPInNotification bool // 通知信息IP不打码
DisableSwitchTemplateInFrontend bool // 前台禁用切换模板功能
// IP变更提醒
EnableIPChangeNotification bool `mapstructure:"enable_ip_change_notification" json:"enable_ip_change_notification,omitempty"`
IPChangeNotificationGroupID uint64 `mapstructure:"ip_change_notification_group_id" json:"ip_change_notification_group_id"`
Cover uint8 `mapstructure:"cover" json:"cover"` // 覆盖范围0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;
IgnoredIPNotification string `mapstructure:"ignored_ip_notification" json:"ignored_ip_notification,omitempty"` // 特定服务器IP多个服务器用逗号分隔
EnableIPChangeNotification bool
IPChangeNotificationTag string
Cover uint8 // 覆盖范围0:提醒未被 IgnoredIPNotification 包含的所有服务器; 1:仅提醒被 IgnoredIPNotification 包含的服务器;
IgnoredIPNotification string // 特定服务器IP多个服务器用逗号分隔
IgnoredIPNotificationServerIDs map[uint64]bool `mapstructure:"ignored_ip_notification_server_ids" json:"ignored_ip_notification_server_ids,omitempty"` // [ServerID] -> bool(值为true代表当前ServerID在特定服务器列表内
AvgPingCount int `mapstructure:"avg_ping_count" json:"avg_ping_count,omitempty"`
DNSServers string `mapstructure:"dns_servers" json:"dns_servers,omitempty"`
Location string // 时区,默认为 Asia/Shanghai
CustomCode string `mapstructure:"custom_code" json:"custom_code,omitempty"`
CustomCodeDashboard string `mapstructure:"custom_code_dashboard" json:"custom_code_dashboard,omitempty"`
v *viper.Viper
IgnoredIPNotificationServerIDs map[uint64]bool // [ServerID] -> bool(值为true代表当前ServerID在特定服务器列表内
MaxTCPPingValue int32
AvgPingCount int
k *koanf.Koanf `json:"-"`
filePath string `json:"-"`
// 动态域名解析更新
DDNS struct {
Enable bool
Provider string
AccessID string
AccessSecret string
WebhookURL string
WebhookMethod string
WebhookRequestBody string
WebhookHeaders string
MaxRetries uint32
Profiles map[string]DDNSProfile
}
}
type DDNSProfile struct {
Provider string
AccessID string
AccessSecret string
WebhookURL string
WebhookMethod string
WebhookRequestBody string
WebhookHeaders string
}
// Read 读取配置文件并应用
func (c *Config) Read(path string) error {
c.k = koanf.New(".")
c.filePath = path
err := c.k.Load(env.Provider("NZ_", ".", func(s string) string {
return strings.Replace(strings.ToLower(strings.TrimPrefix(s, "NZ_")), "_", ".", -1)
}), nil)
c.v = viper.New()
c.v.SetConfigFile(path)
err := c.v.ReadInConfig()
if err != nil {
return err
}
if _, err := os.Stat(path); err == nil {
err = c.k.Load(file.Provider(path), kyaml.Parser())
if err != nil {
return err
}
}
err = c.k.Unmarshal("", c)
err = c.v.Unmarshal(c)
if err != nil {
return err
}
if c.ListenPort == 0 {
c.ListenPort = 8008
if c.Site.Theme == "" {
c.Site.Theme = "default"
}
if c.Site.DashboardTheme == "" {
c.Site.DashboardTheme = "default"
}
if c.Language == "" {
c.Language = "zh_CN"
c.Language = "zh-CN"
}
if c.GRPCPort == 0 {
c.GRPCPort = 5555
}
if c.EnableIPChangeNotification && c.IPChangeNotificationTag == "" {
c.IPChangeNotificationTag = "default"
}
if c.Location == "" {
c.Location = "Asia/Shanghai"
}
if c.MaxTCPPingValue == 0 {
c.MaxTCPPingValue = 1000
}
if c.AvgPingCount == 0 {
c.AvgPingCount = 2
}
if c.JWTSecretKey == "" {
c.JWTSecretKey, err = utils.GenerateRandomString(1024)
if err != nil {
return err
}
if err = c.Save(); err != nil {
return err
}
}
if c.AgentSecretKey == "" {
c.AgentSecretKey, err = utils.GenerateRandomString(32)
if err != nil {
return err
}
if err = c.Save(); err != nil {
return err
}
if c.DDNS.MaxRetries == 0 {
c.DDNS.MaxRetries = 3
}
c.updateIgnoredIPNotificationID()
@ -132,11 +204,5 @@ func (c *Config) Save() error {
if err != nil {
return err
}
dir := filepath.Dir(c.filePath)
if err := os.MkdirAll(dir, 0750); err != nil {
return err
}
return os.WriteFile(c.filePath, data, 0600)
return os.WriteFile(c.v.ConfigFileUsed(), data, os.ModePerm)
}

View File

@ -3,7 +3,7 @@ package model
import (
"time"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/naiba/nezha/pkg/utils"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
)
@ -18,28 +18,19 @@ const (
type Cron struct {
Common
Name string `json:"name"`
TaskType uint8 `gorm:"default:0" json:"task_type"` // 0:计划任务 1:触发任务
Scheduler string `json:"scheduler"` // 分钟 小时 天 月 星期
Command string `json:"command,omitempty"`
Servers []uint64 `gorm:"-" json:"servers"`
PushSuccessful bool `json:"push_successful,omitempty"` // 推送成功的通知
NotificationGroupID uint64 `json:"notification_group_id"` // 指定通知方式的分组
LastExecutedAt time.Time `json:"last_executed_at,omitempty"` // 最后一次执行时间
LastResult bool `json:"last_result,omitempty"` // 最后一次执行结果
Cover uint8 `json:"cover"` // 计划任务覆盖范围 (0:仅覆盖特定服务器 1:仅忽略特定服务器 2:由触发该计划任务的服务器执行)
Name string
TaskType uint8 `gorm:"default:0"` // 0:计划任务 1:触发任务
Scheduler string //分钟 小时 天 月 星期
Command string
Servers []uint64 `gorm:"-"`
PushSuccessful bool // 推送成功的通知
NotificationTag string // 指定通知方式的分组
LastExecutedAt time.Time // 最后一次执行时间
LastResult bool // 最后一次执行结果
Cover uint8 // 计划任务覆盖范围 (0:仅覆盖特定服务器 1:仅忽略特定服务器 2:由触发该计划任务的服务器执行)
CronJobID cron.EntryID `gorm:"-" json:"cron_job_id,omitempty"`
ServersRaw string `json:"-"`
}
func (c *Cron) BeforeSave(tx *gorm.DB) error {
if data, err := utils.Json.Marshal(c.Servers); err != nil {
return err
} else {
c.ServersRaw = string(data)
}
return nil
CronJobID cron.EntryID `gorm:"-"`
ServersRaw string
}
func (c *Cron) AfterFind(tx *gorm.DB) error {

View File

@ -1,12 +0,0 @@
package model
type CronForm struct {
TaskType uint8 `json:"task_type,omitempty" default:"0"` // 0:计划任务 1:触发任务
Name string `json:"name,omitempty" minLength:"1"`
Scheduler string `json:"scheduler,omitempty"`
Command string `json:"command,omitempty" validate:"optional"`
Servers []uint64 `json:"servers,omitempty"`
Cover uint8 `json:"cover,omitempty" default:"0"`
PushSuccessful bool `json:"push_successful,omitempty" validate:"optional"`
NotificationGroupID uint64 `json:"notification_group_id,omitempty"`
}

View File

@ -1,52 +0,0 @@
package model
import (
"github.com/nezhahq/nezha/pkg/utils"
"gorm.io/gorm"
)
const (
ProviderDummy = "dummy"
ProviderWebHook = "webhook"
ProviderCloudflare = "cloudflare"
ProviderTencentCloud = "tencentcloud"
)
var ProviderList = []string{
ProviderDummy, ProviderWebHook, ProviderCloudflare, ProviderTencentCloud,
}
type DDNSProfile struct {
Common
EnableIPv4 *bool `json:"enable_ipv4,omitempty"`
EnableIPv6 *bool `json:"enable_ipv6,omitempty"`
MaxRetries uint64 `json:"max_retries"`
Name string `json:"name"`
Provider string `json:"provider"`
AccessID string `json:"access_id,omitempty"`
AccessSecret string `json:"access_secret,omitempty"`
WebhookURL string `json:"webhook_url,omitempty"`
WebhookMethod uint8 `json:"webhook_method,omitempty"`
WebhookRequestType uint8 `json:"webhook_request_type,omitempty"`
WebhookRequestBody string `json:"webhook_request_body,omitempty"`
WebhookHeaders string `json:"webhook_headers,omitempty"`
Domains []string `json:"domains" gorm:"-"`
DomainsRaw string `json:"-"`
}
func (d DDNSProfile) TableName() string {
return "ddns"
}
func (d *DDNSProfile) BeforeSave(tx *gorm.DB) error {
if data, err := utils.Json.Marshal(d.Domains); err != nil {
return err
} else {
d.DomainsRaw = string(data)
}
return nil
}
func (d *DDNSProfile) AfterFind(tx *gorm.DB) error {
return utils.Json.Unmarshal([]byte(d.DomainsRaw), &d.Domains)
}

View File

@ -1,17 +0,0 @@
package model
type DDNSForm struct {
MaxRetries uint64 `json:"max_retries,omitempty" default:"3"`
EnableIPv4 bool `json:"enable_ipv4,omitempty" validate:"optional"`
EnableIPv6 bool `json:"enable_ipv6,omitempty" validate:"optional"`
Name string `json:"name,omitempty" minLength:"1"`
Provider string `json:"provider,omitempty"`
Domains []string `json:"domains,omitempty"`
AccessID string `json:"access_id,omitempty" validate:"optional"`
AccessSecret string `json:"access_secret,omitempty" validate:"optional"`
WebhookURL string `json:"webhook_url,omitempty" validate:"optional"`
WebhookMethod uint8 `json:"webhook_method,omitempty" validate:"optional" default:"1"`
WebhookRequestType uint8 `json:"webhook_request_type,omitempty" validate:"optional" default:"1"`
WebhookRequestBody string `json:"webhook_request_body,omitempty" validate:"optional"`
WebhookHeaders string `json:"webhook_headers,omitempty" validate:"optional"`
}

View File

@ -1,5 +0,0 @@
package model
type CreateFMResponse struct {
SessionID string `json:"session_id,omitempty"`
}

View File

@ -1,9 +1,7 @@
package model
import (
"fmt"
pb "github.com/nezhahq/nezha/proto"
pb "github.com/naiba/nezha/proto"
)
const (
@ -12,40 +10,25 @@ const (
MTReportHostState
)
type SensorTemperature struct {
Name string
Temperature float64
}
type HostState struct {
CPU float64 `json:"cpu,omitempty"`
MemUsed uint64 `json:"mem_used,omitempty"`
SwapUsed uint64 `json:"swap_used,omitempty"`
DiskUsed uint64 `json:"disk_used,omitempty"`
NetInTransfer uint64 `json:"net_in_transfer,omitempty"`
NetOutTransfer uint64 `json:"net_out_transfer,omitempty"`
NetInSpeed uint64 `json:"net_in_speed,omitempty"`
NetOutSpeed uint64 `json:"net_out_speed,omitempty"`
Uptime uint64 `json:"uptime,omitempty"`
Load1 float64 `json:"load_1,omitempty"`
Load5 float64 `json:"load_5,omitempty"`
Load15 float64 `json:"load_15,omitempty"`
TcpConnCount uint64 `json:"tcp_conn_count,omitempty"`
UdpConnCount uint64 `json:"udp_conn_count,omitempty"`
ProcessCount uint64 `json:"process_count,omitempty"`
Temperatures []SensorTemperature `json:"temperatures,omitempty"`
GPU []float64 `json:"gpu,omitempty"`
CPU float64
MemUsed uint64
SwapUsed uint64
DiskUsed uint64
NetInTransfer uint64
NetOutTransfer uint64
NetInSpeed uint64
NetOutSpeed uint64
Uptime uint64
Load1 float64
Load5 float64
Load15 float64
TcpConnCount uint64
UdpConnCount uint64
ProcessCount uint64
}
func (s *HostState) PB() *pb.State {
var ts []*pb.State_SensorTemperature
for _, t := range s.Temperatures {
ts = append(ts, &pb.State_SensorTemperature{
Name: t.Name,
Temperature: t.Temperature,
})
}
return &pb.State{
Cpu: s.CPU,
MemUsed: s.MemUsed,
@ -62,20 +45,10 @@ func (s *HostState) PB() *pb.State {
TcpConnCount: s.TcpConnCount,
UdpConnCount: s.UdpConnCount,
ProcessCount: s.ProcessCount,
Temperatures: ts,
Gpu: s.GPU,
}
}
func PB2State(s *pb.State) HostState {
var ts []SensorTemperature
for _, t := range s.GetTemperatures() {
ts = append(ts, SensorTemperature{
Name: t.GetName(),
Temperature: t.GetTemperature(),
})
}
return HostState{
CPU: s.GetCpu(),
MemUsed: s.GetMemUsed(),
@ -92,23 +65,22 @@ func PB2State(s *pb.State) HostState {
TcpConnCount: s.GetTcpConnCount(),
UdpConnCount: s.GetUdpConnCount(),
ProcessCount: s.GetProcessCount(),
Temperatures: ts,
GPU: s.GetGpu(),
}
}
type Host struct {
Platform string `json:"platform,omitempty"`
PlatformVersion string `json:"platform_version,omitempty"`
CPU []string `json:"cpu,omitempty"`
MemTotal uint64 `json:"mem_total,omitempty"`
DiskTotal uint64 `json:"disk_total,omitempty"`
SwapTotal uint64 `json:"swap_total,omitempty"`
Arch string `json:"arch,omitempty"`
Virtualization string `json:"virtualization,omitempty"`
BootTime uint64 `json:"boot_time,omitempty"`
Version string `json:"version,omitempty"`
GPU []string `json:"gpu,omitempty"`
Platform string
PlatformVersion string
CPU []string
MemTotal uint64
DiskTotal uint64
SwapTotal uint64
Arch string
Virtualization string
BootTime uint64
IP string `json:"-"`
CountryCode string
Version string
}
func (h *Host) PB() *pb.Host {
@ -122,8 +94,9 @@ func (h *Host) PB() *pb.Host {
Arch: h.Arch,
Virtualization: h.Virtualization,
BootTime: h.BootTime,
Ip: h.IP,
CountryCode: h.CountryCode,
Version: h.Version,
Gpu: h.GPU,
}
}
@ -138,36 +111,8 @@ func PB2Host(h *pb.Host) Host {
Arch: h.GetArch(),
Virtualization: h.GetVirtualization(),
BootTime: h.GetBootTime(),
IP: h.GetIp(),
CountryCode: h.GetCountryCode(),
Version: h.GetVersion(),
GPU: h.GetGpu(),
}
}
type IP struct {
IPv4Addr string `json:"ipv4_addr,omitempty"`
IPv6Addr string `json:"ipv6_addr,omitempty"`
}
func (p *IP) Join() string {
if p.IPv4Addr != "" && p.IPv6Addr != "" {
return fmt.Sprintf("%s/%s", p.IPv4Addr, p.IPv6Addr)
} else if p.IPv4Addr != "" {
return p.IPv4Addr
}
return p.IPv6Addr
}
type GeoIP struct {
IP IP `json:"ip,omitempty"`
CountryCode string `json:"country_code,omitempty"`
}
func PB2GeoIP(p *pb.GeoIP) GeoIP {
pbIP := p.GetIp()
return GeoIP{
IP: IP{
IPv4Addr: pbIP.GetIpv4(),
IPv6Addr: pbIP.GetIpv6(),
},
}
}

136
model/monitor.go Normal file
View File

@ -0,0 +1,136 @@
package model
import (
"fmt"
"log"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
"github.com/naiba/nezha/pkg/utils"
pb "github.com/naiba/nezha/proto"
)
const (
_ = iota
TaskTypeHTTPGET
TaskTypeICMPPing
TaskTypeTCPPing
TaskTypeCommand
TaskTypeTerminal
TaskTypeUpgrade
TaskTypeKeepalive
)
type TerminalTask struct {
// websocket 主机名
Host string `json:"host,omitempty"`
// 是否启用 SSL
UseSSL bool `json:"use_ssl,omitempty"`
// 会话标识
Session string `json:"session,omitempty"`
// Agent在连接Server时需要的额外Cookie信息
Cookie string `json:"cookie,omitempty"`
}
const (
MonitorCoverAll = iota
MonitorCoverIgnoreAll
)
type Monitor struct {
Common
Name string
Type uint8
Target string
SkipServersRaw string
Duration uint64
Notify bool
NotificationTag string // 当前服务监控所属的通知组
Cover uint8
EnableTriggerTask bool `gorm:"default: false"`
EnableShowInService bool `gorm:"default: false"`
FailTriggerTasksRaw string `gorm:"default:'[]'"`
RecoverTriggerTasksRaw string `gorm:"default:'[]'"`
FailTriggerTasks []uint64 `gorm:"-" json:"-"` // 失败时执行的触发任务id
RecoverTriggerTasks []uint64 `gorm:"-" json:"-"` // 恢复时执行的触发任务id
MinLatency float32
MaxLatency float32
LatencyNotify bool
SkipServers map[uint64]bool `gorm:"-" json:"-"`
CronJobID cron.EntryID `gorm:"-" json:"-"`
}
func (m *Monitor) PB() *pb.Task {
return &pb.Task{
Id: m.ID,
Type: uint64(m.Type),
Data: m.Target,
}
}
// CronSpec 返回服务监控请求间隔对应的 cron 表达式
func (m *Monitor) CronSpec() string {
if m.Duration == 0 {
// 默认间隔 30 秒
m.Duration = 30
}
return fmt.Sprintf("@every %ds", m.Duration)
}
func (m *Monitor) BeforeSave(tx *gorm.DB) error {
if data, err := utils.Json.Marshal(m.FailTriggerTasks); err != nil {
return err
} else {
m.FailTriggerTasksRaw = string(data)
}
if data, err := utils.Json.Marshal(m.RecoverTriggerTasks); err != nil {
return err
} else {
m.RecoverTriggerTasksRaw = string(data)
}
return nil
}
func (m *Monitor) AfterFind(tx *gorm.DB) error {
m.SkipServers = make(map[uint64]bool)
var skipServers []uint64
if err := utils.Json.Unmarshal([]byte(m.SkipServersRaw), &skipServers); err != nil {
log.Println("NEZHA>> Monitor.AfterFind:", err)
return nil
}
for i := 0; i < len(skipServers); i++ {
m.SkipServers[skipServers[i]] = true
}
// 加载触发任务列表
if err := utils.Json.Unmarshal([]byte(m.FailTriggerTasksRaw), &m.FailTriggerTasks); err != nil {
return err
}
if err := utils.Json.Unmarshal([]byte(m.RecoverTriggerTasksRaw), &m.RecoverTriggerTasks); err != nil {
return err
}
return nil
}
// IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true
func IsServiceSentinelNeeded(t uint64) bool {
return t != TaskTypeCommand && t != TaskTypeTerminal && t != TaskTypeUpgrade
}
func (m *Monitor) InitSkipServers() error {
var skipServers []uint64
if err := utils.Json.Unmarshal([]byte(m.SkipServersRaw), &skipServers); err != nil {
return err
}
m.SkipServers = make(map[uint64]bool)
for i := 0; i < len(skipServers); i++ {
m.SkipServers[skipServers[i]] = true
}
return nil
}

21
model/monitor_history.go Normal file
View File

@ -0,0 +1,21 @@
package model
import (
"time"
"gorm.io/gorm"
)
// MonitorHistory 历史监控记录
type MonitorHistory struct {
ID uint64 `gorm:"primaryKey"`
CreatedAt time.Time `gorm:"index;<-:create;index:idx_server_id_created_at_monitor_id_avg_delay"`
UpdatedAt time.Time `gorm:"autoUpdateTime"`
DeletedAt gorm.DeletedAt `gorm:"index"`
MonitorID uint64 `gorm:"index:idx_server_id_created_at_monitor_id_avg_delay"`
ServerID uint64 `gorm:"index:idx_server_id_created_at_monitor_id_avg_delay"`
AvgDelay float32 `gorm:"index:idx_server_id_created_at_monitor_id_avg_delay"` // 平均延迟,毫秒
Up uint64 // 检查状态良好计数
Down uint64 // 检查状态异常计数
Data string
}

View File

@ -1,9 +0,0 @@
package model
type NAT struct {
Common
Name string `json:"name"`
ServerID uint64 `json:"server_id"`
Host string `json:"host"`
Domain string `json:"domain" gorm:"unique"`
}

View File

@ -1,8 +0,0 @@
package model
type NATForm struct {
Name string `json:"name,omitempty" minLength:"1"`
ServerID uint64 `json:"server_id,omitempty"`
Host string `json:"host,omitempty"`
Domain string `json:"domain,omitempty"`
}

View File

@ -9,7 +9,7 @@ import (
"strings"
"time"
"github.com/nezhahq/nezha/pkg/utils"
"github.com/naiba/nezha/pkg/utils"
)
const (
@ -32,13 +32,14 @@ type NotificationServerBundle struct {
type Notification struct {
Common
Name string `json:"name"`
URL string `json:"url"`
RequestMethod uint8 `json:"request_method"`
RequestType uint8 `json:"request_type"`
RequestHeader string `json:"request_header" gorm:"type:longtext"`
RequestBody string `json:"request_body" gorm:"type:longtext"`
VerifyTLS *bool `json:"verify_tls,omitempty"`
Name string
Tag string // 分组名
URL string
RequestMethod int
RequestType int
RequestHeader string `gorm:"type:longtext" `
RequestBody string `gorm:"type:longtext" `
VerifySSL *bool
}
func (ns *NotificationServerBundle) reqURL(message string) string {
@ -70,8 +71,8 @@ func (ns *NotificationServerBundle) reqBody(message string) (string, error) {
return string(msgBytes)[1 : len(msgBytes)-1]
}), nil
case NotificationRequestTypeForm:
data, err := utils.GjsonParseStringMap(n.RequestBody)
if err != nil {
var data map[string]string
if err := utils.Json.Unmarshal([]byte(n.RequestBody), &data); err != nil {
return "", err
}
params := url.Values{}
@ -98,8 +99,8 @@ func (n *Notification) setRequestHeader(req *http.Request) error {
if n.RequestHeader == "" {
return nil
}
m, err := utils.GjsonParseStringMap(n.RequestHeader)
if err != nil {
var m map[string]string
if err := utils.Json.Unmarshal([]byte(n.RequestHeader), &m); err != nil {
return err
}
for k, v := range m {
@ -111,7 +112,7 @@ func (n *Notification) setRequestHeader(req *http.Request) error {
func (ns *NotificationServerBundle) Send(message string) error {
var client *http.Client
n := ns.Notification
if n.VerifyTLS != nil && *n.VerifyTLS {
if n.VerifySSL != nil && *n.VerifySSL {
client = utils.HttpClient
} else {
client = utils.HttpClientSkipTlsVerify
@ -169,23 +170,14 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
if ns.Server != nil {
str = strings.ReplaceAll(str, "#SERVER.NAME#", mod(ns.Server.Name))
str = strings.ReplaceAll(str, "#SERVER.ID#", mod(fmt.Sprintf("%d", ns.Server.ID)))
str = strings.ReplaceAll(str, "#SERVER.CPU#", mod(fmt.Sprintf("%f", ns.Server.State.CPU)))
str = strings.ReplaceAll(str, "#SERVER.MEM#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed)))
str = strings.ReplaceAll(str, "#SERVER.SWAP#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed)))
str = strings.ReplaceAll(str, "#SERVER.DISK#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed)))
str = strings.ReplaceAll(str, "#SERVER.MEMUSED#", mod(fmt.Sprintf("%d", ns.Server.State.MemUsed)))
str = strings.ReplaceAll(str, "#SERVER.SWAPUSED#", mod(fmt.Sprintf("%d", ns.Server.State.SwapUsed)))
str = strings.ReplaceAll(str, "#SERVER.DISKUSED#", mod(fmt.Sprintf("%d", ns.Server.State.DiskUsed)))
str = strings.ReplaceAll(str, "#SERVER.MEMTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.MemTotal)))
str = strings.ReplaceAll(str, "#SERVER.SWAPTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.SwapTotal)))
str = strings.ReplaceAll(str, "#SERVER.DISKTOTAL#", mod(fmt.Sprintf("%d", ns.Server.Host.DiskTotal)))
str = strings.ReplaceAll(str, "#SERVER.NETINSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetInSpeed)))
str = strings.ReplaceAll(str, "#SERVER.NETOUTSPEED#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutSpeed)))
str = strings.ReplaceAll(str, "#SERVER.TRANSFERIN#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer)))
str = strings.ReplaceAll(str, "#SERVER.TRANSFEROUT#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer)))
str = strings.ReplaceAll(str, "#SERVER.NETINTRANSFER#", mod(fmt.Sprintf("%d", ns.Server.State.NetInTransfer)))
str = strings.ReplaceAll(str, "#SERVER.NETOUTTRANSFER#", mod(fmt.Sprintf("%d", ns.Server.State.NetOutTransfer)))
str = strings.ReplaceAll(str, "#SERVER.LOAD1#", mod(fmt.Sprintf("%f", ns.Server.State.Load1)))
str = strings.ReplaceAll(str, "#SERVER.LOAD5#", mod(fmt.Sprintf("%f", ns.Server.State.Load5)))
str = strings.ReplaceAll(str, "#SERVER.LOAD15#", mod(fmt.Sprintf("%f", ns.Server.State.Load15)))
@ -193,7 +185,7 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
str = strings.ReplaceAll(str, "#SERVER.UDPCONNCOUNT#", mod(fmt.Sprintf("%d", ns.Server.State.UdpConnCount)))
var ipv4, ipv6, validIP string
ipList := strings.Split(ns.Server.GeoIP.IP.Join(), "/")
ipList := strings.Split(ns.Server.Host.IP, "/")
if len(ipList) > 1 {
// 双栈
ipv4 = ipList[0]
@ -201,7 +193,7 @@ func (ns *NotificationServerBundle) replaceParamsInString(str string, message st
validIP = ipv4
} else if len(ipList) == 1 {
// 仅ipv4|ipv6
if strings.IndexByte(ipList[0], ':') != -1 {
if strings.Contains(ipList[0], ":") {
ipv6 = ipList[0]
validIP = ipv6
} else {

View File

@ -1,12 +0,0 @@
package model
type NotificationForm struct {
Name string `json:"name,omitempty" minLength:"1"`
URL string `json:"url,omitempty"`
RequestMethod uint8 `json:"request_method,omitempty"`
RequestType uint8 `json:"request_type,omitempty"`
RequestHeader string `json:"request_header,omitempty"`
RequestBody string `json:"request_body,omitempty"`
VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"`
SkipCheck bool `json:"skip_check,omitempty" validate:"optional"`
}

View File

@ -1,6 +0,0 @@
package model
type NotificationGroup struct {
Common
Name string `json:"name"`
}

View File

@ -1,11 +0,0 @@
package model
type NotificationGroupForm struct {
Name string `json:"name" minLength:"1"`
Notifications []uint64 `json:"notifications"`
}
type NotificationGroupResponseItem struct {
Group NotificationGroup `json:"group"`
Notifications []uint64 `json:"notifications"`
}

View File

@ -1,7 +0,0 @@
package model
type NotificationGroupNotification struct {
Common
NotificationGroupID uint64 `json:"notification_group_id" gorm:"uniqueIndex:idx_notification_group_notification"`
NotificationID uint64 `json:"notification_id" gorm:"uniqueIndex:idx_notification_group_notification"`
}

View File

@ -17,8 +17,8 @@ type testSt struct {
url string
body string
header string
reqType uint8
reqMethod uint8
reqType int
reqMethod int
expectURL string
expectBody string
expectMethod string
@ -37,6 +37,8 @@ func execCase(t *testing.T, item testSt) {
server := Server{
Common: Common{},
Name: "ServerName",
Tag: "",
Secret: "",
Note: "",
DisplayIndex: 0,
Host: &Host{
@ -49,6 +51,8 @@ func execCase(t *testing.T, item testSt) {
Arch: "",
Virtualization: "",
BootTime: 0,
IP: "1.1.1.1",
CountryCode: "",
Version: "",
},
State: &HostState{
@ -68,17 +72,11 @@ func execCase(t *testing.T, item testSt) {
UdpConnCount: 0,
ProcessCount: 0,
},
GeoIP: &GeoIP{
IP: IP{
IPv4Addr: "1.1.1.1",
},
CountryCode: "",
},
LastActive: time.Time{},
TaskClose: nil,
TaskStream: nil,
PrevTransferInSnapshot: 0,
PrevTransferOutSnapshot: 0,
LastActive: time.Time{},
TaskClose: nil,
TaskStream: nil,
PrevHourlyTransferIn: 0,
PrevHourlyTransferOut: 0,
}
ns := NotificationServerBundle{
Notification: &n,

View File

@ -1,13 +1,10 @@
package model
import (
"slices"
"strings"
"time"
"gorm.io/gorm"
"github.com/nezhahq/nezha/pkg/utils"
)
const (
@ -23,19 +20,19 @@ type Rule struct {
// 指标类型cpu、memory、swap、disk、net_in_speed、net_out_speed
// net_all_speed、transfer_in、transfer_out、transfer_all、offline
// transfer_in_cycle、transfer_out_cycle、transfer_all_cycle
Type string `json:"type"`
Min float64 `json:"min,omitempty" validate:"optional"` // 最小阈值 (百分比、字节 kb ÷ 1024)
Max float64 `json:"max,omitempty" validate:"optional"` // 最大阈值 (百分比、字节 kb ÷ 1024)
CycleStart *time.Time `json:"cycle_start,omitempty" validate:"optional"` // 流量统计的开始时间
CycleInterval uint64 `json:"cycle_interval,omitempty" validate:"optional"` // 流量统计周期
CycleUnit string `json:"cycle_unit,omitempty" enums:"hour,day,week,month,year" validate:"optional" default:"hour"` // 流量统计周期单位默认hour,可选(hour, day, week, month, year)
Duration uint64 `json:"duration,omitempty" validate:"optional"` // 持续时间 (秒)
Cover uint64 `json:"cover"` // 覆盖范围 RuleCoverAll/IgnoreAll
Ignore map[uint64]bool `json:"ignore,omitempty" validate:"optional"` // 覆盖范围的排除
Type string `json:"type,omitempty"`
Min float64 `json:"min,omitempty"` // 最小阈值 (百分比、字节 kb ÷ 1024)
Max float64 `json:"max,omitempty"` // 最大阈值 (百分比、字节 kb ÷ 1024)
CycleStart *time.Time `json:"cycle_start,omitempty"` // 流量统计的开始时间
CycleInterval uint64 `json:"cycle_interval,omitempty"` // 流量统计周期
CycleUnit string `json:"cycle_unit,omitempty"` // 流量统计周期单位默认hour,可选(hour, day, week, month, year)
Duration uint64 `json:"duration,omitempty"` // 持续时间 (秒)
Cover uint64 `json:"cover,omitempty"` // 覆盖范围 RuleCoverAll/IgnoreAll
Ignore map[uint64]bool `json:"ignore,omitempty"` // 覆盖范围的排除
// 只作为缓存使用,记录下次该检测的时间
NextTransferAt map[uint64]time.Time `json:"-"`
LastCycleStatus map[uint64]bool `json:"-"`
NextTransferAt map[uint64]time.Time `json:"-"`
LastCycleStatus map[uint64]interface{} `json:"-"`
}
func percentage(used, total uint64) float64 {
@ -45,15 +42,15 @@ func percentage(used, total uint64) float64 {
return float64(used) * 100 / float64(total)
}
// Snapshot 未通过规则返回 false, 通过返回 true
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) bool {
// Snapshot 未通过规则返回 struct{}{}, 通过返回 nil
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) interface{} {
// 监控全部但是排除了此服务器
if u.Cover == RuleCoverAll && u.Ignore[server.ID] {
return true
return nil
}
// 忽略全部但是指定监控了此服务器
if u.Cover == RuleCoverIgnoreAll && !u.Ignore[server.ID] {
return true
return nil
}
// 循环区间流量检测 · 短期无需重复检测
@ -66,8 +63,6 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
switch u.Type {
case "cpu":
src = float64(server.State.CPU)
case "gpu_max":
src = slices.Max(server.State.GPU)
case "memory":
src = percentage(server.State.MemUsed, server.Host.MemTotal)
case "swap":
@ -93,24 +88,24 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
src = float64(server.LastActive.Unix())
}
case "transfer_in_cycle":
src = float64(utils.Uint64SubInt64(server.State.NetInTransfer, server.PrevTransferInSnapshot))
src = float64(server.State.NetInTransfer - uint64(server.PrevHourlyTransferIn))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
db.Model(&Transfer{}).Select("SUM(`in`) AS n").Where("created_at > ? AND server_id = ?", u.GetTransferDurationStart(), server.ID).Scan(&res)
src += float64(res.N)
}
case "transfer_out_cycle":
src = float64(utils.Uint64SubInt64(server.State.NetOutTransfer, server.PrevTransferOutSnapshot))
src = float64(server.State.NetOutTransfer - uint64(server.PrevHourlyTransferOut))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
db.Model(&Transfer{}).Select("SUM(`out`) AS n").Where("created_at > ? AND server_id = ?", u.GetTransferDurationStart(), server.ID).Scan(&res)
src += float64(res.N)
}
case "transfer_all_cycle":
src = float64(utils.Uint64SubInt64(server.State.NetOutTransfer, server.PrevTransferOutSnapshot) + utils.Uint64SubInt64(server.State.NetInTransfer, server.PrevTransferInSnapshot))
src = float64(server.State.NetOutTransfer - uint64(server.PrevHourlyTransferOut) + server.State.NetInTransfer - uint64(server.PrevHourlyTransferIn))
if u.CycleInterval != 0 {
var res NResult
db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("datetime(`created_at`) >= datetime(?) AND server_id = ?", u.GetTransferDurationStart().UTC(), server.ID).Scan(&res)
db.Model(&Transfer{}).Select("SUM(`in`+`out`) AS n").Where("created_at > ? AND server_id = ?", u.GetTransferDurationStart(), server.ID).Scan(&res)
src += float64(res.N)
}
case "load1":
@ -125,16 +120,6 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
src = float64(server.State.UdpConnCount)
case "process_count":
src = float64(server.State.ProcessCount)
case "temperature_max":
var temp []float64
if server.State.Temperatures != nil {
for _, tempStat := range server.State.Temperatures {
if tempStat.Temperature != 0 {
temp = append(temp, tempStat.Temperature)
}
}
src = slices.Max(temp)
}
}
// 循环区间流量检测 · 更新下次需要检测时间
@ -147,13 +132,13 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
u.NextTransferAt = make(map[uint64]time.Time)
}
if u.LastCycleStatus == nil {
u.LastCycleStatus = make(map[uint64]bool)
u.LastCycleStatus = make(map[uint64]interface{})
}
u.NextTransferAt[server.ID] = time.Now().Add(time.Second * time.Duration(seconds))
if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
u.LastCycleStatus[server.ID] = false
u.LastCycleStatus[server.ID] = struct{}{}
} else {
u.LastCycleStatus[server.ID] = true
u.LastCycleStatus[server.ID] = nil
}
if cycleTransferStats.ServerName[server.ID] != server.Name {
cycleTransferStats.ServerName[server.ID] = server.Name
@ -166,94 +151,94 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
}
if u.Type == "offline" && float64(time.Now().Unix())-src > 6 {
return false
return struct{}{}
} else if (u.Max > 0 && src > u.Max) || (u.Min > 0 && src < u.Min) {
return false
return struct{}{}
}
return true
return nil
}
// IsTransferDurationRule 判断该规则是否属于周期流量规则 属于则返回true
func (u *Rule) IsTransferDurationRule() bool {
return strings.HasSuffix(u.Type, "_cycle")
func (rule Rule) IsTransferDurationRule() bool {
return strings.HasSuffix(rule.Type, "_cycle")
}
// GetTransferDurationStart 获取周期流量的起始时间
func (u *Rule) GetTransferDurationStart() time.Time {
func (rule Rule) GetTransferDurationStart() time.Time {
// Accept uppercase and lowercase
unit := strings.ToLower(u.CycleUnit)
startTime := *u.CycleStart
unit := strings.ToLower(rule.CycleUnit)
startTime := *rule.CycleStart
var nextTime time.Time
switch unit {
case "year":
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
nextTime = startTime.AddDate(int(rule.CycleInterval), 0, 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
nextTime = nextTime.AddDate(int(rule.CycleInterval), 0, 0)
}
case "month":
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
nextTime = startTime.AddDate(0, int(rule.CycleInterval), 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
nextTime = nextTime.AddDate(0, int(rule.CycleInterval), 0)
}
case "week":
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
nextTime = startTime.AddDate(0, 0, 7*int(rule.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
nextTime = nextTime.AddDate(0, 0, 7*int(rule.CycleInterval))
}
case "day":
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
nextTime = startTime.AddDate(0, 0, int(rule.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
nextTime = nextTime.AddDate(0, 0, int(rule.CycleInterval))
}
default:
// For hour unit or not set.
interval := 3600 * int64(u.CycleInterval)
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
interval := 3600 * int64(rule.CycleInterval)
startTime = time.Unix(rule.CycleStart.Unix()+(time.Now().Unix()-rule.CycleStart.Unix())/interval*interval, 0)
}
return startTime
}
// GetTransferDurationEnd 获取周期流量结束时间
func (u *Rule) GetTransferDurationEnd() time.Time {
func (rule Rule) GetTransferDurationEnd() time.Time {
// Accept uppercase and lowercase
unit := strings.ToLower(u.CycleUnit)
startTime := *u.CycleStart
unit := strings.ToLower(rule.CycleUnit)
startTime := *rule.CycleStart
var nextTime time.Time
switch unit {
case "year":
nextTime = startTime.AddDate(int(u.CycleInterval), 0, 0)
nextTime = startTime.AddDate(int(rule.CycleInterval), 0, 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(int(u.CycleInterval), 0, 0)
nextTime = nextTime.AddDate(int(rule.CycleInterval), 0, 0)
}
case "month":
nextTime = startTime.AddDate(0, int(u.CycleInterval), 0)
nextTime = startTime.AddDate(0, int(rule.CycleInterval), 0)
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, int(u.CycleInterval), 0)
nextTime = nextTime.AddDate(0, int(rule.CycleInterval), 0)
}
case "week":
nextTime = startTime.AddDate(0, 0, 7*int(u.CycleInterval))
nextTime = startTime.AddDate(0, 0, 7*int(rule.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, 7*int(u.CycleInterval))
nextTime = nextTime.AddDate(0, 0, 7*int(rule.CycleInterval))
}
case "day":
nextTime = startTime.AddDate(0, 0, int(u.CycleInterval))
nextTime = startTime.AddDate(0, 0, int(rule.CycleInterval))
for time.Now().After(nextTime) {
startTime = nextTime
nextTime = nextTime.AddDate(0, 0, int(u.CycleInterval))
nextTime = nextTime.AddDate(0, 0, int(rule.CycleInterval))
}
default:
// For hour unit or not set.
interval := 3600 * int64(u.CycleInterval)
startTime = time.Unix(u.CycleStart.Unix()+(time.Now().Unix()-u.CycleStart.Unix())/interval*interval, 0)
interval := 3600 * int64(rule.CycleInterval)
startTime = time.Unix(rule.CycleStart.Unix()+(time.Now().Unix()-rule.CycleStart.Unix())/interval*interval, 0)
nextTime = time.Unix(startTime.Unix()+interval, 0)
}

View File

@ -1,61 +1,62 @@
package model
import (
"log"
"sync"
"fmt"
"html/template"
"time"
"gorm.io/gorm"
"github.com/nezhahq/nezha/pkg/utils"
pb "github.com/nezhahq/nezha/proto"
"github.com/naiba/nezha/pkg/utils"
pb "github.com/naiba/nezha/proto"
)
type Server struct {
Common
Name string
Tag string // 分组名
Secret string `gorm:"uniqueIndex" json:"-"`
Note string `json:"-"` // 管理员可见备注
DisplayIndex int // 展示排序,越大越靠前
HideForGuest bool // 对游客隐藏
EnableDDNS bool // 是否启用DDNS 未在配置文件中启用DDNS 或 DDNS检查时间为0时此项无效
EnableIPv4 bool // 是否启用DDNS IPv4
EnableIpv6 bool // 是否启用DDNS IPv6
DDNSDomain string // DDNS中的前缀 如基础域名为abc.oracle DDNSName为mjj 就会把mjj.abc.oracle解析服务器IP 为空则停用
DDNSProfile string // DDNS配置
Name string `json:"name"`
UUID string `json:"uuid,omitempty" gorm:"unique"`
Note string `json:"note,omitempty"` // 管理员可见备注
PublicNote string `json:"public_note,omitempty"` // 公开备注
DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS
DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
Host *Host `gorm:"-"`
State *HostState `gorm:"-"`
LastActive time.Time `gorm:"-"`
DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
TaskClose chan error `gorm:"-" json:"-"`
TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"`
Host *Host `gorm:"-" json:"host,omitempty"`
State *HostState `gorm:"-" json:"state,omitempty"`
GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
TaskClose chan error `gorm:"-" json:"-"`
TaskCloseLock *sync.Mutex `gorm:"-" json:"-"`
TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"`
PrevTransferInSnapshot int64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量
PrevTransferOutSnapshot int64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量
PrevHourlyTransferIn int64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量
PrevHourlyTransferOut int64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量
}
func (s *Server) CopyFromRunningServer(old *Server) {
s.Host = old.Host
s.State = old.State
s.GeoIP = old.GeoIP
s.LastActive = old.LastActive
s.TaskClose = old.TaskClose
s.TaskCloseLock = old.TaskCloseLock
s.TaskStream = old.TaskStream
s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
s.PrevHourlyTransferIn = old.PrevHourlyTransferIn
s.PrevHourlyTransferOut = old.PrevHourlyTransferOut
}
func (s *Server) AfterFind(tx *gorm.DB) error {
if s.DDNSProfilesRaw != "" {
if err := utils.Json.Unmarshal([]byte(s.DDNSProfilesRaw), &s.DDNSProfiles); err != nil {
log.Println("NEZHA>> Server.AfterFind:", err)
return nil
}
func boolToString(b bool) string {
if b {
return "true"
}
return nil
return "false"
}
func (s Server) Marshal() template.JS {
name, _ := utils.Json.Marshal(s.Name)
tag, _ := utils.Json.Marshal(s.Tag)
note, _ := utils.Json.Marshal(s.Note)
secret, _ := utils.Json.Marshal(s.Secret)
ddnsDomain, _ := utils.Json.Marshal(s.DDNSDomain)
ddnsProfile, _ := utils.Json.Marshal(s.DDNSProfile)
return template.JS(fmt.Sprintf(`{"ID":%d,"Name":%s,"Secret":%s,"DisplayIndex":%d,"Tag":%s,"Note":%s,"HideForGuest": %s,"EnableDDNS": %s,"EnableIPv4": %s,"EnableIpv6": %s,"DDNSDomain": %s,"DDNSProfile": %s}`, s.ID, name, secret, s.DisplayIndex, tag, note, boolToString(s.HideForGuest), boolToString(s.EnableDDNS), boolToString(s.EnableIPv4), boolToString(s.EnableIpv6), ddnsDomain, ddnsProfile)) // #nosec
}

View File

@ -1,36 +0,0 @@
package model
import "time"
type StreamServer struct {
ID uint64 `json:"id,omitempty"`
Name string `json:"name,omitempty"`
PublicNote string `json:"public_note,omitempty"` // 公开备注,只第一个数据包有值
DisplayIndex int `json:"display_index,omitempty"` // 展示排序,越大越靠前
Host *Host `json:"host,omitempty"`
State *HostState `json:"state,omitempty"`
CountryCode string `json:"country_code,omitempty"`
LastActive time.Time `json:"last_active,omitempty"`
}
type StreamServerData struct {
Now int64 `json:"now,omitempty"`
Servers []StreamServer `json:"servers,omitempty"`
}
type ServerForm struct {
Name string `json:"name,omitempty"`
Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注
DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏
EnableDDNS bool `json:"enable_ddns,omitempty" validate:"optional"` // 启用DDNS
DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
}
type ForceUpdateResponse struct {
Success []uint64 `json:"success,omitempty" validate:"optional"`
Failure []uint64 `json:"failure,omitempty" validate:"optional"`
Offline []uint64 `json:"offline,omitempty" validate:"optional"`
}

View File

@ -1,7 +0,0 @@
package model
type ServerGroup struct {
Common
Name string `json:"name"`
}

View File

@ -1,11 +0,0 @@
package model
type ServerGroupForm struct {
Name string `json:"name" minLength:"1"`
Servers []uint64 `json:"servers"`
}
type ServerGroupResponseItem struct {
Group ServerGroup `json:"group"`
Servers []uint64 `json:"servers"`
}

View File

@ -1,7 +0,0 @@
package model
type ServerGroupServer struct {
Common
ServerGroupId uint64 `json:"server_group_id" gorm:"uniqueIndex:idx_server_group_server"`
ServerId uint64 `json:"server_id" gorm:"uniqueIndex:idx_server_group_server"`
}

30
model/server_test.go Normal file
View File

@ -0,0 +1,30 @@
package model
import (
"testing"
"github.com/naiba/nezha/pkg/utils"
)
func TestServerMarshal(t *testing.T) {
patterns := []string{
"asd > asd",
"asd \" asd",
"asd } asd",
}
for i := 0; i < len(patterns); i++ {
server := Server{
Name: patterns[i],
Tag: patterns[i],
}
serverStr := string(server.Marshal())
var serverRestore Server
if utils.Json.Unmarshal([]byte(serverStr), &serverRestore) != nil {
t.Fatalf("Error: %s", serverStr)
}
if server.Name != serverRestore.Name {
t.Fatalf("Expected %s, but got %s", server.Name, serverRestore.Name)
}
}
}

View File

@ -1,131 +0,0 @@
package model
import (
"fmt"
"log"
"github.com/robfig/cron/v3"
"gorm.io/gorm"
"github.com/nezhahq/nezha/pkg/utils"
pb "github.com/nezhahq/nezha/proto"
)
const (
_ = iota
TaskTypeHTTPGet
TaskTypeICMPPing
TaskTypeTCPPing
TaskTypeCommand
TaskTypeTerminal
TaskTypeUpgrade
TaskTypeKeepalive
TaskTypeTerminalGRPC
TaskTypeNAT
TaskTypeReportHostInfo
TaskTypeFM
)
type TerminalTask struct {
StreamID string
}
type TaskNAT struct {
StreamID string
Host string
}
type TaskFM struct {
StreamID string
}
const (
ServiceCoverAll = iota
ServiceCoverIgnoreAll
)
type Service struct {
Common
Name string `json:"name"`
Type uint8 `json:"type"`
Target string `json:"target"`
SkipServersRaw string `json:"-"`
Duration uint64 `json:"duration"`
Notify bool `json:"notify,omitempty"`
NotificationGroupID uint64 `json:"notification_group_id"` // 当前服务监控所属的通知组 ID
Cover uint8 `json:"cover"`
EnableTriggerTask bool `gorm:"default: false" json:"enable_trigger_task,omitempty"`
EnableShowInService bool `gorm:"default: false" json:"enable_show_in_service,omitempty"`
FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id
RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id
MinLatency float32 `json:"min_latency"`
MaxLatency float32 `json:"max_latency"`
LatencyNotify bool `json:"latency_notify,omitempty"`
SkipServers map[uint64]bool `gorm:"-" json:"skip_servers"`
CronJobID cron.EntryID `gorm:"-" json:"-"`
}
func (m *Service) PB() *pb.Task {
return &pb.Task{
Id: m.ID,
Type: uint64(m.Type),
Data: m.Target,
}
}
// CronSpec 返回服务监控请求间隔对应的 cron 表达式
func (m *Service) CronSpec() string {
if m.Duration == 0 {
// 默认间隔 30 秒
m.Duration = 30
}
return fmt.Sprintf("@every %ds", m.Duration)
}
func (m *Service) BeforeSave(tx *gorm.DB) error {
if data, err := utils.Json.Marshal(m.SkipServers); err != nil {
return err
} else {
m.SkipServersRaw = string(data)
}
if data, err := utils.Json.Marshal(m.FailTriggerTasks); err != nil {
return err
} else {
m.FailTriggerTasksRaw = string(data)
}
if data, err := utils.Json.Marshal(m.RecoverTriggerTasks); err != nil {
return err
} else {
m.RecoverTriggerTasksRaw = string(data)
}
return nil
}
func (m *Service) AfterFind(tx *gorm.DB) error {
m.SkipServers = make(map[uint64]bool)
if err := utils.Json.Unmarshal([]byte(m.SkipServersRaw), &m.SkipServers); err != nil {
log.Println("NEZHA>> Service.AfterFind:", err)
return nil
}
// 加载触发任务列表
if err := utils.Json.Unmarshal([]byte(m.FailTriggerTasksRaw), &m.FailTriggerTasks); err != nil {
return err
}
if err := utils.Json.Unmarshal([]byte(m.RecoverTriggerTasksRaw), &m.RecoverTriggerTasks); err != nil {
return err
}
return nil
}
// IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true
func IsServiceSentinelNeeded(t uint64) bool {
return t != TaskTypeCommand && t != TaskTypeTerminalGRPC && t != TaskTypeUpgrade
}

View File

@ -1,55 +0,0 @@
package model
import "time"
type ServiceForm struct {
Name string `json:"name,omitempty" minLength:"1"`
Target string `json:"target,omitempty"`
Type uint8 `json:"type,omitempty"`
Cover uint8 `json:"cover,omitempty"`
Notify bool `json:"notify,omitempty" validate:"optional"`
Duration uint64 `json:"duration,omitempty"`
MinLatency float32 `json:"min_latency,omitempty" default:"0.0"`
MaxLatency float32 `json:"max_latency,omitempty" default:"0.0"`
LatencyNotify bool `json:"latency_notify,omitempty" validate:"optional"`
EnableTriggerTask bool `json:"enable_trigger_task,omitempty" validate:"optional"`
EnableShowInService bool `json:"enable_show_in_service,omitempty" validate:"optional"`
FailTriggerTasks []uint64 `json:"fail_trigger_tasks,omitempty"`
RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks,omitempty"`
SkipServers map[uint64]bool `json:"skip_servers,omitempty"`
NotificationGroupID uint64 `json:"notification_group_id,omitempty"`
}
type ServiceResponseItem struct {
Service *Service `json:"service,omitempty"`
CurrentUp uint64 `json:"current_up"`
CurrentDown uint64 `json:"current_down"`
TotalUp uint64 `json:"total_up"`
TotalDown uint64 `json:"total_down"`
Delay *[30]float32 `json:"delay,omitempty"`
Up *[30]int `json:"up,omitempty"`
Down *[30]int `json:"down,omitempty"`
}
func (r ServiceResponseItem) TotalUptime() float32 {
if r.TotalUp+r.TotalDown == 0 {
return 0
}
return float32(r.TotalUp) / (float32(r.TotalUp + r.TotalDown)) * 100
}
type CycleTransferStats struct {
Name string `json:"name"`
From time.Time `json:"from"`
To time.Time `json:"to"`
Max uint64 `json:"max"`
Min uint64 `json:"min"`
ServerName map[uint64]string `json:"server_name,omitempty"`
Transfer map[uint64]uint64 `json:"transfer,omitempty"`
NextUpdate map[uint64]time.Time `json:"next_update,omitempty"`
}
type ServiceResponse struct {
Services map[uint64]ServiceResponseItem `json:"services,omitempty"`
CycleTransferStats map[uint64]CycleTransferStats `json:"cycle_transfer_stats,omitempty"`
}

View File

@ -1,17 +0,0 @@
package model
import (
"time"
)
type ServiceHistory struct {
ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
CreatedAt time.Time `gorm:"index;<-:create;index:idx_server_id_created_at_service_id_avg_delay" json:"created_at,omitempty"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
ServiceID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"service_id,omitempty"`
ServerID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"server_id,omitempty"`
AvgDelay float32 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"avg_delay,omitempty"` // 平均延迟,毫秒
Up uint64 `json:"up,omitempty"` // 检查状态良好计数
Down uint64 `json:"down,omitempty"` // 检查状态异常计数
Data string `json:"data,omitempty"`
}

View File

@ -1,10 +0,0 @@
package model
type ServiceInfos struct {
ServiceID uint64 `json:"monitor_id"`
ServerID uint64 `json:"server_id"`
ServiceName string `json:"monitor_name"`
ServerName string `json:"server_name"`
CreatedAt []int64 `json:"created_at"`
AvgDelay []float32 `json:"avg_delay"`
}

View File

@ -1,17 +0,0 @@
package model
type SettingForm struct {
CustomNameservers string `json:"custom_nameservers,omitempty" validate:"optional"`
IgnoredIPNotification string `json:"ignored_ip_notification,omitempty" validate:"optional"`
IPChangeNotificationGroupID uint64 `json:"ip_change_notification_group_id,omitempty"` // IP变更提醒的通知组
Cover uint8 `json:"cover,omitempty"`
SiteName string `json:"site_name,omitempty" minLength:"1"`
Language string `json:"language,omitempty" minLength:"2"`
InstallHost string `json:"install_host,omitempty" validate:"optional"`
CustomCode string `json:"custom_code,omitempty" validate:"optional"`
CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"`
RealIPHeader string `json:"real_ip_header,omitempty" validate:"optional"` // 真实IP
EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
}

View File

@ -1,12 +0,0 @@
package model
type TerminalForm struct {
Protocol string `json:"protocol,omitempty"`
ServerID uint64 `json:"server_id,omitempty"`
}
type CreateTerminalResponse struct {
SessionID string `json:"session_id,omitempty"`
ServerID uint64 `json:"server_id,omitempty"`
ServerName string `json:"server_name,omitempty"`
}

View File

@ -2,7 +2,7 @@ package model
type Transfer struct {
Common
ServerID uint64 `json:"server_id"`
In uint64 `json:"in"`
Out uint64 `json:"out"`
ServerID uint64
In uint64
Out uint64
}

View File

@ -1,12 +1,71 @@
package model
import (
"time"
"code.gitea.io/sdk/gitea"
"github.com/google/go-github/v47/github"
"github.com/xanzy/go-gitlab"
)
type User struct {
Common
Username string `json:"username,omitempty" gorm:"uniqueIndex"`
Password string `json:"password,omitempty" gorm:"type:char(72)"`
Login string `json:"login,omitempty"` // 登录名
AvatarURL string `json:"avatar_url,omitempty"` // 头像地址
Name string `json:"name,omitempty"` // 昵称
Blog string `json:"blog,omitempty"` // 网站链接
Email string `json:"email,omitempty"` // 邮箱
Hireable bool `json:"hireable,omitempty"`
Bio string `json:"bio,omitempty"` // 个人简介
Token string `json:"-"` // 认证 Token
TokenExpired time.Time `json:"token_expired,omitempty"` // Token 过期时间
SuperAdmin bool `json:"super_admin,omitempty"` // 超级管理员
}
type Profile struct {
User
LoginIP string `json:"login_ip,omitempty"`
func NewUserFromGitea(gu *gitea.User) User {
var u User
u.ID = uint64(gu.ID)
u.Login = gu.UserName
u.AvatarURL = gu.AvatarURL
u.Name = gu.FullName
if u.Name == "" {
u.Name = u.Login
}
u.Blog = gu.Website
u.Email = gu.Email
u.Bio = gu.Description
return u
}
func NewUserFromGitlab(gu *gitlab.User) User {
var u User
u.ID = uint64(gu.ID)
u.Login = gu.Username
u.AvatarURL = gu.AvatarURL
u.Name = gu.Name
if u.Name == "" {
u.Name = u.Login
}
u.Blog = gu.WebsiteURL
u.Email = gu.Email
u.Bio = gu.Bio
return u
}
func NewUserFromGitHub(gu *github.User) User {
var u User
u.ID = uint64(gu.GetID())
u.Login = gu.GetLogin()
u.AvatarURL = gu.GetAvatarURL()
u.Name = gu.GetName()
// 昵称为空的情况
if u.Name == "" {
u.Name = u.Login
}
u.Blog = gu.GetBlog()
u.Email = gu.GetEmail()
u.Hireable = gu.GetHireable()
u.Bio = gu.GetBio()
return u
}

View File

@ -1,12 +0,0 @@
package model
type UserForm struct {
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty" gorm:"type:char(72)"`
}
type ProfileForm struct {
OriginalPassword string `json:"original_password,omitempty"`
NewUsername string `json:"new_username,omitempty"`
NewPassword string `json:"new_password,omitempty"`
}

View File

@ -1,6 +0,0 @@
package model
type UserGroup struct {
Common
Name string `json:"name"`
}

View File

@ -1,7 +0,0 @@
package model
type UserGroupUser struct {
Common
UserGroupId uint64 `json:"user_group_id"`
UserId uint64 `json:"user_id"`
}

View File

@ -1,116 +0,0 @@
package model
import (
"errors"
"math/big"
"time"
"github.com/nezhahq/nezha/pkg/utils"
"gorm.io/gorm"
)
const (
_ uint8 = iota
WAFBlockReasonTypeLoginFail
WAFBlockReasonTypeBruteForceToken
WAFBlockReasonTypeAgentAuthFail
)
type WAFApiMock struct {
IP string `json:"ip,omitempty"`
Count uint64 `json:"count,omitempty"`
LastBlockReason uint8 `json:"last_block_reason,omitempty"`
LastBlockTimestamp uint64 `json:"last_block_timestamp,omitempty"`
}
type WAF struct {
IP []byte `gorm:"type:binary(16);primaryKey" json:"ip,omitempty"`
Count uint64 `json:"count,omitempty"`
LastBlockReason uint8 `json:"last_block_reason,omitempty"`
LastBlockTimestamp uint64 `json:"last_block_timestamp,omitempty"`
}
func (w *WAF) TableName() string {
return "waf"
}
func CheckIP(db *gorm.DB, ip string) error {
if ip == "" {
return nil
}
ipBinary, err := utils.IPStringToBinary(ip)
if err != nil {
return err
}
var w WAF
if err := db.First(&w, "ip = ?", ipBinary).Error; err != nil {
if err != gorm.ErrRecordNotFound {
return err
}
}
now := time.Now().Unix()
if powAdd(w.Count, 4, w.LastBlockTimestamp) > uint64(now) {
return errors.New("you are blocked by nezha WAF")
}
return nil
}
func ClearIP(db *gorm.DB, ip string) error {
if ip == "" {
return nil
}
ipBinary, err := utils.IPStringToBinary(ip)
if err != nil {
return err
}
return db.Unscoped().Delete(&WAF{}, "ip = ?", ipBinary).Error
}
func BatchClearIP(db *gorm.DB, ip []string) error {
if len(ip) < 1 {
return nil
}
ips := make([][]byte, 0, len(ip))
for _, s := range ip {
ipBinary, err := utils.IPStringToBinary(s)
if err != nil {
continue
}
ips = append(ips, ipBinary)
}
return db.Unscoped().Delete(&WAF{}, "ip in (?)", ips).Error
}
func BlockIP(db *gorm.DB, ip string, reason uint8) error {
if ip == "" {
return nil
}
ipBinary, err := utils.IPStringToBinary(ip)
if err != nil {
return err
}
var w WAF
w.IP = ipBinary
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where(&w).Attrs(WAF{
LastBlockReason: reason,
LastBlockTimestamp: uint64(time.Now().Unix()),
}).FirstOrCreate(&w).Error; err != nil {
return err
}
return tx.Exec("UPDATE waf SET count = count + 1, last_block_reason = ?, last_block_timestamp = ? WHERE ip = ?", reason, uint64(time.Now().Unix()), ipBinary).Error
})
}
func powAdd(x, y, z uint64) uint64 {
base := big.NewInt(0).SetUint64(x)
exp := big.NewInt(0).SetUint64(y)
result := big.NewInt(1)
result.Exp(base, exp, nil)
result.Add(result, big.NewInt(0).SetUint64(z))
if !result.IsUint64() {
return ^uint64(0) // return max uint64 value on overflow
}
ret := result.Uint64()
return utils.IfOr(ret < z+3, z+3, ret)
}

177
pkg/ddns/cloudflare.go Normal file
View File

@ -0,0 +1,177 @@
package ddns
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
)
type ProviderCloudflare struct {
Secret string
}
func (provider ProviderCloudflare) UpdateDomain(domainConfig *DomainConfig) bool {
if domainConfig == nil {
return false
}
zoneID, err := provider.getZoneID(domainConfig.FullDomain)
if err != nil {
log.Printf("无法获取 zone ID: %s\n", err)
return false
}
// 当IPv4和IPv6同时成功才算作成功
var resultV4 = true
var resultV6 = true
if domainConfig.EnableIPv4 {
if !provider.addDomainRecord(zoneID, domainConfig, true) {
resultV4 = false
}
}
if domainConfig.EnableIpv6 {
if !provider.addDomainRecord(zoneID, domainConfig, false) {
resultV6 = false
}
}
return resultV4 && resultV6
}
func (provider ProviderCloudflare) addDomainRecord(zoneID string, domainConfig *DomainConfig, isIpv4 bool) bool {
record, err := provider.findDNSRecord(zoneID, domainConfig.FullDomain, isIpv4)
if err != nil {
log.Printf("查找 DNS 记录时出错: %s\n", err)
return false
}
if record == nil {
// 添加 DNS 记录
return provider.createDNSRecord(zoneID, domainConfig, isIpv4)
} else {
// 更新 DNS 记录
return provider.updateDNSRecord(zoneID, record["id"].(string), domainConfig, isIpv4)
}
}
func (provider ProviderCloudflare) getZoneID(domain string) (string, error) {
_, realDomain := SplitDomain(domain)
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", realDomain)
body, err := provider.sendRequest("GET", url, nil)
if err != nil {
return "", err
}
var res map[string]interface{}
err = json.Unmarshal(body, &res)
if err != nil {
return "", err
}
result := res["result"].([]interface{})
if len(result) > 0 {
zoneID := result[0].(map[string]interface{})["id"].(string)
return zoneID, nil
}
return "", fmt.Errorf("找不到 Zone ID")
}
func (provider ProviderCloudflare) findDNSRecord(zoneID string, domain string, isIPv4 bool) (map[string]interface{}, error) {
var ipType = "A"
if !isIPv4 {
ipType = "AAAA"
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, ipType, domain)
body, err := provider.sendRequest("GET", url, nil)
if err != nil {
return nil, err
}
var res map[string]interface{}
err = json.Unmarshal(body, &res)
if err != nil {
return nil, err
}
result := res["result"].([]interface{})
if len(result) > 0 {
return result[0].(map[string]interface{}), nil
}
return nil, nil // 没有找到 DNS 记录
}
func (provider ProviderCloudflare) createDNSRecord(zoneID string, domainConfig *DomainConfig, isIPv4 bool) bool {
var ipType = "A"
var ipAddr = domainConfig.Ipv4Addr
if !isIPv4 {
ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID)
data := map[string]interface{}{
"type": ipType,
"name": domainConfig.FullDomain,
"content": ipAddr,
"ttl": 60,
"proxied": false,
}
jsonData, _ := json.Marshal(data)
_, err := provider.sendRequest("POST", url, jsonData)
return err == nil
}
func (provider ProviderCloudflare) updateDNSRecord(zoneID string, recordID string, domainConfig *DomainConfig, isIPv4 bool) bool {
var ipType = "A"
var ipAddr = domainConfig.Ipv4Addr
if !isIPv4 {
ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID)
data := map[string]interface{}{
"type": ipType,
"name": domainConfig.FullDomain,
"content": ipAddr,
"ttl": 60,
"proxied": false,
}
jsonData, _ := json.Marshal(data)
_, err := provider.sendRequest("PATCH", url, jsonData)
return err == nil
}
// 以下为辅助方法,如发送 HTTP 请求等
func (provider ProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) {
client := &http.Client{}
req, err := http.NewRequest(method, url, bytes.NewBuffer(data))
if err != nil {
return nil, err
}
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", provider.Secret))
req.Header.Add("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s\n", err.Error())
}
}(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}

View File

@ -1,141 +1,14 @@
package ddns
import (
"context"
"fmt"
"log"
"strings"
"time"
"github.com/libdns/libdns"
"github.com/miekg/dns"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
)
var (
dnsTimeOut = 10 * time.Second
customDNSServers []string
)
type IP struct {
Ipv4Addr string
Ipv6Addr string
type DomainConfig struct {
EnableIPv4 bool
EnableIpv6 bool
FullDomain string
Ipv4Addr string
Ipv6Addr string
}
type Provider struct {
ctx context.Context
ipAddr string
recordType string
domain string
prefix string
zone string
DDNSProfile *model.DDNSProfile
IPAddrs *IP
Setter libdns.RecordSetter
}
func InitDNSServers(s string) {
if s != "" {
customDNSServers = strings.Split(s, ",")
}
}
func (provider *Provider) UpdateDomain(ctx context.Context) {
provider.ctx = ctx
for _, domain := range provider.DDNSProfile.Domains {
for retries := 0; retries < int(provider.DDNSProfile.MaxRetries); retries++ {
provider.domain = domain
log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)", provider.domain, retries+1, provider.DDNSProfile.MaxRetries)
if err := provider.updateDomain(); err != nil {
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败: %v", provider.domain, err)
} else {
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功", provider.domain)
break
}
}
}
}
func (provider *Provider) updateDomain() error {
var err error
provider.prefix, provider.zone, err = splitDomainSOA(provider.domain)
if err != nil {
return err
}
// 当IPv4和IPv6同时成功才算作成功
if *provider.DDNSProfile.EnableIPv4 {
provider.recordType = getRecordString(true)
provider.ipAddr = provider.IPAddrs.Ipv4Addr
if err = provider.addDomainRecord(); err != nil {
return err
}
}
if *provider.DDNSProfile.EnableIPv6 {
provider.recordType = getRecordString(false)
provider.ipAddr = provider.IPAddrs.Ipv6Addr
if err = provider.addDomainRecord(); err != nil {
return err
}
}
return nil
}
func (provider *Provider) addDomainRecord() error {
_, err := provider.Setter.SetRecords(provider.ctx, provider.zone,
[]libdns.Record{
{
Type: provider.recordType,
Name: provider.prefix,
Value: provider.ipAddr,
TTL: time.Minute,
},
})
return err
}
func splitDomainSOA(domain string) (prefix string, zone string, err error) {
c := &dns.Client{Timeout: dnsTimeOut}
domain += "."
indexes := dns.Split(domain)
servers := utils.DNSServers
if len(customDNSServers) > 0 {
servers = customDNSServers
}
var r *dns.Msg
for _, idx := range indexes {
m := new(dns.Msg)
m.SetQuestion(domain[idx:], dns.TypeSOA)
for _, server := range servers {
r, _, err = c.Exchange(m, server)
if err != nil {
return
}
if len(r.Answer) > 0 {
if soa, ok := r.Answer[0].(*dns.SOA); ok {
zone = soa.Hdr.Name
prefix = libdns.RelativeName(domain, zone)
return
}
}
}
}
return "", "", fmt.Errorf("SOA record not found for domain: %s", domain)
}
func getRecordString(isIpv4 bool) string {
if isIpv4 {
return "A"
}
return "AAAA"
type Provider interface {
// UpdateDomain Return is updated
UpdateDomain(domainConfig *DomainConfig) bool
}

View File

@ -1,49 +0,0 @@
package ddns
import (
"os"
"testing"
)
type testSt struct {
domain string
zone string
prefix string
}
func TestSplitDomainSOA(t *testing.T) {
if ci := os.Getenv("CI"); ci != "" { // skip if test on CI
return
}
cases := []testSt{
{
domain: "www.example.co.uk",
zone: "example.co.uk.",
prefix: "www",
},
{
domain: "abc.example.com",
zone: "example.com.",
prefix: "abc",
},
{
domain: "example.com",
zone: "example.com.",
prefix: "",
},
}
for _, c := range cases {
prefix, zone, err := splitDomainSOA(c.domain)
if err != nil {
t.Fatalf("Error: %s", err)
}
if prefix != c.prefix {
t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix)
}
if zone != c.zone {
t.Fatalf("Expected zone %s, but got %s", c.zone, zone)
}
}
}

7
pkg/ddns/dummy.go Normal file
View File

@ -0,0 +1,7 @@
package ddns
type ProviderDummy struct{}
func (provider ProviderDummy) UpdateDomain(domainConfig *DomainConfig) bool {
return false
}

View File

@ -1,16 +0,0 @@
package dummy
import (
"context"
"github.com/libdns/libdns"
)
// Internal use
type Provider struct {
}
func (provider *Provider) SetRecords(ctx context.Context, zone string,
recs []libdns.Record) ([]libdns.Record, error) {
return recs, nil
}

40
pkg/ddns/helper.go Normal file
View File

@ -0,0 +1,40 @@
package ddns
import (
"golang.org/x/net/publicsuffix"
"net/http"
"strings"
)
func (provider ProviderWebHook) FormatWebhookString(s string, config *DomainConfig, ipType string) string {
if config == nil {
return s
}
result := strings.TrimSpace(s)
result = strings.Replace(s, "{ip}", config.Ipv4Addr, -1)
result = strings.Replace(result, "{domain}", config.FullDomain, -1)
result = strings.Replace(result, "{type}", ipType, -1)
// remove \r
result = strings.Replace(result, "\r", "", -1)
return result
}
func SetStringHeadersToRequest(req *http.Request, headers []string) {
if req == nil {
return
}
for _, element := range headers {
kv := strings.SplitN(element, ":", 2)
if len(kv) == 2 {
req.Header.Add(kv[0], kv[1])
}
}
}
// SplitDomain 分割域名为前缀和一级域名
func SplitDomain(domain string) (prefix string, realDomain string) {
realDomain, _ = publicsuffix.EffectiveTLDPlusOne(domain)
prefix = domain[:len(domain)-len(realDomain)-1]
return prefix, realDomain
}

228
pkg/ddns/tencentcloud.go Normal file
View File

@ -0,0 +1,228 @@
package ddns
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
)
const (
url = "https://dnspod.tencentcloudapi.com"
)
type ProviderTencentCloud struct {
SecretID string
SecretKey string
}
func (provider ProviderTencentCloud) UpdateDomain(domainConfig *DomainConfig) bool {
if domainConfig == nil {
return false
}
// 当IPv4和IPv6同时成功才算作成功
var resultV4 = true
var resultV6 = true
if domainConfig.EnableIPv4 {
if !provider.addDomainRecord(domainConfig, true) {
resultV4 = false
}
}
if domainConfig.EnableIpv6 {
if !provider.addDomainRecord(domainConfig, false) {
resultV6 = false
}
}
return resultV4 && resultV6
}
func (provider ProviderTencentCloud) addDomainRecord(domainConfig *DomainConfig, isIpv4 bool) bool {
record, err := provider.findDNSRecord(domainConfig.FullDomain, isIpv4)
if err != nil {
log.Printf("查找 DNS 记录时出错: %s\n", err)
return false
}
if errResponse, ok := record["Error"].(map[string]interface{}); ok {
if errCode, ok := errResponse["Code"].(string); ok && errCode == "ResourceNotFound.NoDataOfRecord" { // 没有找到 DNS 记录
// 添加 DNS 记录
return provider.createDNSRecord(domainConfig.FullDomain, domainConfig, isIpv4)
} else {
log.Printf("查询 DNS 记录时出错,错误代码为: %s\n", errCode)
}
}
// 默认情况下更新 DNS 记录
return provider.updateDNSRecord(domainConfig.FullDomain, record["RecordList"].([]interface{})[0].(map[string]interface{})["RecordId"].(float64), domainConfig, isIpv4)
}
func (provider ProviderTencentCloud) findDNSRecord(domain string, isIPv4 bool) (map[string]interface{}, error) {
var ipType = "A"
if !isIPv4 {
ipType = "AAAA"
}
_, realDomain := SplitDomain(domain)
prefix, _ := SplitDomain(domain)
data := map[string]interface{}{
"RecordType": ipType,
"Domain": realDomain,
"RecordLine": "默认",
"Subdomain": prefix,
}
jsonData, _ := json.Marshal(data)
body, err := provider.sendRequest("DescribeRecordList", jsonData)
if err != nil {
return nil, err
}
var res map[string]interface{}
err = json.Unmarshal(body, &res)
if err != nil {
return nil, err
}
result := res["Response"].(map[string]interface{})
return result, nil
}
func (provider ProviderTencentCloud) createDNSRecord(domain string, domainConfig *DomainConfig, isIPv4 bool) bool {
var ipType = "A"
var ipAddr = domainConfig.Ipv4Addr
if !isIPv4 {
ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr
}
_, realDomain := SplitDomain(domain)
prefix, _ := SplitDomain(domain)
data := map[string]interface{}{
"RecordType": ipType,
"RecordLine": "默认",
"Domain": realDomain,
"SubDomain": prefix,
"Value": ipAddr,
"TTL": 600,
}
jsonData, _ := json.Marshal(data)
_, err := provider.sendRequest("CreateRecord", jsonData)
return err == nil
}
func (provider ProviderTencentCloud) updateDNSRecord(domain string, recordID float64, domainConfig *DomainConfig, isIPv4 bool) bool {
var ipType = "A"
var ipAddr = domainConfig.Ipv4Addr
if !isIPv4 {
ipType = "AAAA"
ipAddr = domainConfig.Ipv6Addr
}
_, realDomain := SplitDomain(domain)
prefix, _ := SplitDomain(domain)
data := map[string]interface{}{
"RecordType": ipType,
"RecordLine": "默认",
"Domain": realDomain,
"SubDomain": prefix,
"Value": ipAddr,
"TTL": 600,
"RecordId": recordID,
}
jsonData, _ := json.Marshal(data)
_, err := provider.sendRequest("ModifyRecord", jsonData)
return err == nil
}
// 以下为辅助方法,如发送 HTTP 请求等
func (provider ProviderTencentCloud) sendRequest(action string, data []byte) ([]byte, error) {
client := &http.Client{}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-TC-Version", "2021-03-23")
provider.signRequest(provider.SecretID, provider.SecretKey, req, action, string(data))
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s\n", err.Error())
}
}(resp.Body)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
return body, nil
}
// https://github.com/jeessy2/ddns-go/blob/master/util/tencent_cloud_signer.go
func (provider ProviderTencentCloud) sha256hex(s string) string {
b := sha256.Sum256([]byte(s))
return hex.EncodeToString(b[:])
}
func (provider ProviderTencentCloud) hmacsha256(s, key string) string {
hashed := hmac.New(sha256.New, []byte(key))
hashed.Write([]byte(s))
return string(hashed.Sum(nil))
}
func (provider ProviderTencentCloud) WriteString(strs ...string) string {
var b strings.Builder
for _, str := range strs {
b.WriteString(str)
}
return b.String()
}
func (provider ProviderTencentCloud) signRequest(secretId string, secretKey string, r *http.Request, action string, payload string) {
algorithm := "TC3-HMAC-SHA256"
service := "dnspod"
host := provider.WriteString(service, ".tencentcloudapi.com")
timestamp := time.Now().Unix()
timestampStr := strconv.FormatInt(timestamp, 10)
// 步骤 1拼接规范请求串
canonicalHeaders := provider.WriteString("content-type:application/json\nhost:", host, "\nx-tc-action:", strings.ToLower(action), "\n")
signedHeaders := "content-type;host;x-tc-action"
hashedRequestPayload := provider.sha256hex(payload)
canonicalRequest := provider.WriteString("POST\n/\n\n", canonicalHeaders, "\n", signedHeaders, "\n", hashedRequestPayload)
// 步骤 2拼接待签名字符串
date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
credentialScope := provider.WriteString(date, "/", service, "/tc3_request")
hashedCanonicalRequest := provider.sha256hex(canonicalRequest)
string2sign := provider.WriteString(algorithm, "\n", timestampStr, "\n", credentialScope, "\n", hashedCanonicalRequest)
// 步骤 3计算签名
secretDate := provider.hmacsha256(date, provider.WriteString("TC3", secretKey))
secretService := provider.hmacsha256(service, secretDate)
secretSigning := provider.hmacsha256("tc3_request", secretService)
signature := hex.EncodeToString([]byte(provider.hmacsha256(string2sign, secretSigning)))
// 步骤 4拼接 Authorization
authorization := provider.WriteString(algorithm, " Credential=", secretId, "/", credentialScope, ", SignedHeaders=", signedHeaders, ", Signature=", signature)
r.Header.Add("Authorization", authorization)
r.Header.Set("Host", host)
r.Header.Set("X-TC-Action", action)
r.Header.Add("X-TC-Timestamp", timestampStr)
}

59
pkg/ddns/webhook.go Normal file
View File

@ -0,0 +1,59 @@
package ddns
import (
"bytes"
"log"
"net/http"
"strings"
)
type ProviderWebHook struct {
URL string
RequestMethod string
RequestBody string
RequestHeader string
}
func (provider ProviderWebHook) UpdateDomain(domainConfig *DomainConfig) bool {
if domainConfig == nil {
return false
}
if domainConfig.FullDomain == "" {
log.Println("NEZHA>> Failed to update an empty domain")
return false
}
updated := false
client := &http.Client{}
if domainConfig.EnableIPv4 && domainConfig.Ipv4Addr != "" {
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv4")
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv4")
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv4")
headers := strings.Split(header, "\n")
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
if err == nil && req != nil {
SetStringHeadersToRequest(req, headers)
if _, err := client.Do(req); err != nil {
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
} else {
updated = true
}
}
}
if domainConfig.EnableIpv6 && domainConfig.Ipv6Addr != "" {
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv6")
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv6")
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv6")
headers := strings.Split(header, "\n")
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
if err == nil && req != nil {
SetStringHeadersToRequest(req, headers)
if _, err := client.Do(req); err != nil {
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
} else {
updated = true
}
}
}
return updated
}

View File

@ -1,180 +0,0 @@
package webhook
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/libdns/libdns"
"github.com/nezhahq/nezha/model"
"github.com/nezhahq/nezha/pkg/utils"
)
const (
_ = iota
methodGET
methodPOST
methodPATCH
methodDELETE
methodPUT
)
const (
_ = iota
requestTypeJSON
requestTypeForm
)
var requestTypes = map[uint8]string{
methodGET: "GET",
methodPOST: "POST",
methodPATCH: "PATCH",
methodDELETE: "DELETE",
methodPUT: "PUT",
}
// Internal use
type Provider struct {
ipAddr string
ipType string
recordType string
domain string
DDNSProfile *model.DDNSProfile
}
func (provider *Provider) SetRecords(ctx context.Context, zone string,
recs []libdns.Record) ([]libdns.Record, error) {
for _, rec := range recs {
provider.recordType = rec.Type
provider.ipType = recordToIPType(provider.recordType)
provider.ipAddr = rec.Value
provider.domain = fmt.Sprintf("%s.%s", rec.Name, strings.TrimSuffix(zone, "."))
req, err := provider.prepareRequest(ctx)
if err != nil {
return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err)
}
if _, err := utils.HttpClient.Do(req); err != nil {
return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err)
}
}
return recs, nil
}
func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, error) {
u, err := provider.reqUrl()
if err != nil {
return nil, err
}
body, err := provider.reqBody()
if err != nil {
return nil, err
}
headers, err := utils.GjsonParseStringMap(
provider.formatWebhookString(provider.DDNSProfile.WebhookHeaders))
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, requestTypes[provider.DDNSProfile.WebhookMethod], u.String(), strings.NewReader(body))
if err != nil {
return nil, err
}
provider.setContentType(req)
for k, v := range headers {
req.Header.Set(k, v)
}
return req, nil
}
func (provider *Provider) setContentType(req *http.Request) {
if provider.DDNSProfile.WebhookMethod == methodGET {
return
}
if provider.DDNSProfile.WebhookRequestType == requestTypeForm {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
} else {
req.Header.Set("Content-Type", "application/json")
}
}
func (provider *Provider) reqUrl() (*url.URL, error) {
formattedUrl := strings.ReplaceAll(provider.DDNSProfile.WebhookURL, "#", "%23")
u, err := url.Parse(formattedUrl)
if err != nil {
return nil, err
}
// Only handle queries here
q := u.Query()
for p, vals := range q {
for n, v := range vals {
vals[n] = provider.formatWebhookString(v)
}
q[p] = vals
}
u.RawQuery = q.Encode()
return u, nil
}
func (provider *Provider) reqBody() (string, error) {
if provider.DDNSProfile.WebhookMethod == methodGET ||
provider.DDNSProfile.WebhookMethod == methodDELETE {
return "", nil
}
switch provider.DDNSProfile.WebhookRequestType {
case requestTypeJSON:
return provider.formatWebhookString(provider.DDNSProfile.WebhookRequestBody), nil
case requestTypeForm:
data, err := utils.GjsonParseStringMap(provider.DDNSProfile.WebhookRequestBody)
if err != nil {
return "", err
}
params := url.Values{}
for k, v := range data {
params.Add(k, provider.formatWebhookString(v))
}
return params.Encode(), nil
default:
return "", errors.New("request type not supported")
}
}
func (provider *Provider) formatWebhookString(s string) string {
r := strings.NewReplacer(
"#ip#", provider.ipAddr,
"#domain#", provider.domain,
"#type#", provider.ipType,
"#record#", provider.recordType,
"#access_id#", provider.DDNSProfile.AccessID,
"#access_secret#", provider.DDNSProfile.AccessSecret,
"\r", "",
)
result := r.Replace(strings.TrimSpace(s))
return result
}
func recordToIPType(record string) string {
switch record {
case "A":
return "ipv4"
case "AAAA":
return "ipv6"
default:
return ""
}
}

View File

@ -1,116 +0,0 @@
package webhook
import (
"context"
"testing"
"github.com/nezhahq/nezha/model"
)
var (
reqTypeForm = "application/x-www-form-urlencoded"
reqTypeJSON = "application/json"
)
type testSt struct {
profile model.DDNSProfile
expectURL string
expectBody string
expectContentType string
expectHeader map[string]string
}
func execCase(t *testing.T, item testSt) {
pw := Provider{DDNSProfile: &item.profile}
pw.ipAddr = "1.1.1.1"
pw.domain = item.profile.Domains[0]
pw.ipType = "ipv4"
pw.recordType = "A"
pw.DDNSProfile = &item.profile
reqUrl, err := pw.reqUrl()
if err != nil {
t.Fatalf("Error: %s", err)
}
if item.expectURL != reqUrl.String() {
t.Fatalf("Expected %s, but got %s", item.expectURL, reqUrl.String())
}
reqBody, err := pw.reqBody()
if err != nil {
t.Fatalf("Error: %s", err)
}
if item.expectBody != reqBody {
t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody)
}
req, err := pw.prepareRequest(context.Background())
if err != nil {
t.Fatalf("Error: %s", err)
}
if item.expectContentType != req.Header.Get("Content-Type") {
t.Fatalf("Expected %s, but got %s", item.expectContentType, req.Header.Get("Content-Type"))
}
for k, v := range item.expectHeader {
if v != req.Header.Get(k) {
t.Fatalf("Expected %s, but got %s", v, req.Header.Get(k))
}
}
}
func TestWebhookRequest(t *testing.T) {
ipv4 := true
cases := []testSt{
{
profile: model.DDNSProfile{
Domains: []string{"www.example.com"},
MaxRetries: 1,
EnableIPv4: &ipv4,
WebhookURL: "http://ddns.example.com/?ip=#ip#",
WebhookMethod: methodGET,
WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`,
},
expectURL: "http://ddns.example.com/?ip=1.1.1.1",
expectContentType: "",
expectHeader: map[string]string{
"ip": "1.1.1.1",
"record": "A",
},
},
{
profile: model.DDNSProfile{
Domains: []string{"www.example.com"},
MaxRetries: 1,
EnableIPv4: &ipv4,
WebhookURL: "http://ddns.example.com/api",
WebhookMethod: methodPOST,
WebhookRequestType: requestTypeJSON,
WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
},
expectURL: "http://ddns.example.com/api",
expectContentType: reqTypeJSON,
expectBody: `{"ip":"1.1.1.1","record":"A"}`,
},
{
profile: model.DDNSProfile{
Domains: []string{"www.example.com"},
MaxRetries: 1,
EnableIPv4: &ipv4,
WebhookURL: "http://ddns.example.com/api",
WebhookMethod: methodPOST,
WebhookRequestType: requestTypeForm,
WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
},
expectURL: "http://ddns.example.com/api",
expectContentType: reqTypeForm,
expectBody: "ip=1.1.1.1&record=A",
},
}
for _, c := range cases {
execCase(t, c)
}
}

View File

@ -1 +0,0 @@
stub

View File

@ -1,52 +0,0 @@
package geoip
import (
_ "embed"
"fmt"
"net"
"strings"
"sync"
maxminddb "github.com/oschwald/maxminddb-golang"
)
//go:embed geoip.db
var db []byte
var (
dbOnce = sync.OnceValues(func() (*maxminddb.Reader, error) {
db, err := maxminddb.FromBytes(db)
if err != nil {
return nil, err
}
return db, nil
})
)
type IPInfo struct {
Country string `maxminddb:"country"`
CountryName string `maxminddb:"country_name"`
Continent string `maxminddb:"continent"`
ContinentName string `maxminddb:"continent_name"`
}
func Lookup(ip net.IP) (string, error) {
db, err := dbOnce()
if err != nil {
return "", err
}
var record IPInfo
err = db.Lookup(ip, &record)
if err != nil {
return "", err
}
if record.Country != "" {
return strings.ToLower(record.Country), nil
} else if record.Continent != "" {
return strings.ToLower(record.Continent), nil
}
return "", fmt.Errorf("IP not found")
}

View File

@ -1,65 +0,0 @@
package grpcx
import (
"context"
"io"
"sync/atomic"
"github.com/nezhahq/nezha/proto"
)
var _ io.ReadWriteCloser = (*IOStreamWrapper)(nil)
type IOStream interface {
Recv() (*proto.IOStreamData, error)
Send(*proto.IOStreamData) error
Context() context.Context
}
type IOStreamWrapper struct {
IOStream
dataBuf []byte
closed *atomic.Bool
closeCh chan struct{}
}
func NewIOStreamWrapper(stream IOStream) *IOStreamWrapper {
return &IOStreamWrapper{
IOStream: stream,
closeCh: make(chan struct{}),
closed: new(atomic.Bool),
}
}
func (iw *IOStreamWrapper) Read(p []byte) (n int, err error) {
if len(iw.dataBuf) > 0 {
n := copy(p, iw.dataBuf)
iw.dataBuf = iw.dataBuf[n:]
return n, nil
}
var data *proto.IOStreamData
if data, err = iw.Recv(); err != nil {
return 0, err
}
n = copy(p, data.Data)
if n < len(data.Data) {
iw.dataBuf = data.Data[n:]
}
return n, nil
}
func (iw *IOStreamWrapper) Write(p []byte) (n int, err error) {
err = iw.Send(&proto.IOStreamData{Data: p})
return len(p), err
}
func (iw *IOStreamWrapper) Close() error {
if iw.closed.CompareAndSwap(false, true) {
close(iw.closeCh)
}
return nil
}
func (iw *IOStreamWrapper) Wait() {
<-iw.closeCh
}

View File

@ -1,105 +0,0 @@
package i18n
import (
"embed"
"fmt"
"sync"
"github.com/chai2010/gettext-go"
)
//go:embed translations
var Translations embed.FS
var Languages = map[string]string{
"zh_CN": "简体中文",
"zh_TW": "繁體中文",
"en_US": "English",
"es_ES": "Español",
}
type Localizer struct {
intlMap map[string]gettext.Gettexter
lang string
mu sync.RWMutex
}
func NewLocalizer(lang, domain, path string, data any) *Localizer {
intl := gettext.New(domain, path, data)
intl.SetLanguage(lang)
intlMap := make(map[string]gettext.Gettexter)
intlMap[lang] = intl
return &Localizer{intlMap: intlMap, lang: lang}
}
func (l *Localizer) SetLanguage(lang string) {
l.mu.Lock()
defer l.mu.Unlock()
l.lang = lang
}
func (l *Localizer) Exists(lang string) bool {
l.mu.RLock()
defer l.mu.RUnlock()
if _, ok := l.intlMap[lang]; ok {
return ok
}
return false
}
func (l *Localizer) AppendIntl(lang, domain, path string, data any) {
intl := gettext.New(domain, path, data)
intl.SetLanguage(lang)
l.mu.Lock()
defer l.mu.Unlock()
l.intlMap[lang] = intl
}
// Modified from k8s.io/kubectl/pkg/util/i18n
func (l *Localizer) T(orig string) string {
l.mu.RLock()
intl, ok := l.intlMap[l.lang]
l.mu.RUnlock()
if !ok {
return orig
}
return intl.PGettext("", orig)
}
// N translates a string, possibly substituting arguments into it along
// the way. If len(args) is > 0, args1 is assumed to be the plural value
// and plural translation is used.
func (l *Localizer) N(orig string, args ...int) string {
l.mu.RLock()
intl, ok := l.intlMap[l.lang]
l.mu.RUnlock()
if !ok {
return orig
}
if len(args) == 0 {
return intl.PGettext("", orig)
}
return fmt.Sprintf(intl.PNGettext("", orig, orig+".plural", args[0]),
args[0])
}
// ErrorT produces an error with a translated error string.
// Substitution is performed via the `T` function above, following
// the same rules.
func (l *Localizer) ErrorT(defaultValue string, args ...any) error {
return fmt.Errorf(l.T(defaultValue), args...)
}
func (l *Localizer) Tf(defaultValue string, args ...any) string {
return fmt.Sprintf(l.T(defaultValue), args...)
}

View File

@ -1,220 +0,0 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-11-23 23:56+0800\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: cmd/dashboard/controller/alertrule.go:100
#, c-format
msgid "alert id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/alertrule.go:155
msgid "duration need to be at least 3"
msgstr ""
#: cmd/dashboard/controller/alertrule.go:159
msgid "cycle_interval need to be at least 1"
msgstr ""
#: cmd/dashboard/controller/alertrule.go:162
msgid "cycle_start is not set"
msgstr ""
#: cmd/dashboard/controller/alertrule.go:165
msgid "cycle_start is a future value"
msgstr ""
#: cmd/dashboard/controller/alertrule.go:170
msgid "need to configure at least a single rule"
msgstr ""
#: cmd/dashboard/controller/controller.go:195
msgid "database error"
msgstr ""
#: cmd/dashboard/controller/cron.go:63 cmd/dashboard/controller/cron.go:122
msgid "scheduled tasks cannot be triggered by alarms"
msgstr ""
#: cmd/dashboard/controller/cron.go:161
#, c-format
msgid "task id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/ddns.go:56 cmd/dashboard/controller/ddns.go:120
msgid "the retry count must be an integer between 1 and 10"
msgstr ""
#: cmd/dashboard/controller/ddns.go:79 cmd/dashboard/controller/ddns.go:148
msgid "error parsing %s: %v"
msgstr ""
#: cmd/dashboard/controller/ddns.go:125 cmd/dashboard/controller/nat.go:95
#, c-format
msgid "profile id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/fm.go:45 cmd/dashboard/controller/terminal.go:43
msgid "server not found or not connected"
msgstr ""
#: cmd/dashboard/controller/notification.go:67
#: cmd/dashboard/controller/notification.go:125
msgid "a test message"
msgstr ""
#: cmd/dashboard/controller/notification.go:106
#, c-format
msgid "notification id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/notification_group.go:80
#: cmd/dashboard/controller/notification_group.go:142
msgid "have invalid notification id"
msgstr ""
#: cmd/dashboard/controller/notification_group.go:131
#: cmd/dashboard/controller/server_group.go:130
#, c-format
msgid "group id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/server.go:60
#, c-format
msgid "server id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/server_group.go:78
#: cmd/dashboard/controller/server_group.go:139
msgid "have invalid server id"
msgstr ""
#: cmd/dashboard/controller/service.go:79
#: cmd/dashboard/controller/service.go:155
msgid "server not found"
msgstr ""
#: cmd/dashboard/controller/service.go:86 cmd/dashboard/controller/user.go:23
msgid "unauthorized"
msgstr ""
#: cmd/dashboard/controller/service.go:247
#, c-format
msgid "service id %d does not exist"
msgstr ""
#: cmd/dashboard/controller/user.go:66
msgid "password length must be greater than 6"
msgstr ""
#: cmd/dashboard/controller/user.go:69
msgid "username can't be empty"
msgstr ""
#: service/rpc/io_stream.go:122
msgid "timeout: no connection established"
msgstr ""
#: service/rpc/io_stream.go:125
msgid "timeout: user connection not established"
msgstr ""
#: service/rpc/io_stream.go:128
msgid "timeout: agent connection not established"
msgstr ""
#: service/rpc/nezha.go:58
msgid "Scheduled Task Executed Successfully"
msgstr ""
#: service/rpc/nezha.go:62
msgid "Scheduled Task Executed Failed"
msgstr ""
#: service/rpc/nezha.go:217
msgid "IP Changed"
msgstr ""
#: service/singleton/alertsentinel.go:159
msgid "Incident"
msgstr ""
#: service/singleton/alertsentinel.go:169
msgid "Resolved"
msgstr ""
#: service/singleton/crontask.go:53
msgid "Tasks failed to register: ["
msgstr ""
#: service/singleton/crontask.go:60
msgid ""
"] These tasks will not execute properly. Fix them in the admin dashboard."
msgstr ""
#: service/singleton/crontask.go:146 service/singleton/crontask.go:171
#, c-format
msgid "[Task failed] %s: server %s is offline and cannot execute the task"
msgstr ""
#: service/singleton/servicesentinel.go:439
#, c-format
msgid "[Latency] %s %2f > %2f, Reporter: %s"
msgstr ""
#: service/singleton/servicesentinel.go:446
#, c-format
msgid "[Latency] %s %2f < %2f, Reporter: %s"
msgstr ""
#: service/singleton/servicesentinel.go:472
#, c-format
msgid "[%s] %s Reporter: %s, Error: %s"
msgstr ""
#: service/singleton/servicesentinel.go:515
#, c-format
msgid "[TLS] Fetch cert info failed, Reporter: %s, Error: %s"
msgstr ""
#: service/singleton/servicesentinel.go:555
#, c-format
msgid "The TLS certificate will expire within seven days. Expiration time: %s"
msgstr ""
#: service/singleton/servicesentinel.go:568
#, c-format
msgid ""
"TLS certificate changed, old: issuer %s, expires at %s; new: issuer %s, "
"expires at %s"
msgstr ""
#: service/singleton/servicesentinel.go:604
msgid "No Data"
msgstr ""
#: service/singleton/servicesentinel.go:606
msgid "Good"
msgstr ""
#: service/singleton/servicesentinel.go:608
msgid "Low Availability"
msgstr ""
#: service/singleton/servicesentinel.go:610
msgid "Down"
msgstr ""

Some files were not shown because too many files have changed in this diff Show More