├── .devcontainer
├── build.sh
└── devcontainer.json
├── .gitattributes
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── bug_report_zh.yml
│ └── config.yml
├── admin-frontend.20241220.jpg
├── brand.svg
├── sync.py
├── user-frontend.20241128.png
└── workflows
│ ├── codeql-analysis.yml
│ ├── contributors.yml
│ ├── release.yml
│ ├── sync-code.yml
│ ├── sync-release.yml
│ └── test.yml
├── .gitignore
├── .goreleaser.yml
├── Dockerfile
├── LICENSE
├── README.md
├── SECURITY.md
├── cmd
└── dashboard
│ ├── admin-dist
│ └── .gitkeep
│ ├── controller
│ ├── alertrule.go
│ ├── controller.go
│ ├── cron.go
│ ├── ddns.go
│ ├── fm.go
│ ├── jwt.go
│ ├── nat.go
│ ├── notification.go
│ ├── notification_group.go
│ ├── oauth2.go
│ ├── server.go
│ ├── server_group.go
│ ├── service.go
│ ├── setting.go
│ ├── terminal.go
│ ├── user.go
│ ├── waf.go
│ ├── waf
│ │ ├── waf.go
│ │ └── waf.html
│ └── ws.go
│ ├── main.go
│ ├── rpc
│ └── rpc.go
│ └── user-dist
│ └── .gitkeep
├── go.mod
├── go.sum
├── model
├── alertrule.go
├── alertrule_api.go
├── alertrule_test.go
├── api.go
├── common.go
├── common_test.go
├── config.go
├── config_test.go
├── cron.go
├── cron_api.go
├── ddns.go
├── ddns_api.go
├── fm_api.go
├── host.go
├── nat.go
├── nat_api.go
├── notification.go
├── notification_api.go
├── notification_group.go
├── notification_group_api.go
├── notification_group_notification.go
├── notification_test.go
├── oauth2bind.go
├── oauth2config.go
├── rule.go
├── server.go
├── server_api.go
├── server_group.go
├── server_group_api.go
├── server_group_server.go
├── service.go
├── service_api.go
├── service_history.go
├── service_history_api.go
├── setting_api.go
├── terminal_api.go
├── transfer.go
├── user.go
├── user_api.go
└── waf.go
├── pkg
├── ddns
│ ├── ddns.go
│ ├── ddns_test.go
│ ├── dummy
│ │ └── dummy.go
│ └── webhook
│ │ ├── webhook.go
│ │ └── webhook_test.go
├── geoip
│ ├── geoip.db
│ └── geoip.go
├── grpcx
│ └── io_stream_wrapper.go
├── i18n
│ ├── i18n.go
│ ├── i18n_test.go
│ ├── template.pot
│ └── translations
│ │ ├── de_DE
│ │ └── LC_MESSAGES
│ │ │ ├── nezha.mo
│ │ │ └── nezha.po
│ │ ├── en_US
│ │ └── LC_MESSAGES
│ │ │ ├── nezha.mo
│ │ │ └── nezha.po
│ │ ├── es_ES
│ │ └── LC_MESSAGES
│ │ │ ├── nezha.mo
│ │ │ └── nezha.po
│ │ ├── ru_RU
│ │ └── LC_MESSAGES
│ │ │ ├── nezha.mo
│ │ │ └── nezha.po
│ │ ├── ta_IN
│ │ └── LC_MESSAGES
│ │ │ ├── nezha.mo
│ │ │ └── nezha.po
│ │ ├── zh_CN
│ │ └── LC_MESSAGES
│ │ │ ├── nezha.mo
│ │ │ └── nezha.po
│ │ └── zh_TW
│ │ └── LC_MESSAGES
│ │ ├── nezha.mo
│ │ └── nezha.po
├── utils
│ ├── gin_writer_wrapper.go
│ ├── gjson.go
│ ├── http.go
│ ├── koanf.go
│ ├── request_wrapper.go
│ ├── utils.go
│ └── utils_test.go
└── websocketx
│ └── safe_conn.go
├── proto
├── nezha.pb.go
├── nezha.proto
└── nezha_grpc.pb.go
├── script
├── bootstrap.sh
├── config.yaml
├── entrypoint.sh
├── fetch-frontends.sh
├── i18n.sh
├── install.sh
└── install_en.sh
└── service
├── rpc
├── auth.go
├── io_stream.go
├── io_stream_test.go
└── nezha.go
└── singleton
├── alertsentinel.go
├── config.go
├── crontask.go
├── ddns.go
├── frontend-templates.yaml
├── i18n.go
├── nat.go
├── notification.go
├── online_user.go
├── server.go
├── servicesentinel.go
├── singleton.go
└── user.go
/.devcontainer/build.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | goreleaser build --snapshot --clean
3 | mv dist/**/* dist/
4 | docker buildx build --platform linux/amd64,linux/arm64,linux/s390x -t nezha .
5 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2 | // README at: https://github.com/devcontainers/templates/tree/main/src/go
3 | {
4 | "name": "NeZha",
5 | // Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
6 | "image": "mcr.microsoft.com/devcontainers/go:1-1.24-bookworm",
7 |
8 | // Features to add to the dev container. More info: https://containers.dev/features.
9 | "features": {
10 | "ghcr.io/devcontainers/features/docker-in-docker:2": {
11 | "installDockerBuildx": true,
12 | "installDockerComposeSwitch": true,
13 | "version": "latest",
14 | "dockerDashComposeVersion": "latest"
15 | },
16 | "ghcr.io/devcontainers/features/go:1": {
17 | "version": "1.24.0"
18 | }
19 | },
20 |
21 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
22 | // "forwardPorts": [],
23 |
24 | // Use 'postCreateCommand' to run commands after the container is created.
25 | "postCreateCommand": {
26 | "Init": "sudo apt update && sudo apt install -y protobuf-compiler && go install github.com/swaggo/swag/cmd/swag@latest && go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest && go install google.golang.org/protobuf/cmd/protoc-gen-go@latest && sudo wget https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 -O /usr/bin/yq && sudo chmod +x /usr/bin/yq && touch ./cmd/dashboard/user-dist/a && touch ./cmd/dashboard/admin-dist/a && script/bootstrap.sh && script/fetch-frontends.sh",
27 | "Init_build": "wget https://github.com/goreleaser/goreleaser/releases/download/v2.7.0/goreleaser_2.7.0_amd64.deb && sudo apt install -y ./goreleaser_2.7.0_amd64.deb && rm -rf ./goreleaser_2.7.0_amd64.deb && sudo apt install -y gcc-aarch64-linux-gnu gcc-s390x-linux-gnu mingw-w64",
28 | "Init_DiD_Buildx": "docker buildx create --name nezha-builder --platform linux/amd64,linux/amd64/v2,linux/amd64/v3,linux/386,linux/arm64,linux/arm/v7,linux/s390x && docker buildx use nezha-builder && docker buildx inspect --bootstrap"
29 | }
30 |
31 | // Configure tool-specific properties.
32 | // "customizations": {},
33 |
34 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
35 | // "remoteUser": "root"
36 | }
37 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | resource/** linguist-vendored
2 | resource/static/* !linguist-vendored
3 | resource/template/dashboard/* !linguist-vendored
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug Report
2 | description: File a bug report about nezha.
3 |
4 | body:
5 | - type: input
6 | attributes:
7 | label: Environment
8 | description: Input your OS information and host architecture here.
9 | placeholder: Debian GNU/Linux 12 6.1.0-22-amd64
10 | validations:
11 | required: true
12 | - type: input
13 | attributes:
14 | label: Nezha Version
15 | description: Input the version of your copy of nezha here.
16 | validations:
17 | required: true
18 | - type: textarea
19 | attributes:
20 | label: Describe the bug
21 | description: A clear and concise description of what the bug is.
22 | value: |
23 |
24 | validations:
25 | required: true
26 | - type: textarea
27 | attributes:
28 | label: To Reproduce
29 | description: Input the steps to reproduce the bug here.
30 | value: |
31 |
32 | validations:
33 | required: true
34 | - type: textarea
35 | attributes:
36 | label: Configuration
37 | description: Input your configuration here.
38 | value: |
39 |
40 | validations:
41 | required: true
42 | - type: textarea
43 | attributes:
44 | label: Additional Context
45 | description: Input any other relevant information that may help understand the issue.
46 | value: |
47 |
48 | - type: checkboxes
49 | attributes:
50 | label: Validation
51 | options:
52 | - label: I confirm this is a bug about nezha (Nezha Dashboard).
53 | required: true
54 | - label: I have searched Issues and confirm this bug has been reported before.
55 | required: true
56 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report_zh.yml:
--------------------------------------------------------------------------------
1 | name: 问题反馈
2 | description: 提交 nezha 问题反馈
3 |
4 | body:
5 | - type: input
6 | attributes:
7 | label: 运行环境
8 | description: 请在这里输入你的系统信息及设备架构
9 | placeholder: Debian GNU/Linux 12 6.1.0-22-amd64
10 | validations:
11 | required: true
12 | - type: input
13 | attributes:
14 | label: Nezha 版本
15 | description: 在这里输入你的 nezha 版本号
16 | validations:
17 | required: true
18 | - type: textarea
19 | attributes:
20 | label: 描述问题
21 | description: 一个清晰明了的问题描述
22 | value: |
23 |
24 | validations:
25 | required: true
26 | - type: textarea
27 | attributes:
28 | label: 复现步骤
29 | description: 在这里输入复现问题的步骤
30 | value: |
31 |
32 | validations:
33 | required: true
34 | - type: textarea
35 | attributes:
36 | label: 配置信息
37 | description: 在这里输入你的配置信息
38 | value: |
39 |
40 | validations:
41 | required: true
42 | - type: textarea
43 | attributes:
44 | label: 附加信息
45 | description: 在这里输入其它对问题解决有帮助的信息
46 | value: |
47 |
48 | - type: checkboxes
49 | attributes:
50 | label: 验证
51 | options:
52 | - label: 我确认这是一个 nezha (哪吒面板) 的问题。
53 | required: true
54 | - label: 我已经搜索了 Issues,并确认该问题之前没有被反馈过。
55 | required: true
56 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | contact_links:
2 | - name: Report nezha-agent issues / 反馈 nezha-agent 问题
3 | url: https://github.com/nezhahq/agent/issues
4 | about: 请在这里创建 nezha-agent 的问题反馈。
5 | - name: Report admin frontend issues / 反馈管理前端问题
6 | url: https://github.com/nezhahq/admin-frontend/issues
7 | about: 请在这里创建管理前端的问题反馈。
8 | - name: Report user frontend issues / 反馈用户前端问题
9 | url: https://github.com/hamster1963/nezha-dash/issues
10 | about: 请在这里创建用户前端的问题反馈。
11 |
--------------------------------------------------------------------------------
/.github/admin-frontend.20241220.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/.github/admin-frontend.20241220.jpg
--------------------------------------------------------------------------------
/.github/sync.py:
--------------------------------------------------------------------------------
1 | import os
2 | import time
3 | import requests
4 | import hashlib
5 | from github import Github
6 |
7 |
8 | def get_github_latest_release():
9 | g = Github()
10 | repo = g.get_repo("nezhahq/nezha")
11 | release = repo.get_latest_release()
12 | if release:
13 | print(f"Latest release tag is: {release.tag_name}")
14 | print(f"Latest release info is: {release.body}")
15 | files = []
16 | for asset in release.get_assets():
17 | url = asset.browser_download_url
18 | name = asset.name
19 |
20 | response = requests.get(url)
21 | if response.status_code == 200:
22 | with open(name, 'wb') as f:
23 | f.write(response.content)
24 | print(f"Downloaded {name}")
25 | else:
26 | print(f"Failed to download {name}")
27 | file_abs_path = get_abs_path(asset.name)
28 | files.append(file_abs_path)
29 | sync_to_gitee(release.tag_name, release.body, files)
30 | else:
31 | print("No releases found.")
32 |
33 |
34 | def delete_gitee_releases(latest_id, client, uri, token):
35 | get_data = {
36 | 'access_token': token
37 | }
38 |
39 | release_info = []
40 | release_response = client.get(uri, json=get_data)
41 | if release_response.status_code == 200:
42 | release_info = release_response.json()
43 | else:
44 | print(
45 | f"Request failed with status code {release_response.status_code}")
46 |
47 | release_ids = []
48 | for block in release_info:
49 | if 'id' in block:
50 | release_ids.append(block['id'])
51 |
52 | print(f'Current release ids: {release_ids}')
53 | release_ids.remove(latest_id)
54 |
55 | for id in release_ids:
56 | release_uri = f"{uri}/{id}"
57 | delete_data = {
58 | 'access_token': token
59 | }
60 | delete_response = client.delete(release_uri, json=delete_data)
61 | if delete_response.status_code == 204:
62 | print(f'Successfully deleted release #{id}.')
63 | else:
64 | raise ValueError(
65 | f"Request failed with status code {delete_response.status_code}")
66 |
67 |
68 | def sync_to_gitee(tag: str, body: str, files: slice):
69 | release_id = ""
70 | owner = "naibahq"
71 | repo = "nezha"
72 | release_api_uri = f"https://gitee.com/api/v5/repos/{owner}/{repo}/releases"
73 | api_client = requests.Session()
74 | api_client.headers.update({
75 | 'Accept': 'application/json',
76 | 'Content-Type': 'application/json'
77 | })
78 |
79 | access_token = os.environ['GITEE_TOKEN']
80 | release_data = {
81 | 'access_token': access_token,
82 | 'tag_name': tag,
83 | 'name': tag,
84 | 'body': body,
85 | 'prerelease': False,
86 | 'target_commitish': 'master'
87 | }
88 | release_api_response = api_client.post(release_api_uri, json=release_data)
89 | if release_api_response.status_code == 201:
90 | release_info = release_api_response.json()
91 | release_id = release_info.get('id')
92 | else:
93 | print(
94 | f"Request failed with status code {release_api_response.status_code}")
95 |
96 | print(f"Gitee release id: {release_id}")
97 | asset_api_uri = f"{release_api_uri}/{release_id}/attach_files"
98 |
99 | for file_path in files:
100 | success = False
101 |
102 | while not success:
103 | files = {
104 | 'file': open(file_path, 'rb')
105 | }
106 |
107 | asset_api_response = requests.post(
108 | asset_api_uri, params={'access_token': access_token}, files=files)
109 |
110 | if asset_api_response.status_code == 201:
111 | asset_info = asset_api_response.json()
112 | asset_name = asset_info.get('name')
113 | print(f"Successfully uploaded {asset_name}!")
114 | success = True
115 | else:
116 | print(
117 | f"Request failed with status code {asset_api_response.status_code}")
118 |
119 | # 仅保留最新 Release 以防超出 Gitee 仓库配额
120 | try:
121 | delete_gitee_releases(release_id, api_client, release_api_uri, access_token)
122 | except ValueError as e:
123 | print(e)
124 |
125 | api_client.close()
126 | print("Sync is completed!")
127 |
128 |
129 | def get_abs_path(path: str):
130 | wd = os.getcwd()
131 | return os.path.join(wd, path)
132 |
133 |
134 | get_github_latest_release()
135 |
--------------------------------------------------------------------------------
/.github/user-frontend.20241128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/.github/user-frontend.20241128.png
--------------------------------------------------------------------------------
/.github/workflows/codeql-analysis.yml:
--------------------------------------------------------------------------------
1 | # For most projects, this workflow file will not need changing; you simply need
2 | # to commit it to your repository.
3 | #
4 | # You may wish to alter this file to override the set of languages analyzed,
5 | # or to provide custom queries or build logic.
6 | #
7 | # ******** NOTE ********
8 | # We have attempted to detect the languages in your repository. Please check
9 | # the `language` matrix defined below to confirm you have the correct set of
10 | # supported CodeQL languages.
11 | #
12 | name: "CodeQL"
13 |
14 | on:
15 | push:
16 | branches: [ master ]
17 | pull_request:
18 | # The branches below must be a subset of the branches above
19 | branches: [ master ]
20 | schedule:
21 | - cron: '15 20 * * 0'
22 |
23 | jobs:
24 | analyze:
25 | name: Analyze
26 | runs-on: ubuntu-latest
27 | permissions:
28 | actions: read
29 | contents: read
30 | security-events: write
31 |
32 | strategy:
33 | fail-fast: false
34 | matrix:
35 | language: [ 'go', 'javascript' ]
36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support
38 |
39 | steps:
40 | - name: Checkout repository
41 | uses: actions/checkout@v4
42 |
43 | - name: Install Go
44 | uses: actions/setup-go@v4
45 | with:
46 | go-version-file: go.mod
47 |
48 | # Initializes the CodeQL tools for scanning.
49 | - name: Initialize CodeQL
50 | uses: github/codeql-action/init@v2
51 | with:
52 | languages: ${{ matrix.language }}
53 | # If you wish to specify custom queries, you can do so here or in a config file.
54 | # By default, queries listed here will override any specified in a config file.
55 | # Prefix the list here with "+" to use these queries and those in the config file.
56 | # queries: ./path/to/local/query, your-org/your-repo/queries@main
57 |
58 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
59 | # If this step fails, then you should remove it and run the build manually (see below)
60 | - name: Autobuild
61 | uses: github/codeql-action/autobuild@v2
62 |
63 | # ℹ️ Command-line programs to run using the OS shell.
64 | # 📚 https://git.io/JvXDl
65 |
66 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines
67 | # and modify them (or add more) to build your code if your project
68 | # uses a compiled language
69 |
70 | #- run: |
71 | # make bootstrap
72 | # make release
73 |
74 | - name: Perform CodeQL Analysis
75 | uses: github/codeql-action/analyze@v2
76 |
--------------------------------------------------------------------------------
/.github/workflows/contributors.yml:
--------------------------------------------------------------------------------
1 | name: Contributors
2 |
3 | on:
4 | push:
5 | branches: [master]
6 |
7 | jobs:
8 | contributors:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Generate Contributors Images
12 | uses: jaywcjlove/github-action-contributors@main
13 | id: contributors
14 | with:
15 | filter-author: (renovate\[bot\]|renovate-bot|dependabot\[bot\])
16 | hideName: 'false' # Hide names in htmlTable
17 | avatarSize: 50 # Set the avatar size.
18 | truncate: 6
19 | avatarMargin: 8
20 |
21 | - name: Modify htmlTable README.md
22 | uses: jaywcjlove/github-action-modify-file-content@main
23 | with:
24 | message: update contributors[no ci]
25 | token: ${{ secrets.NAIBA_PAT }}
26 | openDelimiter: ''
27 | closeDelimiter: ''
28 | path: README.md
29 | body: '${{steps.contributors.outputs.htmlList}}'
--------------------------------------------------------------------------------
/.github/workflows/sync-code.yml:
--------------------------------------------------------------------------------
1 | name: Sync
2 |
3 | on:
4 | push:
5 | branches: [master]
6 |
7 | jobs:
8 | sync-to-jihulab:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - uses: actions/checkout@v4
12 | - uses: adambirds/sync-github-to-gitlab-action@v1.1.0
13 | with:
14 | destination_repository: git@gitee.com:naibahq/nezha.git
15 | destination_branch_name: master
16 | destination_ssh_key: ${{ secrets.GITEE_SSH_KEY }}
17 |
--------------------------------------------------------------------------------
/.github/workflows/sync-release.yml:
--------------------------------------------------------------------------------
1 | name: Sync Release to Gitee
2 |
3 | on:
4 | workflow_dispatch:
5 |
6 | jobs:
7 | sync-release-to-gitee:
8 | runs-on: ubuntu-latest
9 | timeout-minutes: 30
10 | env:
11 | GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
12 | steps:
13 | - uses: actions/checkout@v4
14 | - name: Sync to Gitee
15 | run: |
16 | pip3 install PyGitHub
17 | python3 .github/sync.py
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Run Tests
2 |
3 | on:
4 | push:
5 | paths:
6 | - "**.go"
7 | - "go.mod"
8 | - "go.sum"
9 | - "resource/**"
10 | - ".github/workflows/test.yml"
11 | pull_request:
12 | branches:
13 | - master
14 |
15 | jobs:
16 | tests:
17 | strategy:
18 | fail-fast: true
19 | matrix:
20 | os: [ubuntu, windows, macos]
21 |
22 | runs-on: ${{ matrix.os }}-latest
23 | env:
24 | GO111MODULE: on
25 | steps:
26 | - uses: actions/checkout@v4
27 |
28 | - uses: actions/setup-go@v5
29 | with:
30 | go-version: "1.24.x"
31 |
32 | - name: generate swagger docs
33 | run: |
34 | go install github.com/swaggo/swag/cmd/swag@latest
35 | touch ./cmd/dashboard/user-dist/a
36 | touch ./cmd/dashboard/admin-dist/a
37 | swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --parseGoList=false
38 |
39 | - name: Unit test
40 | run: |
41 | go test -v ./...
42 |
43 | - name: Build test
44 | run: go build -v ./cmd/dashboard
45 |
46 | - name: Run Gosec Security Scanner
47 | if: runner.os == 'Linux'
48 | uses: securego/gosec@master
49 | with:
50 | args: --exclude=G104,G402,G115,G203 ./...
51 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, build with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *~
13 | *.out
14 | *.pprof
15 | .idea
16 | /dist
17 | .DS_Store
18 | /cmd/dashboard/data
19 | /cmd/dashboard/main
20 | /cmd/dashboard/*-dist
21 | !/cmd/dashboard/admin-dist/.gitkeep
22 | !/cmd/dashboard/user-dist/.gitkeep
23 | /config.yml
24 | /resource/template/theme-custom
25 | /resource/static/custom
26 | /cmd/dashboard/docs
27 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | before:
3 | hooks:
4 | - go mod tidy -v
5 | builds:
6 | - id: linux_arm64
7 | env:
8 | - CGO_ENABLED=1
9 | - CC=aarch64-linux-gnu-gcc
10 | ldflags:
11 | - -s -w
12 | - -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
13 | - -extldflags "-static -fpic"
14 | flags:
15 | - -trimpath
16 | - -buildvcs=false
17 | tags:
18 | - go_json
19 | goos:
20 | - linux
21 | goarch:
22 | - arm64
23 | main: ./cmd/dashboard
24 | binary: dashboard-{{ .Os }}-{{ .Arch }}
25 | - id: linux_amd64
26 | env:
27 | - CGO_ENABLED=1
28 | - CC=x86_64-linux-gnu-gcc
29 | ldflags:
30 | - -s -w
31 | - -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
32 | - -extldflags "-static -fpic"
33 | flags:
34 | - -trimpath
35 | - -buildvcs=false
36 | tags:
37 | - go_json
38 | goos:
39 | - linux
40 | goarch:
41 | - amd64
42 | main: ./cmd/dashboard
43 | binary: dashboard-{{ .Os }}-{{ .Arch }}
44 | - id: linux_s390x
45 | env:
46 | - CGO_ENABLED=1
47 | - CC=s390x-linux-gnu-gcc
48 | ldflags:
49 | - -s -w
50 | - -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
51 | - -extldflags "-static -fpic"
52 | flags:
53 | - -trimpath
54 | - -buildvcs=false
55 | tags:
56 | - go_json
57 | goos:
58 | - linux
59 | goarch:
60 | - s390x
61 | main: ./cmd/dashboard
62 | binary: dashboard-{{ .Os }}-{{ .Arch }}
63 | - id: windows_amd64
64 | env:
65 | - CGO_ENABLED=1
66 | - CC=x86_64-w64-mingw32-gcc
67 | ldflags:
68 | - -s -w
69 | - -X github.com/nezhahq/nezha/service/singleton.Version={{.Version}}
70 | - -extldflags "-static -fpic"
71 | flags:
72 | - -trimpath
73 | - -buildvcs=false
74 | tags:
75 | - go_json
76 | goos:
77 | - windows
78 | goarch:
79 | - amd64
80 | main: ./cmd/dashboard
81 | binary: dashboard-{{ .Os }}-{{ .Arch }}
82 | snapshot:
83 | version_template: "dashboard"
84 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine AS certs
2 | RUN apk update && apk add ca-certificates
3 |
4 | FROM busybox:stable-musl
5 |
6 | ARG TARGETOS
7 | ARG TARGETARCH
8 |
9 | COPY --from=certs /etc/ssl/certs /etc/ssl/certs
10 | COPY ./script/entrypoint.sh /entrypoint.sh
11 | RUN chmod +x /entrypoint.sh
12 |
13 | WORKDIR /dashboard
14 | COPY dist/dashboard-${TARGETOS}-${TARGETARCH} ./app
15 |
16 | VOLUME ["/dashboard/data"]
17 | EXPOSE 8008
18 | ARG TZ=Asia/Shanghai
19 | ENV TZ=$TZ
20 | ENTRYPOINT ["/entrypoint.sh"]
21 |
--------------------------------------------------------------------------------
/SECURITY.md:
--------------------------------------------------------------------------------
1 | # Security Policy
2 |
3 | ## Supported Versions
4 |
5 | Code in `master` branch.
6 |
7 | ## Reporting a Vulnerability
8 |
9 | Thank you for your contribution to open source security, please email hi@nai.ba with details of the vulnerability.
10 |
--------------------------------------------------------------------------------
/cmd/dashboard/admin-dist/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/cmd/dashboard/admin-dist/.gitkeep
--------------------------------------------------------------------------------
/cmd/dashboard/controller/alertrule.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "maps"
5 | "strconv"
6 | "time"
7 |
8 | "github.com/gin-gonic/gin"
9 | "github.com/jinzhu/copier"
10 |
11 | "github.com/nezhahq/nezha/model"
12 | "github.com/nezhahq/nezha/service/singleton"
13 | )
14 |
15 | // List Alert rules
16 | // @Summary List Alert rules
17 | // @Security BearerAuth
18 | // @Schemes
19 | // @Description List Alert rules
20 | // @Tags auth required
21 | // @Param id query uint false "Resource ID"
22 | // @Produce json
23 | // @Success 200 {object} model.CommonResponse[[]model.AlertRule]
24 | // @Router /alert-rule [get]
25 | func listAlertRule(c *gin.Context) ([]*model.AlertRule, error) {
26 | singleton.AlertsLock.RLock()
27 | defer singleton.AlertsLock.RUnlock()
28 |
29 | var ar []*model.AlertRule
30 | if err := copier.Copy(&ar, &singleton.Alerts); err != nil {
31 | return nil, err
32 | }
33 | return ar, nil
34 | }
35 |
36 | // Add Alert Rule
37 | // @Summary Add Alert Rule
38 | // @Security BearerAuth
39 | // @Schemes
40 | // @Description Add Alert Rule
41 | // @Tags auth required
42 | // @Accept json
43 | // @param request body model.AlertRuleForm true "AlertRuleForm"
44 | // @Produce json
45 | // @Success 200 {object} model.CommonResponse[uint64]
46 | // @Router /alert-rule [post]
47 | func createAlertRule(c *gin.Context) (uint64, error) {
48 | var arf model.AlertRuleForm
49 | var r model.AlertRule
50 |
51 | if err := c.ShouldBindJSON(&arf); err != nil {
52 | return 0, err
53 | }
54 |
55 | uid := getUid(c)
56 |
57 | r.UserID = uid
58 | r.Name = arf.Name
59 | r.Rules = arf.Rules
60 | r.FailTriggerTasks = arf.FailTriggerTasks
61 | r.RecoverTriggerTasks = arf.RecoverTriggerTasks
62 | r.NotificationGroupID = arf.NotificationGroupID
63 | enable := arf.Enable
64 | r.TriggerMode = arf.TriggerMode
65 | r.Enable = &enable
66 |
67 | if err := validateRule(c, &r); err != nil {
68 | return 0, err
69 | }
70 |
71 | if err := singleton.DB.Create(&r).Error; err != nil {
72 | return 0, newGormError("%v", err)
73 | }
74 |
75 | singleton.OnRefreshOrAddAlert(&r)
76 | return r.ID, nil
77 | }
78 |
79 | // Update Alert Rule
80 | // @Summary Update Alert Rule
81 | // @Security BearerAuth
82 | // @Schemes
83 | // @Description Update Alert Rule
84 | // @Tags auth required
85 | // @Accept json
86 | // @param id path uint true "Alert ID"
87 | // @param request body model.AlertRuleForm true "AlertRuleForm"
88 | // @Produce json
89 | // @Success 200 {object} model.CommonResponse[any]
90 | // @Router /alert-rule/{id} [patch]
91 | func updateAlertRule(c *gin.Context) (any, error) {
92 | idStr := c.Param("id")
93 | id, err := strconv.ParseUint(idStr, 10, 64)
94 | if err != nil {
95 | return nil, err
96 | }
97 |
98 | var arf model.AlertRuleForm
99 | if err := c.ShouldBindJSON(&arf); err != nil {
100 | return 0, err
101 | }
102 |
103 | var r model.AlertRule
104 | if err := singleton.DB.First(&r, id).Error; err != nil {
105 | return nil, singleton.Localizer.ErrorT("alert id %d does not exist", id)
106 | }
107 |
108 | if !r.HasPermission(c) {
109 | return nil, singleton.Localizer.ErrorT("permission denied")
110 | }
111 |
112 | r.Name = arf.Name
113 | r.Rules = arf.Rules
114 | r.FailTriggerTasks = arf.FailTriggerTasks
115 | r.RecoverTriggerTasks = arf.RecoverTriggerTasks
116 | r.NotificationGroupID = arf.NotificationGroupID
117 | enable := arf.Enable
118 | r.TriggerMode = arf.TriggerMode
119 | r.Enable = &enable
120 |
121 | if err := validateRule(c, &r); err != nil {
122 | return 0, err
123 | }
124 |
125 | if err := singleton.DB.Save(&r).Error; err != nil {
126 | return 0, newGormError("%v", err)
127 | }
128 |
129 | singleton.OnRefreshOrAddAlert(&r)
130 | return r.ID, nil
131 | }
132 |
133 | // Batch delete Alert rules
134 | // @Summary Batch delete Alert rules
135 | // @Security BearerAuth
136 | // @Schemes
137 | // @Description Batch delete Alert rules
138 | // @Tags auth required
139 | // @Accept json
140 | // @param request body []uint64 true "id list"
141 | // @Produce json
142 | // @Success 200 {object} model.CommonResponse[any]
143 | // @Router /batch-delete/alert-rule [post]
144 | func batchDeleteAlertRule(c *gin.Context) (any, error) {
145 | var ar []uint64
146 | if err := c.ShouldBindJSON(&ar); err != nil {
147 | return nil, err
148 | }
149 |
150 | var ars []model.AlertRule
151 | if err := singleton.DB.Where("id in (?)", ar).Find(&ars).Error; err != nil {
152 | return nil, err
153 | }
154 |
155 | for _, a := range ars {
156 | if !a.HasPermission(c) {
157 | return nil, singleton.Localizer.ErrorT("permission denied")
158 | }
159 | }
160 |
161 | if err := singleton.DB.Unscoped().Delete(&model.AlertRule{}, "id in (?)", ar).Error; err != nil {
162 | return nil, newGormError("%v", err)
163 | }
164 |
165 | singleton.OnDeleteAlert(ar)
166 | return nil, nil
167 | }
168 |
169 | func validateRule(c *gin.Context, r *model.AlertRule) error {
170 | if len(r.Rules) > 0 {
171 | for _, rule := range r.Rules {
172 | if !singleton.ServerShared.CheckPermission(c, maps.Keys(rule.Ignore)) {
173 | return singleton.Localizer.ErrorT("permission denied")
174 | }
175 |
176 | if !rule.IsTransferDurationRule() {
177 | if rule.Duration < 3 {
178 | return singleton.Localizer.ErrorT("duration need to be at least 3")
179 | }
180 | } else {
181 | if rule.CycleInterval < 1 {
182 | return singleton.Localizer.ErrorT("cycle_interval need to be at least 1")
183 | }
184 | if rule.CycleStart == nil {
185 | return singleton.Localizer.ErrorT("cycle_start is not set")
186 | }
187 | if rule.CycleStart.After(time.Now()) {
188 | return singleton.Localizer.ErrorT("cycle_start is a future value")
189 | }
190 | }
191 | }
192 | } else {
193 | return singleton.Localizer.ErrorT("need to configure at least a single rule")
194 | }
195 | return nil
196 | }
197 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/fm.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "strconv"
5 | "time"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/goccy/go-json"
9 | "github.com/gorilla/websocket"
10 | "github.com/hashicorp/go-uuid"
11 |
12 | "github.com/nezhahq/nezha/model"
13 | "github.com/nezhahq/nezha/pkg/websocketx"
14 | "github.com/nezhahq/nezha/proto"
15 | "github.com/nezhahq/nezha/service/rpc"
16 | "github.com/nezhahq/nezha/service/singleton"
17 | )
18 |
19 | // Create FM session
20 | // @Summary Create FM session
21 | // @Description Create an "attached" FM. It is advised to only call this within a terminal session.
22 | // @Tags auth required
23 | // @Accept json
24 | // @Param id query uint true "Server ID"
25 | // @Produce json
26 | // @Success 200 {object} model.CreateFMResponse
27 | // @Router /file [get]
28 | func createFM(c *gin.Context) (*model.CreateFMResponse, error) {
29 | idStr := c.Query("id")
30 | id, err := strconv.ParseUint(idStr, 10, 64)
31 | if err != nil {
32 | return nil, err
33 | }
34 |
35 | server, _ := singleton.ServerShared.Get(id)
36 | if server == nil || server.TaskStream == nil {
37 | return nil, singleton.Localizer.ErrorT("server not found or not connected")
38 | }
39 |
40 | if !server.HasPermission(c) {
41 | return nil, singleton.Localizer.ErrorT("permission denied")
42 | }
43 |
44 | streamId, err := uuid.GenerateUUID()
45 | if err != nil {
46 | return nil, err
47 | }
48 |
49 | rpc.NezhaHandlerSingleton.CreateStream(streamId)
50 |
51 | fmData, _ := json.Marshal(&model.TaskFM{
52 | StreamID: streamId,
53 | })
54 | if err := server.TaskStream.Send(&proto.Task{
55 | Type: model.TaskTypeFM,
56 | Data: string(fmData),
57 | }); err != nil {
58 | return nil, err
59 | }
60 |
61 | return &model.CreateFMResponse{
62 | SessionID: streamId,
63 | }, nil
64 | }
65 |
66 | // Start FM stream
67 | // @Summary Start FM stream
68 | // @Description Start FM stream
69 | // @Tags auth required
70 | // @Param id path string true "Stream UUID"
71 | // @Success 200 {object} model.CommonResponse[any]
72 | // @Router /ws/file/{id} [get]
73 | func fmStream(c *gin.Context) (any, error) {
74 | streamId := c.Param("id")
75 | if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
76 | return nil, err
77 | }
78 | defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
79 |
80 | wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
81 | if err != nil {
82 | return nil, newWsError("%v", err)
83 | }
84 | defer wsConn.Close()
85 | conn := websocketx.NewConn(wsConn)
86 |
87 | go func() {
88 | // PING 保活
89 | for {
90 | if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
91 | return
92 | }
93 | time.Sleep(time.Second * 10)
94 | }
95 | }()
96 |
97 | if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
98 | return nil, newWsError("%v", err)
99 | }
100 |
101 | if err = rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10); err != nil {
102 | return nil, newWsError("%v", err)
103 | }
104 |
105 | return nil, newWsError("")
106 | }
107 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/jwt.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | jwt "github.com/appleboy/gin-jwt/v2"
8 | "github.com/gin-gonic/gin"
9 | "github.com/goccy/go-json"
10 | "golang.org/x/crypto/bcrypt"
11 | "gorm.io/gorm"
12 |
13 | "github.com/nezhahq/nezha/cmd/dashboard/controller/waf"
14 | "github.com/nezhahq/nezha/model"
15 | "github.com/nezhahq/nezha/pkg/utils"
16 | "github.com/nezhahq/nezha/service/singleton"
17 | )
18 |
19 | func initParams() *jwt.GinJWTMiddleware {
20 | return &jwt.GinJWTMiddleware{
21 | Realm: singleton.Conf.SiteName,
22 | Key: []byte(singleton.Conf.JWTSecretKey),
23 | CookieName: "nz-jwt",
24 | SendCookie: true,
25 | Timeout: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
26 | MaxRefresh: time.Hour * time.Duration(singleton.Conf.JWTTimeout),
27 | IdentityKey: model.CtxKeyAuthorizedUser,
28 | PayloadFunc: payloadFunc(),
29 |
30 | IdentityHandler: identityHandler(),
31 | Authenticator: authenticator(),
32 | Authorizator: authorizator(),
33 | Unauthorized: unauthorized(),
34 | TokenLookup: "header: Authorization, query: token, cookie: nz-jwt",
35 | TokenHeadName: "Bearer",
36 | TimeFunc: time.Now,
37 |
38 | LoginResponse: func(c *gin.Context, code int, token string, expire time.Time) {
39 | c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
40 | Success: true,
41 | Data: model.LoginResponse{
42 | Token: token,
43 | Expire: expire.Format(time.RFC3339),
44 | },
45 | })
46 | },
47 | RefreshResponse: refreshResponse,
48 | }
49 | }
50 |
51 | func payloadFunc() func(data any) jwt.MapClaims {
52 | return func(data any) jwt.MapClaims {
53 | if v, ok := data.(string); ok {
54 | return jwt.MapClaims{
55 | model.CtxKeyAuthorizedUser: v,
56 | }
57 | }
58 | return jwt.MapClaims{}
59 | }
60 | }
61 |
62 | func identityHandler() func(c *gin.Context) any {
63 | return func(c *gin.Context) any {
64 | claims := jwt.ExtractClaims(c)
65 | userId := claims[model.CtxKeyAuthorizedUser].(string)
66 | var user model.User
67 | if err := singleton.DB.First(&user, userId).Error; err != nil {
68 | return nil
69 | }
70 | return &user
71 | }
72 | }
73 |
74 | // User Login
75 | // @Summary user login
76 | // @Schemes
77 | // @Description user login
78 | // @Accept json
79 | // @param loginRequest body model.LoginRequest true "Login Request"
80 | // @Produce json
81 | // @Success 200 {object} model.CommonResponse[model.LoginResponse]
82 | // @Router /login [post]
83 | func authenticator() func(c *gin.Context) (any, error) {
84 | return func(c *gin.Context) (any, error) {
85 | var loginVals model.LoginRequest
86 | if err := c.ShouldBind(&loginVals); err != nil {
87 | return "", jwt.ErrMissingLoginValues
88 | }
89 |
90 | var user model.User
91 | realip := c.GetString(model.CtxKeyRealIPStr)
92 |
93 | if err := singleton.DB.Select("id", "password", "reject_password").Where("username = ?", loginVals.Username).First(&user).Error; err != nil {
94 | if err == gorm.ErrRecordNotFound {
95 | model.BlockIP(singleton.DB, realip, model.WAFBlockReasonTypeLoginFail, model.BlockIDUnknownUser)
96 | }
97 | return nil, jwt.ErrFailedAuthentication
98 | }
99 |
100 | if user.RejectPassword {
101 | model.BlockIP(singleton.DB, realip, model.WAFBlockReasonTypeLoginFail, int64(user.ID))
102 | return nil, jwt.ErrFailedAuthentication
103 | }
104 |
105 | if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(loginVals.Password)); err != nil {
106 | model.BlockIP(singleton.DB, realip, model.WAFBlockReasonTypeLoginFail, int64(user.ID))
107 | return nil, jwt.ErrFailedAuthentication
108 | }
109 |
110 | model.UnblockIP(singleton.DB, realip, model.BlockIDUnknownUser)
111 | model.UnblockIP(singleton.DB, realip, int64(user.ID))
112 | return utils.Itoa(user.ID), nil
113 | }
114 | }
115 |
116 | func authorizator() func(data any, c *gin.Context) bool {
117 | return func(data any, c *gin.Context) bool {
118 | _, ok := data.(*model.User)
119 | return ok
120 | }
121 | }
122 |
123 | func unauthorized() func(c *gin.Context, code int, message string) {
124 | return func(c *gin.Context, code int, message string) {
125 | c.JSON(http.StatusOK, model.CommonResponse[any]{
126 | Success: false,
127 | Error: "ApiErrorUnauthorized",
128 | })
129 | }
130 | }
131 |
132 | // Refresh token
133 | // @Summary Refresh token
134 | // @Security BearerAuth
135 | // @Schemes
136 | // @Description Refresh token
137 | // @Tags auth required
138 | // @Produce json
139 | // @Success 200 {object} model.CommonResponse[model.LoginResponse]
140 | // @Router /refresh-token [get]
141 | func refreshResponse(c *gin.Context, code int, token string, expire time.Time) {
142 | c.JSON(http.StatusOK, model.CommonResponse[model.LoginResponse]{
143 | Success: true,
144 | Data: model.LoginResponse{
145 | Token: token,
146 | Expire: expire.Format(time.RFC3339),
147 | },
148 | })
149 | }
150 |
151 | func fallbackAuthMiddleware(mw *jwt.GinJWTMiddleware) func(c *gin.Context) {
152 | return func(c *gin.Context) {
153 | claims, err := mw.GetClaimsFromJWT(c)
154 | if err != nil {
155 | return
156 | }
157 |
158 | switch v := claims["exp"].(type) {
159 | case nil:
160 | return
161 | case float64:
162 | if int64(v) < mw.TimeFunc().Unix() {
163 | return
164 | }
165 | case json.Number:
166 | n, err := v.Int64()
167 | if err != nil {
168 | return
169 | }
170 | if n < mw.TimeFunc().Unix() {
171 | return
172 | }
173 | default:
174 | return
175 | }
176 |
177 | c.Set("JWT_PAYLOAD", claims)
178 | identity := mw.IdentityHandler(c)
179 |
180 | if identity != nil {
181 | model.UnblockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.BlockIDToken)
182 | c.Set(mw.IdentityKey, identity)
183 | } else {
184 | if err := model.BlockIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr), model.WAFBlockReasonTypeBruteForceToken, model.BlockIDToken); err != nil {
185 | waf.ShowBlockPage(c, err)
186 | return
187 | }
188 | }
189 |
190 | c.Next()
191 | }
192 | }
193 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/nat.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "slices"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/jinzhu/copier"
9 |
10 | "github.com/nezhahq/nezha/model"
11 | "github.com/nezhahq/nezha/pkg/utils"
12 | "github.com/nezhahq/nezha/service/singleton"
13 | )
14 |
15 | // List NAT Profiles
16 | // @Summary List NAT profiles
17 | // @Schemes
18 | // @Description List NAT profiles
19 | // @Security BearerAuth
20 | // @Tags auth required
21 | // @Param id query uint false "Resource ID"
22 | // @Produce json
23 | // @Success 200 {object} model.CommonResponse[[]model.NAT]
24 | // @Router /nat [get]
25 | func listNAT(c *gin.Context) ([]*model.NAT, error) {
26 | var n []*model.NAT
27 |
28 | slist := singleton.NATShared.GetSortedList()
29 |
30 | if err := copier.Copy(&n, &slist); err != nil {
31 | return nil, err
32 | }
33 |
34 | return n, nil
35 | }
36 |
37 | // Add NAT profile
38 | // @Summary Add NAT profile
39 | // @Security BearerAuth
40 | // @Schemes
41 | // @Description Add NAT profile
42 | // @Tags auth required
43 | // @Accept json
44 | // @param request body model.NATForm true "NAT Request"
45 | // @Produce json
46 | // @Success 200 {object} model.CommonResponse[uint64]
47 | // @Router /nat [post]
48 | func createNAT(c *gin.Context) (uint64, error) {
49 | var nf model.NATForm
50 | var n model.NAT
51 |
52 | if err := c.ShouldBindJSON(&nf); err != nil {
53 | return 0, err
54 | }
55 |
56 | if server, ok := singleton.ServerShared.Get(nf.ServerID); ok {
57 | if !server.HasPermission(c) {
58 | return 0, singleton.Localizer.ErrorT("permission denied")
59 | }
60 | }
61 |
62 | uid := getUid(c)
63 |
64 | n.UserID = uid
65 | n.Enabled = nf.Enabled
66 | n.Name = nf.Name
67 | n.Domain = nf.Domain
68 | n.Host = nf.Host
69 | n.ServerID = nf.ServerID
70 |
71 | if err := singleton.DB.Create(&n).Error; err != nil {
72 | return 0, newGormError("%v", err)
73 | }
74 |
75 | singleton.NATShared.Update(&n)
76 | return n.ID, nil
77 | }
78 |
79 | // Edit NAT profile
80 | // @Summary Edit NAT profile
81 | // @Security BearerAuth
82 | // @Schemes
83 | // @Description Edit NAT profile
84 | // @Tags auth required
85 | // @Accept json
86 | // @param id path uint true "Profile ID"
87 | // @param request body model.NATForm true "NAT Request"
88 | // @Produce json
89 | // @Success 200 {object} model.CommonResponse[any]
90 | // @Router /nat/{id} [patch]
91 | func updateNAT(c *gin.Context) (any, error) {
92 | idStr := c.Param("id")
93 |
94 | id, err := strconv.ParseUint(idStr, 10, 64)
95 | if err != nil {
96 | return nil, err
97 | }
98 |
99 | var nf model.NATForm
100 | if err := c.ShouldBindJSON(&nf); err != nil {
101 | return nil, err
102 | }
103 |
104 | if server, ok := singleton.ServerShared.Get(nf.ServerID); ok {
105 | if !server.HasPermission(c) {
106 | return nil, singleton.Localizer.ErrorT("permission denied")
107 | }
108 | }
109 |
110 | var n model.NAT
111 | if err = singleton.DB.First(&n, id).Error; err != nil {
112 | return nil, singleton.Localizer.ErrorT("profile id %d does not exist", id)
113 | }
114 |
115 | if !n.HasPermission(c) {
116 | return nil, singleton.Localizer.ErrorT("permission denied")
117 | }
118 |
119 | n.Enabled = nf.Enabled
120 | n.Name = nf.Name
121 | n.Domain = nf.Domain
122 | n.Host = nf.Host
123 | n.ServerID = nf.ServerID
124 |
125 | if err := singleton.DB.Save(&n).Error; err != nil {
126 | return 0, newGormError("%v", err)
127 | }
128 |
129 | singleton.NATShared.Update(&n)
130 | return nil, nil
131 | }
132 |
133 | // Batch delete NAT configurations
134 | // @Summary Batch delete NAT configurations
135 | // @Security BearerAuth
136 | // @Schemes
137 | // @Description Batch delete NAT configurations
138 | // @Tags auth required
139 | // @Accept json
140 | // @param request body []uint64 true "id list"
141 | // @Produce json
142 | // @Success 200 {object} model.CommonResponse[any]
143 | // @Router /batch-delete/nat [post]
144 | func batchDeleteNAT(c *gin.Context) (any, error) {
145 | var n []uint64
146 | if err := c.ShouldBindJSON(&n); err != nil {
147 | return nil, err
148 | }
149 |
150 | if !singleton.NATShared.CheckPermission(c, utils.ConvertSeq(slices.Values(n),
151 | func(id uint64) string {
152 | return singleton.NATShared.GetDomain(id)
153 | })) {
154 | return nil, singleton.Localizer.ErrorT("permission denied")
155 | }
156 |
157 | if err := singleton.DB.Unscoped().Delete(&model.NAT{}, "id in (?)", n).Error; err != nil {
158 | return nil, newGormError("%v", err)
159 | }
160 |
161 | singleton.NATShared.Delete(n)
162 | return nil, nil
163 | }
164 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/notification.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "slices"
5 | "strconv"
6 |
7 | "github.com/gin-gonic/gin"
8 | "github.com/jinzhu/copier"
9 | "gorm.io/gorm"
10 |
11 | "github.com/nezhahq/nezha/model"
12 | "github.com/nezhahq/nezha/service/singleton"
13 | )
14 |
15 | // List notification
16 | // @Summary List notification
17 | // @Security BearerAuth
18 | // @Schemes
19 | // @Description List notification
20 | // @Tags auth required
21 | // @Param id query uint false "Resource ID"
22 | // @Produce json
23 | // @Success 200 {object} model.CommonResponse[[]model.Notification]
24 | // @Router /notification [get]
25 | func listNotification(c *gin.Context) ([]*model.Notification, error) {
26 | slist := singleton.NotificationShared.GetSortedList()
27 |
28 | var notifications []*model.Notification
29 | if err := copier.Copy(¬ifications, &slist); err != nil {
30 | return nil, err
31 | }
32 | return notifications, nil
33 | }
34 |
35 | // Add notification
36 | // @Summary Add notification
37 | // @Security BearerAuth
38 | // @Schemes
39 | // @Description Add notification
40 | // @Tags auth required
41 | // @Accept json
42 | // @param request body model.NotificationForm true "NotificationForm"
43 | // @Produce json
44 | // @Success 200 {object} model.CommonResponse[any]
45 | // @Router /notification [post]
46 | func createNotification(c *gin.Context) (uint64, error) {
47 | var nf model.NotificationForm
48 | if err := c.ShouldBindJSON(&nf); err != nil {
49 | return 0, err
50 | }
51 |
52 | var n model.Notification
53 | n.UserID = getUid(c)
54 | n.Name = nf.Name
55 | n.RequestMethod = nf.RequestMethod
56 | n.RequestType = nf.RequestType
57 | n.RequestHeader = nf.RequestHeader
58 | n.RequestBody = nf.RequestBody
59 | n.URL = nf.URL
60 | verifyTLS := nf.VerifyTLS
61 | n.VerifyTLS = &verifyTLS
62 |
63 | ns := model.NotificationServerBundle{
64 | Notification: &n,
65 | Server: nil,
66 | Loc: singleton.Loc,
67 | }
68 | // 未勾选跳过检查
69 | if !nf.SkipCheck {
70 | if err := ns.Send(singleton.Localizer.T("a test message")); err != nil {
71 | return 0, err
72 | }
73 | }
74 |
75 | if err := singleton.DB.Create(&n).Error; err != nil {
76 | return 0, newGormError("%v", err)
77 | }
78 |
79 | singleton.NotificationShared.Update(&n)
80 | return n.ID, nil
81 | }
82 |
83 | // Edit notification
84 | // @Summary Edit notification
85 | // @Security BearerAuth
86 | // @Schemes
87 | // @Description Edit notification
88 | // @Tags auth required
89 | // @Accept json
90 | // @Param id path uint true "Notification ID"
91 | // @Param body body model.NotificationForm true "NotificationForm"
92 | // @Produce json
93 | // @Success 200 {object} model.CommonResponse[any]
94 | // @Router /notification/{id} [patch]
95 | func updateNotification(c *gin.Context) (any, error) {
96 | idStr := c.Param("id")
97 | id, err := strconv.ParseUint(idStr, 10, 64)
98 | if err != nil {
99 | return nil, err
100 | }
101 | var nf model.NotificationForm
102 | if err := c.ShouldBindJSON(&nf); err != nil {
103 | return nil, err
104 | }
105 |
106 | var n model.Notification
107 | if err := singleton.DB.First(&n, id).Error; err != nil {
108 | return nil, singleton.Localizer.ErrorT("notification id %d does not exist", id)
109 | }
110 |
111 | if !n.HasPermission(c) {
112 | return nil, singleton.Localizer.ErrorT("permission denied")
113 | }
114 |
115 | n.Name = nf.Name
116 | n.RequestMethod = nf.RequestMethod
117 | n.RequestType = nf.RequestType
118 | n.RequestHeader = nf.RequestHeader
119 | n.RequestBody = nf.RequestBody
120 | n.URL = nf.URL
121 | verifyTLS := nf.VerifyTLS
122 | n.VerifyTLS = &verifyTLS
123 |
124 | ns := model.NotificationServerBundle{
125 | Notification: &n,
126 | Server: nil,
127 | Loc: singleton.Loc,
128 | }
129 | // 未勾选跳过检查
130 | if !nf.SkipCheck {
131 | if err := ns.Send(singleton.Localizer.T("a test message")); err != nil {
132 | return nil, err
133 | }
134 | }
135 |
136 | if err := singleton.DB.Save(&n).Error; err != nil {
137 | return nil, newGormError("%v", err)
138 | }
139 |
140 | singleton.NotificationShared.Update(&n)
141 | return nil, nil
142 | }
143 |
144 | // Batch delete notifications
145 | // @Summary Batch delete notifications
146 | // @Security BearerAuth
147 | // @Schemes
148 | // @Description Batch delete notifications
149 | // @Tags auth required
150 | // @Accept json
151 | // @param request body []uint64 true "id list"
152 | // @Produce json
153 | // @Success 200 {object} model.CommonResponse[any]
154 | // @Router /batch-delete/notification [post]
155 | func batchDeleteNotification(c *gin.Context) (any, error) {
156 | var n []uint64
157 | if err := c.ShouldBindJSON(&n); err != nil {
158 | return nil, err
159 | }
160 |
161 | if !singleton.NotificationShared.CheckPermission(c, slices.Values(n)) {
162 | return nil, singleton.Localizer.ErrorT("permission denied")
163 | }
164 |
165 | err := singleton.DB.Transaction(func(tx *gorm.DB) error {
166 | if err := tx.Unscoped().Delete(&model.Notification{}, "id in (?)", n).Error; err != nil {
167 | return err
168 | }
169 | if err := tx.Unscoped().Delete(&model.NotificationGroupNotification{}, "notification_id in (?)", n).Error; err != nil {
170 | return err
171 | }
172 | return nil
173 | })
174 |
175 | if err != nil {
176 | return nil, newGormError("%v", err)
177 | }
178 |
179 | singleton.NotificationShared.Delete(n)
180 | return nil, nil
181 | }
182 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/setting.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "errors"
5 | "strings"
6 |
7 | "github.com/gin-gonic/gin"
8 |
9 | "github.com/nezhahq/nezha/model"
10 | "github.com/nezhahq/nezha/service/singleton"
11 | )
12 |
13 | // List settings
14 | // @Summary List settings
15 | // @Schemes
16 | // @Description List settings
17 | // @Security BearerAuth
18 | // @Tags common
19 | // @Produce json
20 | // @Success 200 {object} model.CommonResponse[model.SettingResponse]
21 | // @Router /setting [get]
22 | func listConfig(c *gin.Context) (*model.SettingResponse, error) {
23 | u, authorized := c.Get(model.CtxKeyAuthorizedUser)
24 | var isAdmin bool
25 | if authorized {
26 | user := u.(*model.User)
27 | isAdmin = user.Role == model.RoleAdmin
28 | }
29 |
30 | config := *singleton.Conf
31 | config.Language = strings.Replace(config.Language, "_", "-", -1)
32 |
33 | conf := model.SettingResponse{
34 | Config: model.Setting{
35 | ConfigForGuests: config.ConfigForGuests,
36 | ConfigDashboard: config.ConfigDashboard,
37 | IgnoredIPNotificationServerIDs: config.IgnoredIPNotificationServerIDs,
38 | Oauth2Providers: config.Oauth2Providers,
39 | },
40 | Version: singleton.Version,
41 | FrontendTemplates: singleton.FrontendTemplates,
42 | }
43 |
44 | if !authorized || !isAdmin {
45 | configForGuests := config.ConfigForGuests
46 | var configDashboard model.ConfigDashboard
47 | if authorized {
48 | configDashboard.AgentTLS = singleton.Conf.AgentTLS
49 | configDashboard.InstallHost = singleton.Conf.InstallHost
50 | }
51 | conf = model.SettingResponse{
52 | Config: model.Setting{
53 | ConfigForGuests: configForGuests,
54 | ConfigDashboard: configDashboard,
55 | Oauth2Providers: config.Oauth2Providers,
56 | },
57 | }
58 | }
59 |
60 | return &conf, nil
61 | }
62 |
63 | // Edit config
64 | // @Summary Edit config
65 | // @Security BearerAuth
66 | // @Schemes
67 | // @Description Edit config
68 | // @Tags admin required
69 | // @Accept json
70 | // @Param body body model.SettingForm true "SettingForm"
71 | // @Produce json
72 | // @Success 200 {object} model.CommonResponse[any]
73 | // @Router /setting [patch]
74 | func updateConfig(c *gin.Context) (any, error) {
75 | var sf model.SettingForm
76 | if err := c.ShouldBindJSON(&sf); err != nil {
77 | return nil, err
78 | }
79 | var userTemplateValid bool
80 | for _, v := range singleton.FrontendTemplates {
81 | if !userTemplateValid && v.Path == sf.UserTemplate && !v.IsAdmin {
82 | userTemplateValid = true
83 | }
84 | if userTemplateValid {
85 | break
86 | }
87 | }
88 | if !userTemplateValid {
89 | return nil, errors.New("invalid user template")
90 | }
91 |
92 | singleton.Conf.Language = strings.Replace(sf.Language, "-", "_", -1)
93 |
94 | singleton.Conf.EnableIPChangeNotification = sf.EnableIPChangeNotification
95 | singleton.Conf.EnablePlainIPInNotification = sf.EnablePlainIPInNotification
96 | singleton.Conf.Cover = sf.Cover
97 | singleton.Conf.InstallHost = sf.InstallHost
98 | singleton.Conf.IgnoredIPNotification = sf.IgnoredIPNotification
99 | singleton.Conf.IPChangeNotificationGroupID = sf.IPChangeNotificationGroupID
100 | singleton.Conf.SiteName = sf.SiteName
101 | singleton.Conf.DNSServers = sf.DNSServers
102 | singleton.Conf.CustomCode = sf.CustomCode
103 | singleton.Conf.CustomCodeDashboard = sf.CustomCodeDashboard
104 | singleton.Conf.WebRealIPHeader = sf.WebRealIPHeader
105 | singleton.Conf.AgentRealIPHeader = sf.AgentRealIPHeader
106 | singleton.Conf.AgentTLS = sf.AgentTLS
107 | singleton.Conf.UserTemplate = sf.UserTemplate
108 |
109 | if err := singleton.Conf.Save(); err != nil {
110 | return nil, newGormError("%v", err)
111 | }
112 |
113 | singleton.OnUpdateLang(singleton.Conf.Language)
114 | return nil, nil
115 | }
116 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/terminal.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gin-gonic/gin"
7 | "github.com/goccy/go-json"
8 | "github.com/gorilla/websocket"
9 | "github.com/hashicorp/go-uuid"
10 |
11 | "github.com/nezhahq/nezha/model"
12 | "github.com/nezhahq/nezha/pkg/websocketx"
13 | "github.com/nezhahq/nezha/proto"
14 | "github.com/nezhahq/nezha/service/rpc"
15 | "github.com/nezhahq/nezha/service/singleton"
16 | )
17 |
18 | // Create web ssh terminal
19 | // @Summary Create web ssh terminal
20 | // @Description Create web ssh terminal
21 | // @Tags auth required
22 | // @Accept json
23 | // @Param terminal body model.TerminalForm true "TerminalForm"
24 | // @Produce json
25 | // @Success 200 {object} model.CreateTerminalResponse
26 | // @Router /terminal [post]
27 | func createTerminal(c *gin.Context) (*model.CreateTerminalResponse, error) {
28 | var createTerminalReq model.TerminalForm
29 | if err := c.ShouldBind(&createTerminalReq); err != nil {
30 | return nil, err
31 | }
32 |
33 | server, _ := singleton.ServerShared.Get(createTerminalReq.ServerID)
34 | if server == nil || server.TaskStream == nil {
35 | return nil, singleton.Localizer.ErrorT("server not found or not connected")
36 | }
37 |
38 | if !server.HasPermission(c) {
39 | return nil, singleton.Localizer.ErrorT("permission denied")
40 | }
41 |
42 | streamId, err := uuid.GenerateUUID()
43 | if err != nil {
44 | return nil, err
45 | }
46 |
47 | rpc.NezhaHandlerSingleton.CreateStream(streamId)
48 |
49 | terminalData, _ := json.Marshal(&model.TerminalTask{
50 | StreamID: streamId,
51 | })
52 | if err := server.TaskStream.Send(&proto.Task{
53 | Type: model.TaskTypeTerminalGRPC,
54 | Data: string(terminalData),
55 | }); err != nil {
56 | return nil, err
57 | }
58 |
59 | return &model.CreateTerminalResponse{
60 | SessionID: streamId,
61 | ServerID: server.ID,
62 | ServerName: server.Name,
63 | }, nil
64 | }
65 |
66 | // TerminalStream web ssh terminal stream
67 | // @Summary Terminal stream
68 | // @Description Terminal stream
69 | // @Tags auth required
70 | // @Param id path string true "Stream UUID"
71 | // @Success 200 {object} model.CommonResponse[any]
72 | // @Router /ws/terminal/{id} [get]
73 | func terminalStream(c *gin.Context) (any, error) {
74 | streamId := c.Param("id")
75 | if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
76 | return nil, err
77 | }
78 | defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
79 |
80 | wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
81 | if err != nil {
82 | return nil, newWsError("%v", err)
83 | }
84 | defer wsConn.Close()
85 | conn := websocketx.NewConn(wsConn)
86 |
87 | go func() {
88 | // PING 保活
89 | for {
90 | if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
91 | return
92 | }
93 | time.Sleep(time.Second * 10)
94 | }
95 | }()
96 |
97 | if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
98 | return nil, newWsError("%v", err)
99 | }
100 |
101 | if err = rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10); err != nil {
102 | return nil, newWsError("%v", err)
103 | }
104 |
105 | return nil, newWsError("")
106 | }
107 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/waf.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "net"
5 | "slices"
6 | "strconv"
7 |
8 | "github.com/gin-gonic/gin"
9 |
10 | "github.com/nezhahq/nezha/model"
11 | "github.com/nezhahq/nezha/pkg/utils"
12 | "github.com/nezhahq/nezha/service/singleton"
13 | )
14 |
15 | // List blocked addresses
16 | // @Summary List blocked addresses
17 | // @Security BearerAuth
18 | // @Schemes
19 | // @Description List server
20 | // @Tags auth required
21 | // @Param limit query uint false "Page limit"
22 | // @Param offset query uint false "Page offset"
23 | // @Produce json
24 | // @Success 200 {object} model.PaginatedResponse[[]model.WAFApiMock, model.WAFApiMock]
25 | // @Router /waf [get]
26 | func listBlockedAddress(c *gin.Context) (*model.Value[[]*model.WAFApiMock], error) {
27 | limit, err := strconv.Atoi(c.Query("limit"))
28 | if err != nil || limit < 1 {
29 | limit = 25
30 | }
31 |
32 | offset, err := strconv.Atoi(c.Query("offset"))
33 | if err != nil || offset < 0 {
34 | offset = 0
35 | }
36 |
37 | var waf []*model.WAF
38 | if err := singleton.DB.Order("block_timestamp DESC").Limit(limit).Offset(offset).Find(&waf).Error; err != nil {
39 | return nil, err
40 | }
41 |
42 | var total int64
43 | if err := singleton.DB.Model(&model.WAF{}).Count(&total).Error; err != nil {
44 | return nil, err
45 | }
46 |
47 | return &model.Value[[]*model.WAFApiMock]{
48 | Value: slices.Collect(utils.ConvertSeq(slices.Values(waf), func(e *model.WAF) *model.WAFApiMock {
49 | return &model.WAFApiMock{
50 | IP: net.IP(e.IP).String(),
51 | BlockIdentifier: e.BlockIdentifier,
52 | BlockReason: e.BlockReason,
53 | BlockTimestamp: e.BlockTimestamp,
54 | Count: e.Count,
55 | }
56 | })),
57 | Pagination: model.Pagination{
58 | Offset: offset,
59 | Limit: limit,
60 | Total: total,
61 | },
62 | }, nil
63 | }
64 |
65 | // Batch delete blocked addresses
66 | // @Summary Edit server
67 | // @Security BearerAuth
68 | // @Schemes
69 | // @Description Edit server
70 | // @Tags admin required
71 | // @Accept json
72 | // @Param request body []string true "block list"
73 | // @Produce json
74 | // @Success 200 {object} model.CommonResponse[any]
75 | // @Router /batch-delete/waf [patch]
76 | func batchDeleteBlockedAddress(c *gin.Context) (any, error) {
77 | var list []string
78 | if err := c.ShouldBindJSON(&list); err != nil {
79 | return nil, err
80 | }
81 |
82 | if err := model.BatchUnblockIP(singleton.DB, utils.Unique(list)); err != nil {
83 | return nil, newGormError("%v", err)
84 | }
85 |
86 | return nil, nil
87 | }
88 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/waf/waf.go:
--------------------------------------------------------------------------------
1 | package waf
2 |
3 | import (
4 | _ "embed"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/gin-gonic/gin"
9 |
10 | "github.com/nezhahq/nezha/model"
11 | "github.com/nezhahq/nezha/pkg/utils"
12 | "github.com/nezhahq/nezha/service/singleton"
13 | )
14 |
15 | //go:embed waf.html
16 | var errorPageTemplate string
17 |
18 | func RealIp(c *gin.Context) {
19 | if singleton.Conf.WebRealIPHeader == "" {
20 | c.Next()
21 | return
22 | }
23 |
24 | if singleton.Conf.WebRealIPHeader == model.ConfigUsePeerIP {
25 | c.Set(model.CtxKeyRealIPStr, c.RemoteIP())
26 | c.Next()
27 | return
28 | }
29 |
30 | vals := c.Request.Header.Get(singleton.Conf.WebRealIPHeader)
31 | if vals == "" {
32 | c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: "real ip header not found"})
33 | return
34 | }
35 | ip, err := utils.GetIPFromHeader(vals)
36 | if err != nil {
37 | c.AbortWithStatusJSON(http.StatusOK, model.CommonResponse[any]{Success: false, Error: err.Error()})
38 | return
39 | }
40 | c.Set(model.CtxKeyRealIPStr, ip)
41 | c.Next()
42 | }
43 |
44 | func Waf(c *gin.Context) {
45 | if err := model.CheckIP(singleton.DB, c.GetString(model.CtxKeyRealIPStr)); err != nil {
46 | ShowBlockPage(c, err)
47 | return
48 | }
49 | c.Next()
50 | }
51 |
52 | func ShowBlockPage(c *gin.Context, err error) {
53 | c.Writer.WriteHeader(http.StatusForbidden)
54 | c.Header("Content-Type", "text/html; charset=utf-8")
55 | c.Writer.WriteString(strings.Replace(errorPageTemplate, "{error}", err.Error(), 1))
56 | c.Abort()
57 | }
58 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/waf/waf.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Blocked
8 |
35 |
36 |
37 |
38 |
39 | 🤡
40 | Blocked
41 | {error}
42 | nezha WAF
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/cmd/dashboard/controller/ws.go:
--------------------------------------------------------------------------------
1 | package controller
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | "net/http"
7 | "net/url"
8 | "time"
9 | "unicode/utf8"
10 |
11 | "github.com/gin-gonic/gin"
12 | "github.com/goccy/go-json"
13 | "github.com/gorilla/websocket"
14 | "github.com/hashicorp/go-uuid"
15 | "golang.org/x/sync/singleflight"
16 |
17 | "github.com/nezhahq/nezha/model"
18 | "github.com/nezhahq/nezha/pkg/utils"
19 | "github.com/nezhahq/nezha/service/singleton"
20 | )
21 |
22 | var upgrader *websocket.Upgrader
23 |
24 | func InitUpgrader() {
25 | var checkOrigin func(r *http.Request) bool
26 |
27 | // Allow CORS from loopback addresses in debug mode
28 | if singleton.Conf.Debug {
29 | checkOrigin = func(r *http.Request) bool {
30 | if checkSameOrigin(r) {
31 | return true
32 | }
33 | hostAddr := r.Host
34 | host, _, err := net.SplitHostPort(hostAddr)
35 | if err != nil {
36 | return false
37 | }
38 | if ip := net.ParseIP(host); ip != nil {
39 | if ip.IsLoopback() {
40 | return true
41 | }
42 | } else {
43 | // Handle domains like "localhost"
44 | ip, err := net.LookupHost(host)
45 | if err != nil || len(ip) == 0 {
46 | return false
47 | }
48 | if netIP := net.ParseIP(ip[0]); netIP != nil && netIP.IsLoopback() {
49 | return true
50 | }
51 | }
52 | return false
53 | }
54 | }
55 |
56 | upgrader = &websocket.Upgrader{
57 | ReadBufferSize: 32768,
58 | WriteBufferSize: 32768,
59 | CheckOrigin: checkOrigin,
60 | }
61 | }
62 |
63 | func equalASCIIFold(s, t string) bool {
64 | for s != "" && t != "" {
65 | sr, size := utf8.DecodeRuneInString(s)
66 | s = s[size:]
67 | tr, size := utf8.DecodeRuneInString(t)
68 | t = t[size:]
69 | if sr == tr {
70 | continue
71 | }
72 | if 'A' <= sr && sr <= 'Z' {
73 | sr = sr + 'a' - 'A'
74 | }
75 | if 'A' <= tr && tr <= 'Z' {
76 | tr = tr + 'a' - 'A'
77 | }
78 | if sr != tr {
79 | return false
80 | }
81 | }
82 | return s == t
83 | }
84 |
85 | func checkSameOrigin(r *http.Request) bool {
86 | origin := r.Header["Origin"]
87 | if len(origin) == 0 {
88 | return true
89 | }
90 | u, err := url.Parse(origin[0])
91 | if err != nil {
92 | return false
93 | }
94 | return equalASCIIFold(u.Host, r.Host)
95 | }
96 |
97 | // Websocket server stream
98 | // @Summary Websocket server stream
99 | // @tags common
100 | // @Schemes
101 | // @Description Websocket server stream
102 | // @security BearerAuth
103 | // @Produce json
104 | // @Success 200 {object} model.StreamServerData
105 | // @Router /ws/server [get]
106 | func serverStream(c *gin.Context) (any, error) {
107 | connId, err := uuid.GenerateUUID()
108 | if err != nil {
109 | return nil, newWsError("%v", err)
110 | }
111 |
112 | conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
113 | if err != nil {
114 | return nil, newWsError("%v", err)
115 | }
116 | defer conn.Close()
117 |
118 | userIp := c.GetString(model.CtxKeyRealIPStr)
119 | if userIp == "" {
120 | userIp = c.RemoteIP()
121 | }
122 |
123 | u, isMember := c.Get(model.CtxKeyAuthorizedUser)
124 | var userId uint64
125 | if isMember {
126 | userId = u.(*model.User).ID
127 | }
128 |
129 | singleton.AddOnlineUser(connId, &model.OnlineUser{
130 | UserID: userId,
131 | IP: userIp,
132 | ConnectedAt: time.Now(),
133 | Conn: conn,
134 | })
135 | defer singleton.RemoveOnlineUser(connId)
136 |
137 | count := 0
138 | for {
139 | stat, err := getServerStat(count == 0, isMember)
140 | if err != nil {
141 | continue
142 | }
143 | if err := conn.WriteMessage(websocket.TextMessage, stat); err != nil {
144 | break
145 | }
146 | count += 1
147 | if count%4 == 0 {
148 | err = conn.WriteMessage(websocket.PingMessage, []byte{})
149 | if err != nil {
150 | break
151 | }
152 | }
153 | time.Sleep(time.Second * 2)
154 | }
155 | return nil, newWsError("")
156 | }
157 |
158 | var requestGroup singleflight.Group
159 |
160 | func getServerStat(withPublicNote, authorized bool) ([]byte, error) {
161 | v, err, _ := requestGroup.Do(fmt.Sprintf("serverStats::%t", authorized), func() (any, error) {
162 | var serverList []*model.Server
163 | if authorized {
164 | serverList = singleton.ServerShared.GetSortedList()
165 | } else {
166 | serverList = singleton.ServerShared.GetSortedListForGuest()
167 | }
168 |
169 | servers := make([]model.StreamServer, 0, len(serverList))
170 | for _, server := range serverList {
171 | var countryCode string
172 | if server.GeoIP != nil {
173 | countryCode = server.GeoIP.CountryCode
174 | }
175 | servers = append(servers, model.StreamServer{
176 | ID: server.ID,
177 | Name: server.Name,
178 | PublicNote: utils.IfOr(withPublicNote, server.PublicNote, ""),
179 | DisplayIndex: server.DisplayIndex,
180 | Host: utils.IfOr(authorized, server.Host, server.Host.Filter()),
181 | State: server.State,
182 | CountryCode: countryCode,
183 | LastActive: server.LastActive,
184 | })
185 | }
186 |
187 | return json.Marshal(model.StreamServerData{
188 | Now: time.Now().Unix() * 1000,
189 | Online: singleton.GetOnlineUserCount(),
190 | Servers: servers,
191 | })
192 | })
193 |
194 | return v.([]byte), err
195 | }
196 |
--------------------------------------------------------------------------------
/cmd/dashboard/rpc/rpc.go:
--------------------------------------------------------------------------------
1 | package rpc
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/http"
8 | "net/netip"
9 | "time"
10 |
11 | "github.com/goccy/go-json"
12 | "google.golang.org/grpc"
13 | "google.golang.org/grpc/metadata"
14 | "google.golang.org/grpc/peer"
15 |
16 | "github.com/hashicorp/go-uuid"
17 | "github.com/nezhahq/nezha/model"
18 | "github.com/nezhahq/nezha/pkg/utils"
19 | "github.com/nezhahq/nezha/proto"
20 | rpcService "github.com/nezhahq/nezha/service/rpc"
21 | "github.com/nezhahq/nezha/service/singleton"
22 | )
23 |
24 | func ServeRPC() *grpc.Server {
25 | server := grpc.NewServer(grpc.ChainUnaryInterceptor(getRealIp, waf))
26 | rpcService.NezhaHandlerSingleton = rpcService.NewNezhaHandler()
27 | proto.RegisterNezhaServiceServer(server, rpcService.NezhaHandlerSingleton)
28 | return server
29 | }
30 |
31 | func waf(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
32 | realip, _ := ctx.Value(model.CtxKeyRealIP{}).(string)
33 | if err := model.CheckIP(singleton.DB, realip); err != nil {
34 | return nil, err
35 | }
36 | return handler(ctx, req)
37 | }
38 |
39 | func getRealIp(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
40 | var ip, connectingIp string
41 | p, ok := peer.FromContext(ctx)
42 | if ok {
43 | addrPort, err := netip.ParseAddrPort(p.Addr.String())
44 | if err == nil {
45 | connectingIp = addrPort.Addr().String()
46 | }
47 | }
48 | ctx = context.WithValue(ctx, model.CtxKeyConnectingIP{}, connectingIp)
49 |
50 | if singleton.Conf.AgentRealIPHeader == "" {
51 | return handler(ctx, req)
52 | }
53 |
54 | if singleton.Conf.AgentRealIPHeader == model.ConfigUsePeerIP {
55 | if connectingIp == "" {
56 | return nil, fmt.Errorf("connecting ip not found")
57 | }
58 | } else {
59 | vals := metadata.ValueFromIncomingContext(ctx, singleton.Conf.AgentRealIPHeader)
60 | if len(vals) == 0 {
61 | return nil, fmt.Errorf("real ip header not found")
62 | }
63 | var err error
64 | ip, err = utils.GetIPFromHeader(vals[0])
65 | if err != nil {
66 | return nil, err
67 | }
68 | }
69 |
70 | if singleton.Conf.Debug {
71 | log.Printf("NEZHA>> gRPC Agent Real IP: %s, connecting IP: %s\n", ip, connectingIp)
72 | }
73 |
74 | ctx = context.WithValue(ctx, model.CtxKeyRealIP{}, ip)
75 | return handler(ctx, req)
76 | }
77 |
78 | func DispatchTask(serviceSentinelDispatchBus <-chan *model.Service) {
79 | for task := range serviceSentinelDispatchBus {
80 | if task == nil {
81 | continue
82 | }
83 |
84 | switch task.Cover {
85 | case model.ServiceCoverIgnoreAll:
86 | for id, enabled := range task.SkipServers {
87 | if !enabled {
88 | continue
89 | }
90 |
91 | server, _ := singleton.ServerShared.Get(id)
92 | if server == nil || server.TaskStream == nil {
93 | continue
94 | }
95 |
96 | if canSendTaskToServer(task, server) {
97 | server.TaskStream.Send(task.PB())
98 | }
99 | }
100 | case model.ServiceCoverAll:
101 | for id, server := range singleton.ServerShared.Range {
102 | if server == nil || server.TaskStream == nil || task.SkipServers[id] {
103 | continue
104 | }
105 |
106 | if canSendTaskToServer(task, server) {
107 | server.TaskStream.Send(task.PB())
108 | }
109 | }
110 | }
111 | }
112 | }
113 |
114 | func DispatchKeepalive() {
115 | singleton.CronShared.AddFunc("@every 20s", func() {
116 | list := singleton.ServerShared.GetSortedList()
117 | for _, s := range list {
118 | if s == nil || s.TaskStream == nil {
119 | continue
120 | }
121 | s.TaskStream.Send(&proto.Task{Type: model.TaskTypeKeepalive})
122 | }
123 | })
124 | }
125 |
126 | func ServeNAT(w http.ResponseWriter, r *http.Request, natConfig *model.NAT) {
127 | server, _ := singleton.ServerShared.Get(natConfig.ServerID)
128 | if server == nil || server.TaskStream == nil {
129 | w.WriteHeader(http.StatusServiceUnavailable)
130 | w.Write([]byte("server not found or not connected"))
131 | return
132 | }
133 |
134 | streamId, err := uuid.GenerateUUID()
135 | if err != nil {
136 | w.WriteHeader(http.StatusServiceUnavailable)
137 | w.Write(fmt.Appendf(nil, "stream id error: %v", err))
138 | return
139 | }
140 |
141 | rpcService.NezhaHandlerSingleton.CreateStream(streamId)
142 | defer rpcService.NezhaHandlerSingleton.CloseStream(streamId)
143 |
144 | taskData, err := json.Marshal(model.TaskNAT{
145 | StreamID: streamId,
146 | Host: natConfig.Host,
147 | })
148 | if err != nil {
149 | w.WriteHeader(http.StatusServiceUnavailable)
150 | w.Write(fmt.Appendf(nil, "task data error: %v", err))
151 | return
152 | }
153 |
154 | if err := server.TaskStream.Send(&proto.Task{
155 | Type: model.TaskTypeNAT,
156 | Data: string(taskData),
157 | }); err != nil {
158 | w.WriteHeader(http.StatusServiceUnavailable)
159 | w.Write(fmt.Appendf(nil, "send task error: %v", err))
160 | return
161 | }
162 |
163 | wWrapped, err := utils.NewRequestWrapper(r, w)
164 | if err != nil {
165 | w.WriteHeader(http.StatusServiceUnavailable)
166 | w.Write(fmt.Appendf(nil, "request wrapper error: %v", err))
167 | return
168 | }
169 |
170 | if err := rpcService.NezhaHandlerSingleton.UserConnected(streamId, wWrapped); err != nil {
171 | w.WriteHeader(http.StatusServiceUnavailable)
172 | w.Write(fmt.Appendf(nil, "user connected error: %v", err))
173 | return
174 | }
175 |
176 | rpcService.NezhaHandlerSingleton.StartStream(streamId, time.Second*10)
177 | }
178 |
179 | func canSendTaskToServer(task *model.Service, server *model.Server) bool {
180 | var role uint8
181 | singleton.UserLock.RLock()
182 | if u, ok := singleton.UserInfoMap[server.UserID]; !ok {
183 | role = model.RoleMember
184 | } else {
185 | role = u.Role
186 | }
187 | singleton.UserLock.RUnlock()
188 |
189 | return task.UserID == server.UserID || role == model.RoleAdmin
190 | }
191 |
--------------------------------------------------------------------------------
/cmd/dashboard/user-dist/.gitkeep:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/cmd/dashboard/user-dist/.gitkeep
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/nezhahq/nezha
2 |
3 | go 1.24.0
4 |
5 | require (
6 | github.com/appleboy/gin-jwt/v2 v2.10.3
7 | github.com/dustinkirkland/golang-petname v0.0.0-20240428194347-eebcea082ee0
8 | github.com/gin-contrib/pprof v1.5.3
9 | github.com/gin-gonic/gin v1.10.0
10 | github.com/go-viper/mapstructure/v2 v2.2.1
11 | github.com/goccy/go-json v0.10.5
12 | github.com/gorilla/websocket v1.5.3
13 | github.com/hashicorp/go-uuid v1.0.3
14 | github.com/jinzhu/copier v0.4.0
15 | github.com/knadh/koanf/maps v0.1.2
16 | github.com/knadh/koanf/providers/env v1.1.0
17 | github.com/knadh/koanf/providers/file v1.2.0
18 | github.com/knadh/koanf/v2 v2.2.0
19 | github.com/leonelquinteros/gotext v1.7.1
20 | github.com/libdns/cloudflare v0.2.1
21 | github.com/libdns/he v1.1.1
22 | github.com/libdns/libdns v1.0.0
23 | github.com/miekg/dns v1.1.65
24 | github.com/nezhahq/libdns-tencentcloud v0.0.0-20250501081622-bd293105845a
25 | github.com/ory/graceful v0.1.3
26 | github.com/oschwald/maxminddb-golang v1.13.1
27 | github.com/patrickmn/go-cache v2.1.0+incompatible
28 | github.com/robfig/cron/v3 v3.0.1
29 | github.com/swaggo/files v1.0.1
30 | github.com/swaggo/gin-swagger v1.6.0
31 | github.com/swaggo/swag v1.16.4
32 | github.com/tidwall/gjson v1.18.0
33 | golang.org/x/crypto v0.37.0
34 | golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0
35 | golang.org/x/net v0.39.0
36 | golang.org/x/oauth2 v0.29.0
37 | golang.org/x/sync v0.13.0
38 | google.golang.org/grpc v1.72.0
39 | google.golang.org/protobuf v1.36.6
40 | gorm.io/driver/sqlite v1.5.7
41 | gorm.io/gorm v1.26.0
42 | sigs.k8s.io/yaml v1.4.0
43 | )
44 |
45 | require (
46 | github.com/KyleBanks/depth v1.2.1 // indirect
47 | github.com/bytedance/sonic v1.13.2 // indirect
48 | github.com/bytedance/sonic/loader v0.2.4 // indirect
49 | github.com/cloudwego/base64x v0.1.5 // indirect
50 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
51 | github.com/fsnotify/fsnotify v1.9.0 // indirect
52 | github.com/gabriel-vasile/mimetype v1.4.9 // indirect
53 | github.com/gin-contrib/sse v1.1.0 // indirect
54 | github.com/go-openapi/jsonpointer v0.21.0 // indirect
55 | github.com/go-openapi/jsonreference v0.21.0 // indirect
56 | github.com/go-openapi/spec v0.21.0 // indirect
57 | github.com/go-openapi/swag v0.23.0 // indirect
58 | github.com/go-playground/locales v0.14.1 // indirect
59 | github.com/go-playground/universal-translator v0.18.1 // indirect
60 | github.com/go-playground/validator/v10 v10.26.0 // indirect
61 | github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
62 | github.com/jinzhu/inflection v1.0.0 // indirect
63 | github.com/jinzhu/now v1.1.5 // indirect
64 | github.com/josharian/intern v1.0.0 // indirect
65 | github.com/json-iterator/go v1.1.12 // indirect
66 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect
67 | github.com/leodido/go-urn v1.4.0 // indirect
68 | github.com/mailru/easyjson v0.9.0 // indirect
69 | github.com/mattn/go-isatty v0.0.20 // indirect
70 | github.com/mattn/go-sqlite3 v1.14.24 // indirect
71 | github.com/mitchellh/copystructure v1.2.0 // indirect
72 | github.com/mitchellh/reflectwalk v1.0.2 // indirect
73 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
74 | github.com/modern-go/reflect2 v1.0.2 // indirect
75 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect
76 | github.com/pkg/errors v0.9.1 // indirect
77 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
78 | github.com/tidwall/match v1.1.1 // indirect
79 | github.com/tidwall/pretty v1.2.1 // indirect
80 | github.com/tidwall/sjson v1.2.5 // indirect
81 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
82 | github.com/ugorji/go/codec v1.2.12 // indirect
83 | github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
84 | golang.org/x/arch v0.16.0 // indirect
85 | golang.org/x/mod v0.24.0 // indirect
86 | golang.org/x/sys v0.32.0 // indirect
87 | golang.org/x/text v0.24.0 // indirect
88 | golang.org/x/time v0.11.0 // indirect
89 | golang.org/x/tools v0.32.0 // indirect
90 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250428153025-10db94c68c34 // indirect
91 | gopkg.in/yaml.v3 v3.0.1 // indirect
92 | )
93 |
--------------------------------------------------------------------------------
/model/alertrule.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "slices"
5 |
6 | "github.com/goccy/go-json"
7 | "gorm.io/gorm"
8 | )
9 |
10 | const (
11 | ModeAlwaysTrigger = 0
12 | ModeOnetimeTrigger = 1
13 | )
14 |
15 | type AlertRule struct {
16 | Common
17 | Name string `json:"name"`
18 | RulesRaw string `json:"-"`
19 | Enable *bool `json:"enable,omitempty"`
20 | TriggerMode uint8 `gorm:"default:0" json:"trigger_mode"` // 触发模式: 0-始终触发(默认) 1-单次触发
21 | NotificationGroupID uint64 `json:"notification_group_id"` // 该报警规则所在的通知组
22 | FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
23 | RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
24 | Rules []*Rule `gorm:"-" json:"rules"`
25 | FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id
26 | RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id
27 | }
28 |
29 | func (r *AlertRule) BeforeSave(tx *gorm.DB) error {
30 | if data, err := json.Marshal(r.Rules); err != nil {
31 | return err
32 | } else {
33 | r.RulesRaw = string(data)
34 | }
35 | if data, err := json.Marshal(r.FailTriggerTasks); err != nil {
36 | return err
37 | } else {
38 | r.FailTriggerTasksRaw = string(data)
39 | }
40 | if data, err := json.Marshal(r.RecoverTriggerTasks); err != nil {
41 | return err
42 | } else {
43 | r.RecoverTriggerTasksRaw = string(data)
44 | }
45 | return nil
46 | }
47 |
48 | func (r *AlertRule) AfterFind(tx *gorm.DB) error {
49 | var err error
50 | if err = json.Unmarshal([]byte(r.RulesRaw), &r.Rules); err != nil {
51 | return err
52 | }
53 | if err = json.Unmarshal([]byte(r.FailTriggerTasksRaw), &r.FailTriggerTasks); err != nil {
54 | return err
55 | }
56 | if err = json.Unmarshal([]byte(r.RecoverTriggerTasksRaw), &r.RecoverTriggerTasks); err != nil {
57 | return err
58 | }
59 | return nil
60 | }
61 |
62 | func (r *AlertRule) Enabled() bool {
63 | return r.Enable != nil && *r.Enable
64 | }
65 |
66 | // Snapshot 对传入的Server进行该报警规则下所有type的检查 返回每项检查结果
67 | func (r *AlertRule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) []bool {
68 | point := make([]bool, len(r.Rules))
69 |
70 | for i, rule := range r.Rules {
71 | point[i] = rule.Snapshot(cycleTransferStats, server, db)
72 | }
73 | return point
74 | }
75 |
76 | // Check 传入包含当前报警规则下所有type检查结果 返回报警持续时间与是否通过报警检查(通过则返回true)
77 | func (r *AlertRule) Check(points [][]bool) (int, bool) {
78 | var hasPassedRule bool
79 | durations := make([]int, len(r.Rules))
80 |
81 | for ruleIndex, rule := range r.Rules {
82 | duration := int(rule.Duration)
83 | if rule.IsTransferDurationRule() {
84 | // 循环区间流量报警
85 | if durations[ruleIndex] < 1 {
86 | durations[ruleIndex] = 1
87 | }
88 | if hasPassedRule {
89 | continue
90 | }
91 | // 只要最后一次检查超出了规则范围 就认为检查未通过
92 | if len(points) > 0 && points[len(points)-1][ruleIndex] {
93 | hasPassedRule = true
94 | }
95 | } else if rule.IsOfflineRule() {
96 | // 离线报警,检查直到最后一次在线的离线采样点是否大于 duration
97 | if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
98 | continue
99 | }
100 | var fail int
101 | for _, point := range slices.Backward(points[len(points)-duration:]) {
102 | fail++
103 | if point[ruleIndex] {
104 | hasPassedRule = true
105 | break
106 | }
107 | }
108 | durations[ruleIndex] = fail
109 | continue
110 | } else {
111 | // 常规报警
112 | if hasPassedRule = boundCheck(len(points), duration, hasPassedRule); hasPassedRule {
113 | continue
114 | }
115 | if duration > durations[ruleIndex] {
116 | durations[ruleIndex] = duration
117 | }
118 | total, fail := duration, 0
119 | for timeTick := len(points) - duration; timeTick < len(points); timeTick++ {
120 | if !points[timeTick][ruleIndex] {
121 | fail++
122 | }
123 | }
124 | // 当70%以上的采样点未通过规则判断时 才认为当前检查未通过
125 | if fail*100/total <= 70 {
126 | hasPassedRule = true
127 | }
128 | }
129 | }
130 |
131 | // 仅当所有检查均未通过时 才触发告警
132 | return slices.Max(durations), hasPassedRule
133 | }
134 |
135 | func boundCheck(length, duration int, passed bool) bool {
136 | if passed {
137 | return true
138 | }
139 | // 如果采样点数量不足 则认为检查通过
140 | return length < duration
141 | }
142 |
--------------------------------------------------------------------------------
/model/alertrule_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type AlertRuleForm struct {
4 | Name string `json:"name" minLength:"1"`
5 | Rules []*Rule `json:"rules"`
6 | FailTriggerTasks []uint64 `json:"fail_trigger_tasks"` // 失败时触发的任务id
7 | RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks"` // 恢复时触发的任务id
8 | NotificationGroupID uint64 `json:"notification_group_id"`
9 | TriggerMode uint8 `json:"trigger_mode" default:"0"`
10 | Enable bool `json:"enable" validate:"optional"`
11 | }
12 |
--------------------------------------------------------------------------------
/model/api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | const (
4 | ApiErrorUnauthorized = 10001
5 | )
6 |
7 | type Oauth2LoginResponse struct {
8 | Redirect string `json:"redirect,omitempty"`
9 | }
10 |
11 | type Oauth2Callback struct {
12 | State string `json:"state,omitempty"`
13 | Code string `json:"code,omitempty"`
14 | }
15 |
16 | type LoginRequest struct {
17 | Username string `json:"username,omitempty"`
18 | Password string `json:"password,omitempty"`
19 | }
20 |
21 | type CommonResponse[T any] struct {
22 | Success bool `json:"success,omitempty"`
23 | Data T `json:"data,omitempty"`
24 | Error string `json:"error,omitempty"`
25 | }
26 |
27 | type PaginatedResponse[S ~[]E, E any] struct {
28 | Success bool `json:"success,omitempty"`
29 | Data *Value[S] `json:"data,omitempty"`
30 | Error string `json:"error,omitempty"`
31 | }
32 |
33 | type Value[T any] struct {
34 | Value T `json:"value,omitempty"`
35 | Pagination Pagination `json:"pagination,omitempty"`
36 | }
37 |
38 | type Pagination struct {
39 | Offset int `json:"offset,omitempty"`
40 | Limit int `json:"limit,omitempty"`
41 | Total int64 `json:"total,omitempty"`
42 | }
43 |
44 | type LoginResponse struct {
45 | Token string `json:"token,omitempty"`
46 | Expire string `json:"expire,omitempty"`
47 | }
48 |
--------------------------------------------------------------------------------
/model/common.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "cmp"
5 | "iter"
6 | "slices"
7 | "strconv"
8 | "strings"
9 | "time"
10 |
11 | "github.com/gin-gonic/gin"
12 | "github.com/nezhahq/nezha/pkg/utils"
13 | )
14 |
15 | const (
16 | CtxKeyAuthorizedUser = "ckau"
17 | CtxKeyRealIPStr = "ckri"
18 | )
19 |
20 | const (
21 | CacheKeyOauth2State = "cko2s::"
22 | )
23 |
24 | type CtxKeyRealIP struct{}
25 | type CtxKeyConnectingIP struct{}
26 |
27 | type Common struct {
28 | ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
29 | CreatedAt time.Time `gorm:"index;<-:create" json:"created_at,omitempty"`
30 | UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
31 |
32 | UserID uint64 `gorm:"index;default:0" json:"-"`
33 | }
34 |
35 | func (c *Common) GetID() uint64 {
36 | return c.ID
37 | }
38 |
39 | func (c *Common) GetUserID() uint64 {
40 | return c.UserID
41 | }
42 |
43 | func (c *Common) HasPermission(ctx *gin.Context) bool {
44 | auth, ok := ctx.Get(CtxKeyAuthorizedUser)
45 | if !ok {
46 | return false
47 | }
48 |
49 | user := *auth.(*User)
50 | if user.Role == RoleAdmin {
51 | return true
52 | }
53 |
54 | return user.ID == c.UserID
55 | }
56 |
57 | type CommonInterface interface {
58 | GetID() uint64
59 | GetUserID() uint64
60 | HasPermission(*gin.Context) bool
61 | }
62 |
63 | func FindByUserID[S ~[]E, E CommonInterface](s S, uid uint64) []uint64 {
64 | var list []uint64
65 | for _, v := range s {
66 | if v.GetUserID() == uid {
67 | list = append(list, v.GetID())
68 | }
69 | }
70 |
71 | return list
72 | }
73 |
74 | func SearchByIDCtx[S ~[]E, E CommonInterface](c *gin.Context, x S) S {
75 | return SearchByID(strings.SplitSeq(c.Query("id"), ","), x)
76 | }
77 |
78 | func SearchByID[S ~[]E, E CommonInterface](seq iter.Seq[string], x S) S {
79 | if hasPriorityList[E]() {
80 | return searchByIDPri(seq, x)
81 | }
82 |
83 | var s S
84 | for idStr := range seq {
85 | id, err := strconv.ParseUint(idStr, 10, 64)
86 | if err != nil {
87 | continue
88 | }
89 |
90 | s = appendBinarySearch(s, x, id)
91 | }
92 | return utils.IfOr(len(s) > 0, s, x)
93 | }
94 |
95 | func hasPriorityList[T CommonInterface]() bool {
96 | var class T
97 |
98 | switch any(class).(type) {
99 | case *Server:
100 | return true
101 | default:
102 | return false
103 | }
104 | }
105 |
106 | type splitter[S ~[]E, E CommonInterface] interface {
107 | // SplitList should split a sorted list into two separate lists:
108 | // The first list contains elements with a priority set (DisplayIndex != 0).
109 | // The second list contains elements without a priority set (DisplayIndex == 0).
110 | // The original slice is not modified. If no element without a priority is found, it returns nil.
111 | // Should be safe to use with a nil pointer.
112 | SplitList(x S) (S, S)
113 | }
114 |
115 | func searchByIDPri[S ~[]E, E CommonInterface](seq iter.Seq[string], x S) S {
116 | var class E
117 | split, ok := any(class).(splitter[S, E])
118 | if !ok {
119 | return x
120 | }
121 |
122 | plist, list2 := split.SplitList(x)
123 |
124 | var clist1, clist2 S
125 | for idStr := range seq {
126 | id, err := strconv.ParseUint(idStr, 10, 64)
127 | if err != nil {
128 | continue
129 | }
130 |
131 | clist1 = appendSearch(clist1, plist, id)
132 | clist2 = appendBinarySearch(clist2, list2, id)
133 | }
134 |
135 | l := slices.Concat(clist1, clist2)
136 | return utils.IfOr(len(l) > 0, l, x)
137 | }
138 |
139 | func appendBinarySearch[S ~[]E, E CommonInterface](x, y S, target uint64) S {
140 | if i, ok := slices.BinarySearchFunc(y, target, func(e E, t uint64) int {
141 | return cmp.Compare(e.GetID(), t)
142 | }); ok {
143 | x = append(x, y[i])
144 | }
145 | return x
146 | }
147 |
148 | func appendSearch[S ~[]E, E CommonInterface](x, y S, target uint64) S {
149 | if i := slices.IndexFunc(y, func(e E) bool {
150 | return e.GetID() == target
151 | }); i != -1 {
152 | x = append(x, y[i])
153 | }
154 |
155 | return x
156 | }
157 |
--------------------------------------------------------------------------------
/model/common_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "reflect"
5 | "slices"
6 | "testing"
7 | )
8 |
9 | func TestSearchByID(t *testing.T) {
10 | t.Run("WithoutPriorityList", func(t *testing.T) {
11 | list, exp := []*DDNSProfile{
12 | {Common: Common{ID: 1}},
13 | {Common: Common{ID: 2}},
14 | {Common: Common{ID: 3}},
15 | {Common: Common{ID: 4}},
16 | {Common: Common{ID: 5}},
17 | }, []*DDNSProfile{
18 | {Common: Common{ID: 4}},
19 | {Common: Common{ID: 1}},
20 | {Common: Common{ID: 3}},
21 | }
22 |
23 | searchList := slices.Values([]string{"4", "1", "3"})
24 | filtered := SearchByID(searchList, list)
25 | if !reflect.DeepEqual(filtered, exp) {
26 | t.Fatalf("expected %v, but got %v", exp, filtered)
27 | }
28 | })
29 |
30 | t.Run("WithPriorityTest", func(t *testing.T) {
31 | list, exp := []*Server{
32 | {Common: Common{ID: 5}, DisplayIndex: 2},
33 | {Common: Common{ID: 4}, DisplayIndex: 1},
34 | {Common: Common{ID: 1}},
35 | {Common: Common{ID: 2}},
36 | {Common: Common{ID: 3}},
37 | }, []*Server{
38 | {Common: Common{ID: 4}, DisplayIndex: 1},
39 | {Common: Common{ID: 5}, DisplayIndex: 2},
40 | {Common: Common{ID: 3}},
41 | }
42 |
43 | searchList := slices.Values([]string{"3", "4", "5"})
44 | filtered := SearchByID(searchList, list)
45 | if !reflect.DeepEqual(filtered, exp) {
46 | t.Fatalf("expected %v, but got %v", exp, filtered)
47 | }
48 | })
49 | }
50 |
--------------------------------------------------------------------------------
/model/config_test.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "os"
5 | "strings"
6 | "testing"
7 | )
8 |
9 | func TestReadConfig(t *testing.T) {
10 | t.Run("ReadEmptyConfig", func(t *testing.T) {
11 | file := newTempConfig(t, "")
12 | c := &Config{}
13 |
14 | if err := c.Read(file, nil); err != nil {
15 | t.Fatalf("read empty config failed: %v", err)
16 | }
17 |
18 | testFields := []struct {
19 | Name string
20 | Value any
21 | Cond bool
22 | }{
23 | {"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey != ""},
24 | {"user_template", c.UserTemplate, c.UserTemplate == "user-dist"},
25 | {"admin_template", c.AdminTemplate, c.AdminTemplate == "admin-dist"},
26 | {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey != ""},
27 | }
28 |
29 | for _, field := range testFields {
30 | if !field.Cond {
31 | t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
32 | }
33 | }
34 |
35 | os.Remove(file)
36 | })
37 |
38 | t.Run("ReadFile", func(t *testing.T) {
39 | const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
40 |
41 | var testFrontendTemplates = []FrontendTemplate{
42 | {Path: "um"},
43 | {Path: "am", IsAdmin: true},
44 | }
45 | file := newTempConfig(t, testCfg)
46 | c := &Config{}
47 |
48 | if err := c.Read(file, testFrontendTemplates); err != nil {
49 | t.Fatalf("read config failed: %v", err)
50 | }
51 |
52 | testFields := []struct {
53 | Name string
54 | Value any
55 | Cond bool
56 | }{
57 | {"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
58 | {"user_template", c.UserTemplate, c.UserTemplate == "um"},
59 | {"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
60 | {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
61 | {"site_name", c.SiteName, c.SiteName == "lowkick"},
62 | }
63 |
64 | for _, field := range testFields {
65 | if !field.Cond {
66 | t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
67 | }
68 | }
69 |
70 | os.Remove(file)
71 | })
72 |
73 | t.Run("ReadEnv", func(t *testing.T) {
74 | os.Setenv("NZ_JWTSECRETKEY", "test")
75 | os.Setenv("NZ_USERTEMPLATE", "um")
76 | os.Setenv("NZ_ADMINTEMPLATE", "am")
77 | os.Setenv("NZ_AGENTSECRETKEY", "none")
78 | os.Setenv("NZ_HTTPS_LISTENPORT", "9876")
79 |
80 | var testFrontendTemplates = []FrontendTemplate{
81 | {Path: "um"},
82 | {Path: "am", IsAdmin: true},
83 | }
84 | file := newTempConfig(t, "")
85 | c := &Config{}
86 |
87 | if err := c.Read(file, testFrontendTemplates); err != nil {
88 | t.Fatalf("read empty config failed: %v", err)
89 | }
90 |
91 | testFields := []struct {
92 | Name string
93 | Value any
94 | Cond bool
95 | }{
96 | {"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
97 | {"user_template", c.UserTemplate, c.UserTemplate == "um"},
98 | {"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
99 | {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
100 | {"https.listenport", c.HTTPS.ListenPort, c.HTTPS.ListenPort == 9876},
101 | }
102 |
103 | for _, field := range testFields {
104 | if !field.Cond {
105 | t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
106 | }
107 | }
108 |
109 | os.Remove(file)
110 | })
111 |
112 | t.Run("ReadEnvFile", func(t *testing.T) {
113 | os.Setenv("NZ_JWTSECRETKEY", "test1")
114 | os.Setenv("NZ_USERTEMPLATE", "um1")
115 | os.Setenv("NZ_ADMINTEMPLATE", "am1")
116 | os.Setenv("NZ_AGENTSECRETKEY", "none1")
117 | os.Setenv("NZ_SITENAME", "lowkick1")
118 |
119 | const testCfg = "jwt_secret_key: test\nuser_template: um\nadmin_template: am\nagent_secret_key: none\nsite_name: lowkick"
120 |
121 | var testFrontendTemplates = []FrontendTemplate{
122 | {Path: "um"},
123 | {Path: "am", IsAdmin: true},
124 | }
125 | file := newTempConfig(t, testCfg)
126 | c := &Config{}
127 |
128 | if err := c.Read(file, testFrontendTemplates); err != nil {
129 | t.Fatalf("read empty config failed: %v", err)
130 | }
131 |
132 | testFields := []struct {
133 | Name string
134 | Value any
135 | Cond bool
136 | }{
137 | {"jwt_secret_key", c.JWTSecretKey, c.JWTSecretKey == "test"},
138 | {"user_template", c.UserTemplate, c.UserTemplate == "um"},
139 | {"admin_template", c.AdminTemplate, c.AdminTemplate == "am"},
140 | {"agent_secret_key", c.AgentSecretKey, c.AgentSecretKey == "none"},
141 | {"site_name", c.SiteName, c.SiteName == "lowkick"},
142 | }
143 |
144 | for _, field := range testFields {
145 | if !field.Cond {
146 | t.Fatalf("%s did not passed check, value: %v", field.Name, field.Value)
147 | }
148 | }
149 |
150 | os.Remove(file)
151 | })
152 | }
153 |
154 | func newTempConfig(t *testing.T, cfg string) string {
155 | t.Helper()
156 |
157 | file, err := os.CreateTemp(os.TempDir(), "nezha-test-config-*.yml")
158 | if err != nil {
159 | t.Fatalf("create temp file failed: %v", err)
160 | }
161 | defer file.Close()
162 |
163 | _, err = file.ReadFrom(strings.NewReader(cfg))
164 | if err != nil {
165 | t.Fatalf("write to temp file failed: %v", err)
166 | }
167 |
168 | return file.Name()
169 | }
170 |
--------------------------------------------------------------------------------
/model/cron.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/goccy/go-json"
7 | "github.com/robfig/cron/v3"
8 | "gorm.io/gorm"
9 | )
10 |
11 | const (
12 | CronCoverIgnoreAll = iota
13 | CronCoverAll
14 | CronCoverAlertTrigger
15 | CronTypeCronTask = 0
16 | CronTypeTriggerTask = 1
17 | )
18 |
19 | type Cron struct {
20 | Common
21 | Name string `json:"name"`
22 | TaskType uint8 `gorm:"default:0" json:"task_type"` // 0:计划任务 1:触发任务
23 | Scheduler string `json:"scheduler"` // 分钟 小时 天 月 星期
24 | Command string `json:"command,omitempty"`
25 | Servers []uint64 `gorm:"-" json:"servers"`
26 | PushSuccessful bool `json:"push_successful,omitempty"` // 推送成功的通知
27 | NotificationGroupID uint64 `json:"notification_group_id"` // 指定通知方式的分组
28 | LastExecutedAt time.Time `json:"last_executed_at,omitempty"` // 最后一次执行时间
29 | LastResult bool `json:"last_result,omitempty"` // 最后一次执行结果
30 | Cover uint8 `json:"cover"` // 计划任务覆盖范围 (0:仅覆盖特定服务器 1:仅忽略特定服务器 2:由触发该计划任务的服务器执行)
31 |
32 | CronJobID cron.EntryID `gorm:"-" json:"cron_job_id,omitempty"`
33 | ServersRaw string `json:"-"`
34 | }
35 |
36 | func (c *Cron) BeforeSave(tx *gorm.DB) error {
37 | if data, err := json.Marshal(c.Servers); err != nil {
38 | return err
39 | } else {
40 | c.ServersRaw = string(data)
41 | }
42 | return nil
43 | }
44 |
45 | func (c *Cron) AfterFind(tx *gorm.DB) error {
46 | return json.Unmarshal([]byte(c.ServersRaw), &c.Servers)
47 | }
48 |
--------------------------------------------------------------------------------
/model/cron_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type CronForm struct {
4 | TaskType uint8 `json:"task_type,omitempty" default:"0"` // 0:计划任务 1:触发任务
5 | Name string `json:"name,omitempty" minLength:"1"`
6 | Scheduler string `json:"scheduler,omitempty"`
7 | Command string `json:"command,omitempty" validate:"optional"`
8 | Servers []uint64 `json:"servers,omitempty"`
9 | Cover uint8 `json:"cover,omitempty" default:"0"`
10 | PushSuccessful bool `json:"push_successful,omitempty" validate:"optional"`
11 | NotificationGroupID uint64 `json:"notification_group_id,omitempty"`
12 | }
13 |
--------------------------------------------------------------------------------
/model/ddns.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "github.com/goccy/go-json"
5 | "gorm.io/gorm"
6 | )
7 |
8 | const (
9 | ProviderDummy = "dummy"
10 | ProviderWebHook = "webhook"
11 | ProviderCloudflare = "cloudflare"
12 | ProviderTencentCloud = "tencentcloud"
13 | ProviderHE = "he"
14 | )
15 |
16 | var ProviderList = [...]string{
17 | ProviderDummy, ProviderWebHook, ProviderCloudflare, ProviderTencentCloud, ProviderHE,
18 | }
19 |
20 | type DDNSProfile struct {
21 | Common
22 | EnableIPv4 *bool `json:"enable_ipv4,omitempty"`
23 | EnableIPv6 *bool `json:"enable_ipv6,omitempty"`
24 | MaxRetries uint64 `json:"max_retries"`
25 | Name string `json:"name"`
26 | Provider string `json:"provider"`
27 | AccessID string `json:"access_id,omitempty"`
28 | AccessSecret string `json:"access_secret,omitempty"`
29 | WebhookURL string `json:"webhook_url,omitempty"`
30 | WebhookMethod uint8 `json:"webhook_method,omitempty"`
31 | WebhookRequestType uint8 `json:"webhook_request_type,omitempty"`
32 | WebhookRequestBody string `json:"webhook_request_body,omitempty"`
33 | WebhookHeaders string `json:"webhook_headers,omitempty"`
34 | Domains []string `json:"domains" gorm:"-"`
35 | DomainsRaw string `json:"-"`
36 | }
37 |
38 | func (d *DDNSProfile) TableName() string {
39 | return "ddns"
40 | }
41 |
42 | func (d *DDNSProfile) BeforeSave(tx *gorm.DB) error {
43 | if data, err := json.Marshal(d.Domains); err != nil {
44 | return err
45 | } else {
46 | d.DomainsRaw = string(data)
47 | }
48 | return nil
49 | }
50 |
51 | func (d *DDNSProfile) AfterFind(tx *gorm.DB) error {
52 | return json.Unmarshal([]byte(d.DomainsRaw), &d.Domains)
53 | }
54 |
--------------------------------------------------------------------------------
/model/ddns_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type DDNSForm struct {
4 | MaxRetries uint64 `json:"max_retries,omitempty" default:"3"`
5 | EnableIPv4 bool `json:"enable_ipv4,omitempty" validate:"optional"`
6 | EnableIPv6 bool `json:"enable_ipv6,omitempty" validate:"optional"`
7 | Name string `json:"name,omitempty" minLength:"1"`
8 | Provider string `json:"provider,omitempty"`
9 | Domains []string `json:"domains,omitempty"`
10 | AccessID string `json:"access_id,omitempty" validate:"optional"`
11 | AccessSecret string `json:"access_secret,omitempty" validate:"optional"`
12 | WebhookURL string `json:"webhook_url,omitempty" validate:"optional"`
13 | WebhookMethod uint8 `json:"webhook_method,omitempty" validate:"optional" default:"1"`
14 | WebhookRequestType uint8 `json:"webhook_request_type,omitempty" validate:"optional" default:"1"`
15 | WebhookRequestBody string `json:"webhook_request_body,omitempty" validate:"optional"`
16 | WebhookHeaders string `json:"webhook_headers,omitempty" validate:"optional"`
17 | }
18 |
--------------------------------------------------------------------------------
/model/fm_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type CreateFMResponse struct {
4 | SessionID string `json:"session_id,omitempty"`
5 | }
6 |
--------------------------------------------------------------------------------
/model/host.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "fmt"
5 |
6 | pb "github.com/nezhahq/nezha/proto"
7 | )
8 |
9 | const (
10 | _ = iota
11 |
12 | MTReportHostState
13 | )
14 |
15 | type SensorTemperature struct {
16 | Name string
17 | Temperature float64
18 | }
19 |
20 | type HostState struct {
21 | CPU float64 `json:"cpu,omitempty"`
22 | MemUsed uint64 `json:"mem_used,omitempty"`
23 | SwapUsed uint64 `json:"swap_used,omitempty"`
24 | DiskUsed uint64 `json:"disk_used,omitempty"`
25 | NetInTransfer uint64 `json:"net_in_transfer,omitempty"`
26 | NetOutTransfer uint64 `json:"net_out_transfer,omitempty"`
27 | NetInSpeed uint64 `json:"net_in_speed,omitempty"`
28 | NetOutSpeed uint64 `json:"net_out_speed,omitempty"`
29 | Uptime uint64 `json:"uptime,omitempty"`
30 | Load1 float64 `json:"load_1,omitempty"`
31 | Load5 float64 `json:"load_5,omitempty"`
32 | Load15 float64 `json:"load_15,omitempty"`
33 | TcpConnCount uint64 `json:"tcp_conn_count,omitempty"`
34 | UdpConnCount uint64 `json:"udp_conn_count,omitempty"`
35 | ProcessCount uint64 `json:"process_count,omitempty"`
36 | Temperatures []SensorTemperature `json:"temperatures,omitempty"`
37 | GPU []float64 `json:"gpu,omitempty"`
38 | }
39 |
40 | func (s *HostState) PB() *pb.State {
41 | var ts []*pb.State_SensorTemperature
42 | for _, t := range s.Temperatures {
43 | ts = append(ts, &pb.State_SensorTemperature{
44 | Name: t.Name,
45 | Temperature: t.Temperature,
46 | })
47 | }
48 |
49 | return &pb.State{
50 | Cpu: s.CPU,
51 | MemUsed: s.MemUsed,
52 | SwapUsed: s.SwapUsed,
53 | DiskUsed: s.DiskUsed,
54 | NetInTransfer: s.NetInTransfer,
55 | NetOutTransfer: s.NetOutTransfer,
56 | NetInSpeed: s.NetInSpeed,
57 | NetOutSpeed: s.NetOutSpeed,
58 | Uptime: s.Uptime,
59 | Load1: s.Load1,
60 | Load5: s.Load5,
61 | Load15: s.Load15,
62 | TcpConnCount: s.TcpConnCount,
63 | UdpConnCount: s.UdpConnCount,
64 | ProcessCount: s.ProcessCount,
65 | Temperatures: ts,
66 | Gpu: s.GPU,
67 | }
68 | }
69 |
70 | func PB2State(s *pb.State) HostState {
71 | var ts []SensorTemperature
72 | for _, t := range s.GetTemperatures() {
73 | ts = append(ts, SensorTemperature{
74 | Name: t.GetName(),
75 | Temperature: t.GetTemperature(),
76 | })
77 | }
78 |
79 | return HostState{
80 | CPU: s.GetCpu(),
81 | MemUsed: s.GetMemUsed(),
82 | SwapUsed: s.GetSwapUsed(),
83 | DiskUsed: s.GetDiskUsed(),
84 | NetInTransfer: s.GetNetInTransfer(),
85 | NetOutTransfer: s.GetNetOutTransfer(),
86 | NetInSpeed: s.GetNetInSpeed(),
87 | NetOutSpeed: s.GetNetOutSpeed(),
88 | Uptime: s.GetUptime(),
89 | Load1: s.GetLoad1(),
90 | Load5: s.GetLoad5(),
91 | Load15: s.GetLoad15(),
92 | TcpConnCount: s.GetTcpConnCount(),
93 | UdpConnCount: s.GetUdpConnCount(),
94 | ProcessCount: s.GetProcessCount(),
95 | Temperatures: ts,
96 | GPU: s.GetGpu(),
97 | }
98 | }
99 |
100 | type Host struct {
101 | Platform string `json:"platform,omitempty"`
102 | PlatformVersion string `json:"platform_version,omitempty"`
103 | CPU []string `json:"cpu,omitempty"`
104 | MemTotal uint64 `json:"mem_total,omitempty"`
105 | DiskTotal uint64 `json:"disk_total,omitempty"`
106 | SwapTotal uint64 `json:"swap_total,omitempty"`
107 | Arch string `json:"arch,omitempty"`
108 | Virtualization string `json:"virtualization,omitempty"`
109 | BootTime uint64 `json:"boot_time,omitempty"`
110 | Version string `json:"version,omitempty"`
111 | GPU []string `json:"gpu,omitempty"`
112 | }
113 |
114 | func (h *Host) PB() *pb.Host {
115 | return &pb.Host{
116 | Platform: h.Platform,
117 | PlatformVersion: h.PlatformVersion,
118 | Cpu: h.CPU,
119 | MemTotal: h.MemTotal,
120 | DiskTotal: h.DiskTotal,
121 | SwapTotal: h.SwapTotal,
122 | Arch: h.Arch,
123 | Virtualization: h.Virtualization,
124 | BootTime: h.BootTime,
125 | Version: h.Version,
126 | Gpu: h.GPU,
127 | }
128 | }
129 |
130 | // Filter returns a new instance of Host with some fields redacted.
131 | func (h *Host) Filter() *Host {
132 | return &Host{
133 | Platform: h.Platform,
134 | CPU: h.CPU,
135 | MemTotal: h.MemTotal,
136 | DiskTotal: h.DiskTotal,
137 | SwapTotal: h.SwapTotal,
138 | Arch: h.Arch,
139 | Virtualization: h.Virtualization,
140 | BootTime: h.BootTime,
141 | GPU: h.GPU,
142 | }
143 | }
144 |
145 | func PB2Host(h *pb.Host) Host {
146 | return Host{
147 | Platform: h.GetPlatform(),
148 | PlatformVersion: h.GetPlatformVersion(),
149 | CPU: h.GetCpu(),
150 | MemTotal: h.GetMemTotal(),
151 | DiskTotal: h.GetDiskTotal(),
152 | SwapTotal: h.GetSwapTotal(),
153 | Arch: h.GetArch(),
154 | Virtualization: h.GetVirtualization(),
155 | BootTime: h.GetBootTime(),
156 | Version: h.GetVersion(),
157 | GPU: h.GetGpu(),
158 | }
159 | }
160 |
161 | type IP struct {
162 | IPv4Addr string `json:"ipv4_addr,omitempty"`
163 | IPv6Addr string `json:"ipv6_addr,omitempty"`
164 | }
165 |
166 | func (p *IP) Join() string {
167 | if p.IPv4Addr != "" && p.IPv6Addr != "" {
168 | return fmt.Sprintf("%s/%s", p.IPv4Addr, p.IPv6Addr)
169 | } else if p.IPv4Addr != "" {
170 | return p.IPv4Addr
171 | }
172 | return p.IPv6Addr
173 | }
174 |
175 | type GeoIP struct {
176 | IP IP `json:"ip,omitempty"`
177 | CountryCode string `json:"country_code,omitempty"`
178 | }
179 |
180 | func PB2GeoIP(p *pb.GeoIP) GeoIP {
181 | pbIP := p.GetIp()
182 | return GeoIP{
183 | IP: IP{
184 | IPv4Addr: pbIP.GetIpv4(),
185 | IPv6Addr: pbIP.GetIpv6(),
186 | },
187 | }
188 | }
189 |
--------------------------------------------------------------------------------
/model/nat.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type NAT struct {
4 | Common
5 | Enabled bool `json:"enabled"`
6 | Name string `json:"name"`
7 | ServerID uint64 `json:"server_id"`
8 | Host string `json:"host"`
9 | Domain string `json:"domain" gorm:"unique"`
10 | }
11 |
--------------------------------------------------------------------------------
/model/nat_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type NATForm struct {
4 | Name string `json:"name,omitempty" minLength:"1"`
5 | Enabled bool `json:"enabled,omitempty"`
6 | ServerID uint64 `json:"server_id,omitempty"`
7 | Host string `json:"host,omitempty"`
8 | Domain string `json:"domain,omitempty"`
9 | }
10 |
--------------------------------------------------------------------------------
/model/notification_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type NotificationForm struct {
4 | Name string `json:"name,omitempty" minLength:"1"`
5 | URL string `json:"url,omitempty"`
6 | RequestMethod uint8 `json:"request_method,omitempty"`
7 | RequestType uint8 `json:"request_type,omitempty"`
8 | RequestHeader string `json:"request_header,omitempty"`
9 | RequestBody string `json:"request_body,omitempty"`
10 | VerifyTLS bool `json:"verify_tls,omitempty" validate:"optional"`
11 | SkipCheck bool `json:"skip_check,omitempty" validate:"optional"`
12 | }
13 |
--------------------------------------------------------------------------------
/model/notification_group.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type NotificationGroup struct {
4 | Common
5 | Name string `json:"name"`
6 | }
7 |
--------------------------------------------------------------------------------
/model/notification_group_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type NotificationGroupForm struct {
4 | Name string `json:"name" minLength:"1"`
5 | Notifications []uint64 `json:"notifications"`
6 | }
7 |
8 | type NotificationGroupResponseItem struct {
9 | Group NotificationGroup `json:"group"`
10 | Notifications []uint64 `json:"notifications"`
11 | }
12 |
--------------------------------------------------------------------------------
/model/notification_group_notification.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type NotificationGroupNotification struct {
4 | Common
5 | NotificationGroupID uint64 `json:"notification_group_id" gorm:"uniqueIndex:idx_notification_group_notification"`
6 | NotificationID uint64 `json:"notification_id" gorm:"uniqueIndex:idx_notification_group_notification"`
7 | }
8 |
--------------------------------------------------------------------------------
/model/oauth2bind.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Oauth2Bind struct {
4 | Common
5 |
6 | UserID uint64 `gorm:"uniqueIndex:u_p_o" json:"user_id,omitempty"`
7 | Provider string `gorm:"uniqueIndex:u_p_o" json:"provider,omitempty"`
8 | OpenID string `gorm:"uniqueIndex:u_p_o" json:"open_id,omitempty"`
9 | }
10 |
11 | type Oauth2LoginType uint8
12 |
13 | const (
14 | _ Oauth2LoginType = iota
15 | RTypeLogin
16 | RTypeBind
17 | )
18 |
19 | type Oauth2State struct {
20 | Action Oauth2LoginType
21 | Provider string
22 | State string
23 | RedirectURL string
24 | }
25 |
--------------------------------------------------------------------------------
/model/oauth2config.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "golang.org/x/oauth2"
5 | )
6 |
7 | type Oauth2Config struct {
8 | ClientID string `koanf:"client_id" json:"client_id,omitempty"`
9 | ClientSecret string `koanf:"client_secret" json:"client_secret,omitempty"`
10 | Endpoint Oauth2Endpoint `koanf:"endpoint" json:"endpoint,omitempty"`
11 | Scopes []string `koanf:"scopes" json:"scopes,omitempty"`
12 |
13 | UserInfoURL string `koanf:"user_info_url" json:"user_info_url,omitempty"`
14 | UserIDPath string `koanf:"user_id_path" json:"user_id_path,omitempty"`
15 | }
16 |
17 | type Oauth2Endpoint struct {
18 | AuthURL string `koanf:"auth_url" json:"auth_url,omitempty"`
19 | TokenURL string `koanf:"token_url" json:"token_url,omitempty"`
20 | }
21 |
22 | func (c *Oauth2Config) Setup(redirectURL string) *oauth2.Config {
23 | return &oauth2.Config{
24 | ClientID: c.ClientID,
25 | ClientSecret: c.ClientSecret,
26 | Endpoint: oauth2.Endpoint{
27 | AuthURL: c.Endpoint.AuthURL,
28 | TokenURL: c.Endpoint.TokenURL,
29 | },
30 | RedirectURL: redirectURL,
31 | Scopes: c.Scopes,
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/model/server.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "log"
5 | "slices"
6 | "time"
7 |
8 | "github.com/goccy/go-json"
9 | "gorm.io/gorm"
10 |
11 | pb "github.com/nezhahq/nezha/proto"
12 | )
13 |
14 | type Server struct {
15 | Common
16 |
17 | Name string `json:"name"`
18 | UUID string `json:"uuid,omitempty" gorm:"unique"`
19 | Note string `json:"note,omitempty"` // 管理员可见备注
20 | PublicNote string `json:"public_note,omitempty"` // 公开备注
21 | DisplayIndex int `json:"display_index"` // 展示排序,越大越靠前
22 | HideForGuest bool `json:"hide_for_guest,omitempty"` // 对游客隐藏
23 | EnableDDNS bool `json:"enable_ddns,omitempty"` // 启用DDNS
24 | DDNSProfilesRaw string `gorm:"default:'[]';column:ddns_profiles_raw" json:"-"`
25 | OverrideDDNSDomainsRaw string `gorm:"default:'{}';column:override_ddns_domains_raw" json:"-"`
26 |
27 | DDNSProfiles []uint64 `gorm:"-" json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
28 | OverrideDDNSDomains map[uint64][]string `gorm:"-" json:"override_ddns_domains,omitempty" validate:"optional"`
29 |
30 | Host *Host `gorm:"-" json:"host,omitempty"`
31 | State *HostState `gorm:"-" json:"state,omitempty"`
32 | GeoIP *GeoIP `gorm:"-" json:"geoip,omitempty"`
33 | LastActive time.Time `gorm:"-" json:"last_active,omitempty"`
34 |
35 | TaskStream pb.NezhaService_RequestTaskServer `gorm:"-" json:"-"`
36 | ConfigCache chan any `gorm:"-" json:"-"`
37 |
38 | PrevTransferInSnapshot uint64 `gorm:"-" json:"-"` // 上次数据点时的入站使用量
39 | PrevTransferOutSnapshot uint64 `gorm:"-" json:"-"` // 上次数据点时的出站使用量
40 | }
41 |
42 | func InitServer(s *Server) {
43 | s.Host = &Host{}
44 | s.State = &HostState{}
45 | s.GeoIP = &GeoIP{}
46 | s.ConfigCache = make(chan any, 1)
47 | }
48 |
49 | func (s *Server) CopyFromRunningServer(old *Server) {
50 | s.Host = old.Host
51 | s.State = old.State
52 | s.GeoIP = old.GeoIP
53 | s.LastActive = old.LastActive
54 | s.TaskStream = old.TaskStream
55 | s.ConfigCache = old.ConfigCache
56 | s.PrevTransferInSnapshot = old.PrevTransferInSnapshot
57 | s.PrevTransferOutSnapshot = old.PrevTransferOutSnapshot
58 | }
59 |
60 | func (s *Server) AfterFind(tx *gorm.DB) error {
61 | if s.DDNSProfilesRaw != "" {
62 | if err := json.Unmarshal([]byte(s.DDNSProfilesRaw), &s.DDNSProfiles); err != nil {
63 | log.Println("NEZHA>> Server.AfterFind:", err)
64 | return nil
65 | }
66 | }
67 | if s.OverrideDDNSDomainsRaw != "" {
68 | if err := json.Unmarshal([]byte(s.OverrideDDNSDomainsRaw), &s.OverrideDDNSDomains); err != nil {
69 | log.Println("NEZHA>> Server.AfterFind:", err)
70 | return nil
71 | }
72 | }
73 | return nil
74 | }
75 |
76 | func (s *Server) SplitList(x []*Server) ([]*Server, []*Server) {
77 | pri := func(s *Server) bool {
78 | return s.DisplayIndex == 0
79 | }
80 |
81 | i := slices.IndexFunc(x, pri)
82 | if i == -1 {
83 | return nil, x
84 | }
85 |
86 | return x[:i], x[i:]
87 | }
88 |
--------------------------------------------------------------------------------
/model/server_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "time"
4 |
5 | type StreamServer struct {
6 | ID uint64 `json:"id,omitempty"`
7 | Name string `json:"name,omitempty"`
8 | PublicNote string `json:"public_note,omitempty"` // 公开备注,只第一个数据包有值
9 | DisplayIndex int `json:"display_index,omitempty"` // 展示排序,越大越靠前
10 |
11 | Host *Host `json:"host,omitempty"`
12 | State *HostState `json:"state,omitempty"`
13 | CountryCode string `json:"country_code,omitempty"`
14 | LastActive time.Time `json:"last_active,omitempty"`
15 | }
16 |
17 | type StreamServerData struct {
18 | Now int64 `json:"now,omitempty"`
19 | Online int `json:"online,omitempty"`
20 | Servers []StreamServer `json:"servers,omitempty"`
21 | }
22 |
23 | type ServerForm struct {
24 | Name string `json:"name,omitempty"`
25 | Note string `json:"note,omitempty" validate:"optional"` // 管理员可见备注
26 | PublicNote string `json:"public_note,omitempty" validate:"optional"` // 公开备注
27 | DisplayIndex int `json:"display_index,omitempty" default:"0"` // 展示排序,越大越靠前
28 | HideForGuest bool `json:"hide_for_guest,omitempty" validate:"optional"` // 对游客隐藏
29 | EnableDDNS bool `json:"enable_ddns,omitempty" validate:"optional"` // 启用DDNS
30 | DDNSProfiles []uint64 `json:"ddns_profiles,omitempty" validate:"optional"` // DDNS配置
31 | OverrideDDNSDomains map[uint64][]string `json:"override_ddns_domains,omitempty" validate:"optional"`
32 | }
33 |
34 | type ServerConfigForm struct {
35 | Servers []uint64 `json:"servers,omitempty"`
36 | Config string `json:"config,omitempty"`
37 | }
38 |
39 | type ServerTaskResponse struct {
40 | Success []uint64 `json:"success,omitempty" validate:"optional"`
41 | Failure []uint64 `json:"failure,omitempty" validate:"optional"`
42 | Offline []uint64 `json:"offline,omitempty" validate:"optional"`
43 | }
44 |
--------------------------------------------------------------------------------
/model/server_group.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type ServerGroup struct {
4 | Common
5 |
6 | Name string `json:"name"`
7 | }
8 |
--------------------------------------------------------------------------------
/model/server_group_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type ServerGroupForm struct {
4 | Name string `json:"name" minLength:"1"`
5 | Servers []uint64 `json:"servers"`
6 | }
7 |
8 | type ServerGroupResponseItem struct {
9 | Group ServerGroup `json:"group"`
10 | Servers []uint64 `json:"servers"`
11 | }
12 |
--------------------------------------------------------------------------------
/model/server_group_server.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type ServerGroupServer struct {
4 | Common
5 | ServerGroupId uint64 `json:"server_group_id" gorm:"uniqueIndex:idx_server_group_server"`
6 | ServerId uint64 `json:"server_id" gorm:"uniqueIndex:idx_server_group_server"`
7 | }
8 |
--------------------------------------------------------------------------------
/model/service.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "fmt"
5 | "log"
6 |
7 | "github.com/goccy/go-json"
8 | "github.com/robfig/cron/v3"
9 | "gorm.io/gorm"
10 |
11 | pb "github.com/nezhahq/nezha/proto"
12 | )
13 |
14 | const (
15 | _ = iota
16 | TaskTypeHTTPGet
17 | TaskTypeICMPPing
18 | TaskTypeTCPPing
19 | TaskTypeCommand
20 | TaskTypeTerminal
21 | TaskTypeUpgrade
22 | TaskTypeKeepalive
23 | TaskTypeTerminalGRPC
24 | TaskTypeNAT
25 | TaskTypeReportHostInfoDeprecated
26 | TaskTypeFM
27 | TaskTypeReportConfig
28 | TaskTypeApplyConfig
29 | )
30 |
31 | type TerminalTask struct {
32 | StreamID string
33 | }
34 |
35 | type TaskNAT struct {
36 | StreamID string
37 | Host string
38 | }
39 |
40 | type TaskFM struct {
41 | StreamID string
42 | }
43 |
44 | const (
45 | ServiceCoverAll = iota
46 | ServiceCoverIgnoreAll
47 | )
48 |
49 | type Service struct {
50 | Common
51 | Name string `json:"name"`
52 | Type uint8 `json:"type"`
53 | Target string `json:"target"`
54 | SkipServersRaw string `json:"-"`
55 | Duration uint64 `json:"duration"`
56 | Notify bool `json:"notify,omitempty"`
57 | NotificationGroupID uint64 `json:"notification_group_id"` // 当前服务监控所属的通知组 ID
58 | Cover uint8 `json:"cover"`
59 |
60 | EnableTriggerTask bool `gorm:"default: false" json:"enable_trigger_task,omitempty"`
61 | EnableShowInService bool `gorm:"default: false" json:"enable_show_in_service,omitempty"`
62 | FailTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
63 | RecoverTriggerTasksRaw string `gorm:"default:'[]'" json:"-"`
64 |
65 | FailTriggerTasks []uint64 `gorm:"-" json:"fail_trigger_tasks"` // 失败时执行的触发任务id
66 | RecoverTriggerTasks []uint64 `gorm:"-" json:"recover_trigger_tasks"` // 恢复时执行的触发任务id
67 |
68 | MinLatency float32 `json:"min_latency"`
69 | MaxLatency float32 `json:"max_latency"`
70 | LatencyNotify bool `json:"latency_notify,omitempty"`
71 |
72 | SkipServers map[uint64]bool `gorm:"-" json:"skip_servers"`
73 | CronJobID cron.EntryID `gorm:"-" json:"-"`
74 | }
75 |
76 | func (m *Service) PB() *pb.Task {
77 | return &pb.Task{
78 | Id: m.ID,
79 | Type: uint64(m.Type),
80 | Data: m.Target,
81 | }
82 | }
83 |
84 | // CronSpec 返回服务监控请求间隔对应的 cron 表达式
85 | func (m *Service) CronSpec() string {
86 | if m.Duration == 0 {
87 | // 默认间隔 30 秒
88 | m.Duration = 30
89 | }
90 | return fmt.Sprintf("@every %ds", m.Duration)
91 | }
92 |
93 | func (m *Service) BeforeSave(tx *gorm.DB) error {
94 | if data, err := json.Marshal(m.SkipServers); err != nil {
95 | return err
96 | } else {
97 | m.SkipServersRaw = string(data)
98 | }
99 | if data, err := json.Marshal(m.FailTriggerTasks); err != nil {
100 | return err
101 | } else {
102 | m.FailTriggerTasksRaw = string(data)
103 | }
104 | if data, err := json.Marshal(m.RecoverTriggerTasks); err != nil {
105 | return err
106 | } else {
107 | m.RecoverTriggerTasksRaw = string(data)
108 | }
109 | return nil
110 | }
111 |
112 | func (m *Service) AfterFind(tx *gorm.DB) error {
113 | m.SkipServers = make(map[uint64]bool)
114 | if err := json.Unmarshal([]byte(m.SkipServersRaw), &m.SkipServers); err != nil {
115 | log.Println("NEZHA>> Service.AfterFind:", err)
116 | return nil
117 | }
118 |
119 | // 加载触发任务列表
120 | if err := json.Unmarshal([]byte(m.FailTriggerTasksRaw), &m.FailTriggerTasks); err != nil {
121 | return err
122 | }
123 | if err := json.Unmarshal([]byte(m.RecoverTriggerTasksRaw), &m.RecoverTriggerTasks); err != nil {
124 | return err
125 | }
126 |
127 | return nil
128 | }
129 |
130 | // IsServiceSentinelNeeded 判断该任务类型是否需要进行服务监控 需要则返回true
131 | func IsServiceSentinelNeeded(t uint64) bool {
132 | switch t {
133 | case TaskTypeCommand, TaskTypeTerminalGRPC, TaskTypeUpgrade,
134 | TaskTypeKeepalive, TaskTypeNAT, TaskTypeFM,
135 | TaskTypeReportConfig, TaskTypeApplyConfig:
136 | return false
137 | default:
138 | return true
139 | }
140 | }
141 |
--------------------------------------------------------------------------------
/model/service_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import "time"
4 |
5 | type ServiceForm struct {
6 | Name string `json:"name,omitempty" minLength:"1"`
7 | Target string `json:"target,omitempty"`
8 | Type uint8 `json:"type,omitempty"`
9 | Cover uint8 `json:"cover,omitempty"`
10 | Notify bool `json:"notify,omitempty" validate:"optional"`
11 | Duration uint64 `json:"duration,omitempty"`
12 | MinLatency float32 `json:"min_latency,omitempty" default:"0.0"`
13 | MaxLatency float32 `json:"max_latency,omitempty" default:"0.0"`
14 | LatencyNotify bool `json:"latency_notify,omitempty" validate:"optional"`
15 | EnableTriggerTask bool `json:"enable_trigger_task,omitempty" validate:"optional"`
16 | EnableShowInService bool `json:"enable_show_in_service,omitempty" validate:"optional"`
17 | FailTriggerTasks []uint64 `json:"fail_trigger_tasks,omitempty"`
18 | RecoverTriggerTasks []uint64 `json:"recover_trigger_tasks,omitempty"`
19 | SkipServers map[uint64]bool `json:"skip_servers,omitempty"`
20 | NotificationGroupID uint64 `json:"notification_group_id,omitempty"`
21 | }
22 |
23 | type ServiceResponseItem struct {
24 | ServiceName string `json:"service_name,omitempty"`
25 | CurrentUp uint64 `json:"current_up"`
26 | CurrentDown uint64 `json:"current_down"`
27 | TotalUp uint64 `json:"total_up"`
28 | TotalDown uint64 `json:"total_down"`
29 | Delay *[30]float32 `json:"delay,omitempty"`
30 | Up *[30]uint64 `json:"up,omitempty"`
31 | Down *[30]uint64 `json:"down,omitempty"`
32 | }
33 |
34 | func (r ServiceResponseItem) TotalUptime() float32 {
35 | if r.TotalUp+r.TotalDown == 0 {
36 | return 0
37 | }
38 | return float32(r.TotalUp) / (float32(r.TotalUp + r.TotalDown)) * 100
39 | }
40 |
41 | type CycleTransferStats struct {
42 | Name string `json:"name"`
43 | From time.Time `json:"from"`
44 | To time.Time `json:"to"`
45 | Max uint64 `json:"max"`
46 | Min uint64 `json:"min"`
47 | ServerName map[uint64]string `json:"server_name,omitempty"`
48 | Transfer map[uint64]uint64 `json:"transfer,omitempty"`
49 | NextUpdate map[uint64]time.Time `json:"next_update,omitempty"`
50 | }
51 |
52 | type ServiceResponse struct {
53 | Services map[uint64]ServiceResponseItem `json:"services,omitempty"`
54 | CycleTransferStats map[uint64]CycleTransferStats `json:"cycle_transfer_stats,omitempty"`
55 | }
56 |
--------------------------------------------------------------------------------
/model/service_history.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 | )
6 |
7 | type ServiceHistory struct {
8 | ID uint64 `gorm:"primaryKey" json:"id,omitempty"`
9 | CreatedAt time.Time `gorm:"index;<-:create;index:idx_server_id_created_at_service_id_avg_delay" json:"created_at,omitempty"`
10 | UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at,omitempty"`
11 | ServiceID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"service_id,omitempty"`
12 | ServerID uint64 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"server_id,omitempty"`
13 | AvgDelay float32 `gorm:"index:idx_server_id_created_at_service_id_avg_delay" json:"avg_delay,omitempty"` // 平均延迟,毫秒
14 | Up uint64 `json:"up,omitempty"` // 检查状态良好计数
15 | Down uint64 `json:"down,omitempty"` // 检查状态异常计数
16 | Data string `json:"data,omitempty"`
17 | }
18 |
--------------------------------------------------------------------------------
/model/service_history_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type ServiceInfos struct {
4 | ServiceID uint64 `json:"monitor_id"`
5 | ServerID uint64 `json:"server_id"`
6 | ServiceName string `json:"monitor_name"`
7 | ServerName string `json:"server_name"`
8 | CreatedAt []int64 `json:"created_at"`
9 | AvgDelay []float32 `json:"avg_delay"`
10 | }
11 |
--------------------------------------------------------------------------------
/model/setting_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type SettingForm struct {
4 | DNSServers string `json:"dns_servers,omitempty" validate:"optional"`
5 | IgnoredIPNotification string `json:"ignored_ip_notification,omitempty" validate:"optional"`
6 | IPChangeNotificationGroupID uint64 `json:"ip_change_notification_group_id,omitempty"` // IP变更提醒的通知组
7 | Cover uint8 `json:"cover,omitempty"`
8 | SiteName string `json:"site_name,omitempty" minLength:"1"`
9 | Language string `json:"language,omitempty" minLength:"2"`
10 | InstallHost string `json:"install_host,omitempty" validate:"optional"`
11 | CustomCode string `json:"custom_code,omitempty" validate:"optional"`
12 | CustomCodeDashboard string `json:"custom_code_dashboard,omitempty" validate:"optional"`
13 | WebRealIPHeader string `json:"web_real_ip_header,omitempty" validate:"optional"` // 前端真实IP
14 | AgentRealIPHeader string `json:"agent_real_ip_header,omitempty" validate:"optional"` // Agent真实IP
15 | UserTemplate string `json:"user_template,omitempty" validate:"optional"`
16 |
17 | AgentTLS bool `json:"tls,omitempty" validate:"optional"`
18 | EnableIPChangeNotification bool `json:"enable_ip_change_notification,omitempty" validate:"optional"`
19 | EnablePlainIPInNotification bool `json:"enable_plain_ip_in_notification,omitempty" validate:"optional"`
20 | }
21 |
22 | type Setting struct {
23 | ConfigForGuests
24 | ConfigDashboard
25 |
26 | IgnoredIPNotificationServerIDs map[uint64]bool `json:"ignored_ip_notification_server_ids,omitempty"`
27 | Oauth2Providers []string `json:"oauth2_providers,omitempty"`
28 | }
29 |
30 | type FrontendTemplate struct {
31 | Path string `json:"path,omitempty"`
32 | Name string `json:"name,omitempty"`
33 | Repository string `json:"repository,omitempty"`
34 | Author string `json:"author,omitempty"`
35 | Version string `json:"version,omitempty"`
36 | IsAdmin bool `json:"is_admin,omitempty"`
37 | IsOfficial bool `json:"is_official,omitempty"`
38 | }
39 |
40 | type SettingResponse struct {
41 | Config Setting `json:"config"`
42 |
43 | Version string `json:"version,omitempty"`
44 | FrontendTemplates []FrontendTemplate `json:"frontend_templates,omitempty"`
45 | }
46 |
--------------------------------------------------------------------------------
/model/terminal_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type TerminalForm struct {
4 | Protocol string `json:"protocol,omitempty"`
5 | ServerID uint64 `json:"server_id,omitempty"`
6 | }
7 |
8 | type CreateTerminalResponse struct {
9 | SessionID string `json:"session_id,omitempty"`
10 | ServerID uint64 `json:"server_id,omitempty"`
11 | ServerName string `json:"server_name,omitempty"`
12 | }
13 |
--------------------------------------------------------------------------------
/model/transfer.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type Transfer struct {
4 | Common
5 | ServerID uint64 `json:"server_id"`
6 | In uint64 `json:"in"`
7 | Out uint64 `json:"out"`
8 | }
9 |
--------------------------------------------------------------------------------
/model/user.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/gorilla/websocket"
7 | "github.com/nezhahq/nezha/pkg/utils"
8 | "gorm.io/gorm"
9 | )
10 |
11 | const (
12 | RoleAdmin uint8 = iota
13 | RoleMember
14 | )
15 |
16 | const DefaultAgentSecretLength = 32
17 |
18 | type User struct {
19 | Common
20 | Username string `json:"username,omitempty" gorm:"uniqueIndex"`
21 | Password string `json:"password,omitempty" gorm:"type:char(72)"`
22 | Role uint8 `json:"role,omitempty"`
23 | AgentSecret string `json:"agent_secret,omitempty" gorm:"type:char(32)"`
24 | RejectPassword bool `json:"reject_password,omitempty"`
25 | }
26 |
27 | type UserInfo struct {
28 | Role uint8
29 | AgentSecret string
30 | }
31 |
32 | func (u *User) BeforeSave(tx *gorm.DB) error {
33 | if u.AgentSecret != "" {
34 | return nil
35 | }
36 |
37 | key, err := utils.GenerateRandomString(DefaultAgentSecretLength)
38 | if err != nil {
39 | return err
40 | }
41 |
42 | u.AgentSecret = key
43 | return nil
44 | }
45 |
46 | type Profile struct {
47 | User
48 | LoginIP string `json:"login_ip,omitempty"`
49 | Oauth2Bind map[string]string `json:"oauth2_bind,omitempty"`
50 | }
51 |
52 | type OnlineUser struct {
53 | UserID uint64 `json:"user_id,omitempty"`
54 | ConnectedAt time.Time `json:"connected_at,omitempty"`
55 | IP string `json:"ip,omitempty"`
56 |
57 | Conn *websocket.Conn `json:"-"`
58 | }
59 |
--------------------------------------------------------------------------------
/model/user_api.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | type UserForm struct {
4 | Role uint8 `json:"role,omitempty"`
5 | Username string `json:"username,omitempty"`
6 | Password string `json:"password,omitempty" gorm:"type:char(72)"`
7 | }
8 |
9 | type ProfileForm struct {
10 | OriginalPassword string `json:"original_password,omitempty"`
11 | NewUsername string `json:"new_username,omitempty"`
12 | NewPassword string `json:"new_password,omitempty"`
13 | RejectPassword bool `json:"reject_password,omitempty" validate:"optional"`
14 | }
15 |
--------------------------------------------------------------------------------
/model/waf.go:
--------------------------------------------------------------------------------
1 | package model
2 |
3 | import (
4 | "errors"
5 | "math/big"
6 | "time"
7 |
8 | "github.com/nezhahq/nezha/pkg/utils"
9 | "gorm.io/gorm"
10 | )
11 |
12 | const (
13 | _ uint8 = iota
14 | WAFBlockReasonTypeLoginFail
15 | WAFBlockReasonTypeBruteForceToken
16 | WAFBlockReasonTypeAgentAuthFail
17 | WAFBlockReasonTypeManual
18 | WAFBlockReasonTypeBruteForceOauth2
19 | )
20 |
21 | const (
22 | BlockIDgRPC = -127 + iota
23 | BlockIDToken
24 | BlockIDUnknownUser
25 | BlockIDManual
26 | )
27 |
28 | type WAFApiMock struct {
29 | IP string `json:"ip,omitempty"`
30 | BlockIdentifier int64 `json:"block_identifier,omitempty"`
31 | BlockReason uint8 `json:"block_reason,omitempty"`
32 | BlockTimestamp uint64 `json:"block_timestamp,omitempty"`
33 | Count uint64 `json:"count,omitempty"`
34 | }
35 |
36 | type WAF struct {
37 | IP []byte `gorm:"type:binary(16);primaryKey" json:"ip,omitempty"`
38 | BlockIdentifier int64 `gorm:"primaryKey" json:"block_identifier,omitempty"`
39 | BlockReason uint8 `json:"block_reason,omitempty"`
40 | BlockTimestamp uint64 `gorm:"index" json:"block_timestamp,omitempty"`
41 | Count uint64 `json:"count,omitempty"`
42 | }
43 |
44 | func (w *WAF) TableName() string {
45 | return "nz_waf"
46 | }
47 |
48 | func CheckIP(db *gorm.DB, ip string) error {
49 | if ip == "" {
50 | return nil
51 | }
52 | ipBinary, err := utils.IPStringToBinary(ip)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | var blockTimestamp uint64
58 | result := db.Model(&WAF{}).Order("block_timestamp desc").Select("block_timestamp").Where("ip = ?", ipBinary).Limit(1).Find(&blockTimestamp)
59 | if result.Error != nil {
60 | return result.Error
61 | }
62 |
63 | // 检查是否未找到记录
64 | if result.RowsAffected < 1 {
65 | return nil
66 | }
67 |
68 | var count uint64
69 | if err := db.Model(&WAF{}).Select("SUM(count)").Where("ip = ?", ipBinary).Scan(&count).Error; err != nil {
70 | return err
71 | }
72 |
73 | now := time.Now().Unix()
74 | if powAdd(count, 4, blockTimestamp) > uint64(now) {
75 | return errors.New("you were blocked by nezha WAF")
76 | }
77 | return nil
78 | }
79 |
80 | func UnblockIP(db *gorm.DB, ip string, uid int64) error {
81 | if ip == "" {
82 | return nil
83 | }
84 | ipBinary, err := utils.IPStringToBinary(ip)
85 | if err != nil {
86 | return err
87 | }
88 | return db.Unscoped().Delete(&WAF{}, "ip = ? and block_identifier = ?", ipBinary, uid).Error
89 | }
90 |
91 | func BatchUnblockIP(db *gorm.DB, ip []string) error {
92 | if len(ip) < 1 {
93 | return nil
94 | }
95 | ips := make([][]byte, 0, len(ip))
96 | for _, s := range ip {
97 | ipBinary, err := utils.IPStringToBinary(s)
98 | if err != nil {
99 | continue
100 | }
101 | ips = append(ips, ipBinary)
102 | }
103 | return db.Unscoped().Delete(&WAF{}, "ip in (?)", ips).Error
104 | }
105 |
106 | func BlockIP(db *gorm.DB, ip string, reason uint8, uid int64) error {
107 | if ip == "" {
108 | return nil
109 | }
110 | ipBinary, err := utils.IPStringToBinary(ip)
111 | if err != nil {
112 | return err
113 | }
114 | w := WAF{
115 | IP: ipBinary,
116 | BlockIdentifier: uid,
117 | }
118 | now := uint64(time.Now().Unix())
119 |
120 | var count any
121 | if reason == WAFBlockReasonTypeManual {
122 | count = 99999
123 | } else {
124 | count = gorm.Expr("count + 1")
125 | }
126 |
127 | return db.Transaction(func(tx *gorm.DB) error {
128 | if err := tx.Where(&w).Attrs(WAF{
129 | BlockReason: reason,
130 | BlockTimestamp: now,
131 | }).FirstOrCreate(&w).Error; err != nil {
132 | return err
133 | }
134 | return tx.Exec("UPDATE nz_waf SET count = ?, block_reason = ?, block_timestamp = ? WHERE ip = ? and block_identifier = ?", count, reason, now, ipBinary, uid).Error
135 | })
136 | }
137 |
138 | func powAdd(x, y, z uint64) uint64 {
139 | base := big.NewInt(0).SetUint64(x)
140 | exp := big.NewInt(0).SetUint64(y)
141 | result := big.NewInt(1)
142 | result.Exp(base, exp, nil)
143 | result.Add(result, big.NewInt(0).SetUint64(z))
144 | if !result.IsUint64() {
145 | return ^uint64(0) // return max uint64 value on overflow
146 | }
147 | ret := result.Uint64()
148 | return utils.IfOr(ret < z+3, z+3, ret)
149 | }
150 |
--------------------------------------------------------------------------------
/pkg/ddns/ddns.go:
--------------------------------------------------------------------------------
1 | package ddns
2 |
3 | import (
4 | "context"
5 | "fmt"
6 | "log"
7 | "net/netip"
8 | "time"
9 |
10 | "github.com/libdns/libdns"
11 | "github.com/miekg/dns"
12 |
13 | "github.com/nezhahq/nezha/model"
14 | "github.com/nezhahq/nezha/pkg/utils"
15 | )
16 |
17 | type DNSServerKey struct{}
18 |
19 | const (
20 | dnsTimeOut = 10 * time.Second
21 | )
22 |
23 | type Provider struct {
24 | prefix string
25 | zone string
26 |
27 | DDNSProfile *model.DDNSProfile
28 | IPAddrs *model.IP
29 | Setter libdns.RecordSetter
30 | }
31 |
32 | func (provider *Provider) GetProfileID() uint64 {
33 | return provider.DDNSProfile.ID
34 | }
35 |
36 | func (provider *Provider) UpdateDomain(ctx context.Context, overrideDomains ...string) {
37 | for _, domain := range utils.IfOr(len(overrideDomains) > 0, overrideDomains, provider.DDNSProfile.Domains) {
38 | for retries := range int(provider.DDNSProfile.MaxRetries) {
39 | log.Printf("NEZHA>> Updating DNS Record of domain %s: %d/%d", domain, retries+1, provider.DDNSProfile.MaxRetries)
40 | if err := provider.updateDomain(ctx, domain); err != nil {
41 | log.Printf("NEZHA>> Failed to update DNS record of domain %s: %v", domain, err)
42 | } else {
43 | log.Printf("NEZHA>> Update DNS record of domain %s succeeded", domain)
44 | break
45 | }
46 | }
47 | }
48 | }
49 |
50 | func (provider *Provider) updateDomain(ctx context.Context, domain string) error {
51 | var err error
52 | provider.prefix, provider.zone, err = provider.splitDomainSOA(ctx, domain)
53 | if err != nil {
54 | return err
55 | }
56 |
57 | // 当IPv4和IPv6同时成功才算作成功
58 | if *provider.DDNSProfile.EnableIPv4 {
59 | if err = provider.addDomainRecord(ctx, "A", provider.IPAddrs.IPv4Addr); err != nil {
60 | return err
61 | }
62 | }
63 |
64 | if *provider.DDNSProfile.EnableIPv6 {
65 | if err = provider.addDomainRecord(ctx, "AAAA", provider.IPAddrs.IPv6Addr); err != nil {
66 | return err
67 | }
68 | }
69 |
70 | return nil
71 | }
72 |
73 | func (provider *Provider) addDomainRecord(ctx context.Context, recType, addr string) error {
74 | netipAddr, err := netip.ParseAddr(addr)
75 | if err != nil {
76 | return fmt.Errorf("parse error: %v", err)
77 | }
78 |
79 | _, err = provider.Setter.SetRecords(ctx, provider.zone,
80 | []libdns.Record{
81 | libdns.Address{
82 | Name: provider.prefix,
83 | IP: netipAddr,
84 | TTL: time.Minute,
85 | },
86 | })
87 | return err
88 | }
89 |
90 | func (provider *Provider) splitDomainSOA(ctx context.Context, domain string) (prefix string, zone string, err error) {
91 | c := &dns.Client{Timeout: dnsTimeOut}
92 |
93 | domain += "."
94 | indexes := dns.Split(domain)
95 |
96 | servers := utils.DNSServers
97 |
98 | customDNSServers, _ := ctx.Value(DNSServerKey{}).([]string)
99 | if len(customDNSServers) > 0 {
100 | servers = customDNSServers
101 | }
102 |
103 | for _, server := range servers {
104 | for _, idx := range indexes {
105 | var m dns.Msg
106 | m.SetQuestion(domain[idx:], dns.TypeSOA)
107 |
108 | r, _, err := c.Exchange(&m, server)
109 | if err != nil {
110 | continue
111 | }
112 |
113 | if len(r.Answer) > 0 {
114 | if soa, ok := r.Answer[0].(*dns.SOA); ok {
115 | zone := soa.Hdr.Name
116 | prefix := libdns.RelativeName(domain, zone)
117 | return prefix, zone, nil
118 | }
119 | }
120 | }
121 | }
122 |
123 | return "", "", fmt.Errorf("SOA record not found for domain: %s", domain)
124 | }
125 |
--------------------------------------------------------------------------------
/pkg/ddns/ddns_test.go:
--------------------------------------------------------------------------------
1 | package ddns
2 |
3 | import (
4 | "context"
5 | "os"
6 | "testing"
7 | )
8 |
9 | type testSt struct {
10 | domain string
11 | zone string
12 | prefix string
13 | }
14 |
15 | func TestSplitDomainSOA(t *testing.T) {
16 | if ci := os.Getenv("CI"); ci != "" { // skip if test on CI
17 | return
18 | }
19 |
20 | cases := []testSt{
21 | {
22 | domain: "www.example.co.uk",
23 | zone: "example.co.uk.",
24 | prefix: "www",
25 | },
26 | {
27 | domain: "abc.example.com",
28 | zone: "example.com.",
29 | prefix: "abc",
30 | },
31 | {
32 | domain: "example.com",
33 | zone: "example.com.",
34 | prefix: "",
35 | },
36 | }
37 |
38 | ctx := context.WithValue(context.Background(), DNSServerKey{}, []string{"1.1.1.1:53"})
39 | provider := &Provider{}
40 | for _, c := range cases {
41 | prefix, zone, err := provider.splitDomainSOA(ctx, c.domain)
42 | if err != nil {
43 | t.Fatalf("Error: %s", err)
44 | }
45 | if prefix != c.prefix {
46 | t.Fatalf("Expected prefix %s, but got %s", c.prefix, prefix)
47 | }
48 | if zone != c.zone {
49 | t.Fatalf("Expected zone %s, but got %s", c.zone, zone)
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/pkg/ddns/dummy/dummy.go:
--------------------------------------------------------------------------------
1 | package dummy
2 |
3 | import (
4 | "context"
5 |
6 | "github.com/libdns/libdns"
7 | )
8 |
9 | // Internal use
10 | type Provider struct{}
11 |
12 | func (provider *Provider) SetRecords(ctx context.Context, zone string,
13 | recs []libdns.Record) ([]libdns.Record, error) {
14 | return recs, nil
15 | }
16 |
--------------------------------------------------------------------------------
/pkg/ddns/webhook/webhook.go:
--------------------------------------------------------------------------------
1 | package webhook
2 |
3 | import (
4 | "context"
5 | "errors"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "strings"
10 |
11 | "github.com/libdns/libdns"
12 | "github.com/nezhahq/nezha/model"
13 | "github.com/nezhahq/nezha/pkg/utils"
14 | )
15 |
16 | const (
17 | _ = iota
18 | methodGET
19 | methodPOST
20 | methodPATCH
21 | methodDELETE
22 | methodPUT
23 | )
24 |
25 | const (
26 | _ = iota
27 | requestTypeJSON
28 | requestTypeForm
29 | )
30 |
31 | var requestTypes = map[uint8]string{
32 | methodGET: "GET",
33 | methodPOST: "POST",
34 | methodPATCH: "PATCH",
35 | methodDELETE: "DELETE",
36 | methodPUT: "PUT",
37 | }
38 |
39 | // Internal use
40 | type Provider struct {
41 | ipAddr string
42 | ipType string
43 | recordType string
44 | domain string
45 |
46 | DDNSProfile *model.DDNSProfile
47 | }
48 |
49 | func (provider *Provider) SetRecords(ctx context.Context, zone string,
50 | recs []libdns.Record) ([]libdns.Record, error) {
51 | for _, rec := range recs {
52 | switch rec.(type) {
53 | case libdns.Address:
54 | rr := rec.RR()
55 | provider.recordType = rr.Type
56 | provider.ipType = recordToIPType(provider.recordType)
57 | provider.ipAddr = rr.Data
58 | provider.domain = fmt.Sprintf("%s.%s", rr.Name, strings.TrimSuffix(zone, "."))
59 |
60 | req, err := provider.prepareRequest(ctx)
61 | if err != nil {
62 | return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err)
63 | }
64 | if _, err := utils.HttpClient.Do(req); err != nil {
65 | return nil, fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domain, err)
66 | }
67 | default:
68 | return nil, fmt.Errorf("unsupported record type: %T", rec)
69 | }
70 | }
71 |
72 | return recs, nil
73 | }
74 |
75 | func (provider *Provider) prepareRequest(ctx context.Context) (*http.Request, error) {
76 | u, err := provider.reqUrl()
77 | if err != nil {
78 | return nil, err
79 | }
80 |
81 | body, err := provider.reqBody()
82 | if err != nil {
83 | return nil, err
84 | }
85 |
86 | headers, err := utils.GjsonIter(
87 | provider.formatWebhookString(provider.DDNSProfile.WebhookHeaders))
88 | if err != nil {
89 | return nil, err
90 | }
91 |
92 | req, err := http.NewRequestWithContext(ctx, requestTypes[provider.DDNSProfile.WebhookMethod], u.String(), strings.NewReader(body))
93 | if err != nil {
94 | return nil, err
95 | }
96 |
97 | provider.setContentType(req)
98 |
99 | for k, v := range headers {
100 | req.Header.Set(k, v)
101 | }
102 |
103 | return req, nil
104 | }
105 |
106 | func (provider *Provider) setContentType(req *http.Request) {
107 | if provider.DDNSProfile.WebhookMethod == methodGET {
108 | return
109 | }
110 | if provider.DDNSProfile.WebhookRequestType == requestTypeForm {
111 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
112 | } else {
113 | req.Header.Set("Content-Type", "application/json")
114 | }
115 | }
116 |
117 | func (provider *Provider) reqUrl() (*url.URL, error) {
118 | formattedUrl := strings.ReplaceAll(provider.DDNSProfile.WebhookURL, "#", "%23")
119 |
120 | u, err := url.Parse(formattedUrl)
121 | if err != nil {
122 | return nil, err
123 | }
124 |
125 | // Only handle queries here
126 | q := u.Query()
127 | for p, vals := range q {
128 | for n, v := range vals {
129 | vals[n] = provider.formatWebhookString(v)
130 | }
131 | q[p] = vals
132 | }
133 |
134 | u.RawQuery = q.Encode()
135 | return u, nil
136 | }
137 |
138 | func (provider *Provider) reqBody() (string, error) {
139 | if provider.DDNSProfile.WebhookMethod == methodGET ||
140 | provider.DDNSProfile.WebhookMethod == methodDELETE {
141 | return "", nil
142 | }
143 |
144 | switch provider.DDNSProfile.WebhookRequestType {
145 | case requestTypeJSON:
146 | return provider.formatWebhookString(provider.DDNSProfile.WebhookRequestBody), nil
147 | case requestTypeForm:
148 | data, err := utils.GjsonIter(provider.DDNSProfile.WebhookRequestBody)
149 | if err != nil {
150 | return "", err
151 | }
152 | params := url.Values{}
153 | for k, v := range data {
154 | params.Add(k, provider.formatWebhookString(v))
155 | }
156 | return params.Encode(), nil
157 | default:
158 | return "", errors.New("request type not supported")
159 | }
160 | }
161 |
162 | func (provider *Provider) formatWebhookString(s string) string {
163 | r := strings.NewReplacer(
164 | "#ip#", provider.ipAddr,
165 | "#domain#", provider.domain,
166 | "#type#", provider.ipType,
167 | "#record#", provider.recordType,
168 | "#access_id#", provider.DDNSProfile.AccessID,
169 | "#access_secret#", provider.DDNSProfile.AccessSecret,
170 | "\r", "",
171 | )
172 |
173 | result := r.Replace(strings.TrimSpace(s))
174 | return result
175 | }
176 |
177 | func recordToIPType(record string) string {
178 | switch record {
179 | case "A":
180 | return "ipv4"
181 | case "AAAA":
182 | return "ipv6"
183 | default:
184 | return ""
185 | }
186 | }
187 |
--------------------------------------------------------------------------------
/pkg/ddns/webhook/webhook_test.go:
--------------------------------------------------------------------------------
1 | package webhook
2 |
3 | import (
4 | "context"
5 | "testing"
6 |
7 | "github.com/nezhahq/nezha/model"
8 | )
9 |
10 | var (
11 | reqTypeForm = "application/x-www-form-urlencoded"
12 | reqTypeJSON = "application/json"
13 | )
14 |
15 | type testSt struct {
16 | profile model.DDNSProfile
17 | expectURL string
18 | expectBody string
19 | expectContentType string
20 | expectHeader map[string]string
21 | }
22 |
23 | func execCase(t *testing.T, item testSt) {
24 | pw := Provider{DDNSProfile: &item.profile}
25 | pw.ipAddr = "1.1.1.1"
26 | pw.domain = item.profile.Domains[0]
27 | pw.ipType = "ipv4"
28 | pw.recordType = "A"
29 | pw.DDNSProfile = &item.profile
30 |
31 | reqUrl, err := pw.reqUrl()
32 | if err != nil {
33 | t.Fatalf("Error: %s", err)
34 | }
35 | if item.expectURL != reqUrl.String() {
36 | t.Fatalf("Expected %s, but got %s", item.expectURL, reqUrl.String())
37 | }
38 |
39 | reqBody, err := pw.reqBody()
40 | if err != nil {
41 | t.Fatalf("Error: %s", err)
42 | }
43 | if item.expectBody != reqBody {
44 | t.Fatalf("Expected %s, but got %s", item.expectBody, reqBody)
45 | }
46 |
47 | req, err := pw.prepareRequest(context.Background())
48 | if err != nil {
49 | t.Fatalf("Error: %s", err)
50 | }
51 |
52 | if item.expectContentType != req.Header.Get("Content-Type") {
53 | t.Fatalf("Expected %s, but got %s", item.expectContentType, req.Header.Get("Content-Type"))
54 | }
55 |
56 | for k, v := range item.expectHeader {
57 | if v != req.Header.Get(k) {
58 | t.Fatalf("Expected %s, but got %s", v, req.Header.Get(k))
59 | }
60 | }
61 | }
62 |
63 | func TestWebhookRequest(t *testing.T) {
64 | ipv4 := true
65 |
66 | cases := []testSt{
67 | {
68 | profile: model.DDNSProfile{
69 | Domains: []string{"www.example.com"},
70 | MaxRetries: 1,
71 | EnableIPv4: &ipv4,
72 | WebhookURL: "http://ddns.example.com/?ip=#ip#",
73 | WebhookMethod: methodGET,
74 | WebhookHeaders: `{"ip":"#ip#","record":"#record#"}`,
75 | },
76 | expectURL: "http://ddns.example.com/?ip=1.1.1.1",
77 | expectContentType: "",
78 | expectHeader: map[string]string{
79 | "ip": "1.1.1.1",
80 | "record": "A",
81 | },
82 | },
83 | {
84 | profile: model.DDNSProfile{
85 | Domains: []string{"www.example.com"},
86 | MaxRetries: 1,
87 | EnableIPv4: &ipv4,
88 | WebhookURL: "http://ddns.example.com/api",
89 | WebhookMethod: methodPOST,
90 | WebhookRequestType: requestTypeJSON,
91 | WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
92 | },
93 | expectURL: "http://ddns.example.com/api",
94 | expectContentType: reqTypeJSON,
95 | expectBody: `{"ip":"1.1.1.1","record":"A"}`,
96 | },
97 | {
98 | profile: model.DDNSProfile{
99 | Domains: []string{"www.example.com"},
100 | MaxRetries: 1,
101 | EnableIPv4: &ipv4,
102 | WebhookURL: "http://ddns.example.com/api",
103 | WebhookMethod: methodPOST,
104 | WebhookRequestType: requestTypeForm,
105 | WebhookRequestBody: `{"ip":"#ip#","record":"#record#"}`,
106 | },
107 | expectURL: "http://ddns.example.com/api",
108 | expectContentType: reqTypeForm,
109 | expectBody: "ip=1.1.1.1&record=A",
110 | },
111 | }
112 |
113 | for _, c := range cases {
114 | execCase(t, c)
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/pkg/geoip/geoip.db:
--------------------------------------------------------------------------------
1 | stub
2 |
--------------------------------------------------------------------------------
/pkg/geoip/geoip.go:
--------------------------------------------------------------------------------
1 | package geoip
2 |
3 | import (
4 | _ "embed"
5 | "errors"
6 | "net"
7 | "strings"
8 | "sync"
9 |
10 | maxminddb "github.com/oschwald/maxminddb-golang"
11 | )
12 |
13 | //go:embed geoip.db
14 | var db []byte
15 |
16 | var (
17 | dbOnce = sync.OnceValues(func() (*maxminddb.Reader, error) {
18 | db, err := maxminddb.FromBytes(db)
19 | if err != nil {
20 | return nil, err
21 | }
22 | return db, nil
23 | })
24 | )
25 |
26 | type IPInfo struct {
27 | Country string `maxminddb:"country"`
28 | CountryName string `maxminddb:"country_name"`
29 | Continent string `maxminddb:"continent"`
30 | ContinentName string `maxminddb:"continent_name"`
31 | }
32 |
33 | func Lookup(ip net.IP) (string, error) {
34 | db, err := dbOnce()
35 | if err != nil {
36 | return "", err
37 | }
38 |
39 | var record IPInfo
40 | err = db.Lookup(ip, &record)
41 | if err != nil {
42 | return "", err
43 | }
44 |
45 | if record.Country != "" {
46 | return strings.ToLower(record.Country), nil
47 | } else if record.Continent != "" {
48 | return strings.ToLower(record.Continent), nil
49 | }
50 |
51 | return "", errors.New("IP not found")
52 | }
53 |
--------------------------------------------------------------------------------
/pkg/grpcx/io_stream_wrapper.go:
--------------------------------------------------------------------------------
1 | package grpcx
2 |
3 | import (
4 | "context"
5 | "io"
6 | "sync/atomic"
7 |
8 | "github.com/nezhahq/nezha/proto"
9 | )
10 |
11 | var _ io.ReadWriteCloser = (*IOStreamWrapper)(nil)
12 |
13 | type IOStream interface {
14 | Recv() (*proto.IOStreamData, error)
15 | Send(*proto.IOStreamData) error
16 | Context() context.Context
17 | }
18 |
19 | type IOStreamWrapper struct {
20 | IOStream
21 | dataBuf []byte
22 | closed *atomic.Bool
23 | closeCh chan struct{}
24 | }
25 |
26 | func NewIOStreamWrapper(stream IOStream) *IOStreamWrapper {
27 | return &IOStreamWrapper{
28 | IOStream: stream,
29 | closeCh: make(chan struct{}),
30 | closed: new(atomic.Bool),
31 | }
32 | }
33 |
34 | func (iw *IOStreamWrapper) Read(p []byte) (n int, err error) {
35 | if len(iw.dataBuf) > 0 {
36 | n := copy(p, iw.dataBuf)
37 | iw.dataBuf = iw.dataBuf[n:]
38 | return n, nil
39 | }
40 | var data *proto.IOStreamData
41 | if data, err = iw.Recv(); err != nil {
42 | return 0, err
43 | }
44 | n = copy(p, data.Data)
45 | if n < len(data.Data) {
46 | iw.dataBuf = data.Data[n:]
47 | }
48 | return n, nil
49 | }
50 |
51 | func (iw *IOStreamWrapper) Write(p []byte) (n int, err error) {
52 | err = iw.Send(&proto.IOStreamData{Data: p})
53 | return len(p), err
54 | }
55 |
56 | func (iw *IOStreamWrapper) Close() error {
57 | if iw.closed.CompareAndSwap(false, true) {
58 | close(iw.closeCh)
59 | }
60 | return nil
61 | }
62 |
63 | func (iw *IOStreamWrapper) Wait() {
64 | <-iw.closeCh
65 | }
66 |
--------------------------------------------------------------------------------
/pkg/i18n/i18n.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "embed"
5 | "fmt"
6 | "io/fs"
7 | "path"
8 | "sync"
9 |
10 | "github.com/leonelquinteros/gotext"
11 | )
12 |
13 | //go:embed translations
14 | var Translations embed.FS
15 |
16 | type Localizer struct {
17 | intlMap map[string]gotext.Translator
18 | lang string
19 | domain string
20 | path string
21 | fs fs.FS
22 |
23 | mu sync.RWMutex
24 | }
25 |
26 | func NewLocalizer(lang, domain, path string, fs fs.FS) *Localizer {
27 | loc := &Localizer{
28 | intlMap: make(map[string]gotext.Translator),
29 | lang: lang,
30 | domain: domain,
31 | path: path,
32 | fs: fs,
33 | }
34 |
35 | file := loc.findExt(lang, "mo")
36 | if file == "" {
37 | return loc
38 | }
39 |
40 | mo := gotext.NewMoFS(loc.fs)
41 | mo.ParseFile(file)
42 | loc.intlMap[lang] = mo
43 |
44 | return loc
45 | }
46 |
47 | func (l *Localizer) SetLanguage(lang string) {
48 | l.mu.Lock()
49 | defer l.mu.Unlock()
50 |
51 | l.lang = lang
52 | }
53 |
54 | func (l *Localizer) Exists(lang string) bool {
55 | l.mu.RLock()
56 | defer l.mu.RUnlock()
57 |
58 | if _, ok := l.intlMap[lang]; ok {
59 | return ok
60 | }
61 | return false
62 | }
63 |
64 | func (l *Localizer) AppendIntl(lang string) {
65 | file := l.findExt(lang, "mo")
66 | if file == "" {
67 | return
68 | }
69 |
70 | mo := gotext.NewMoFS(l.fs)
71 | mo.ParseFile(file)
72 |
73 | l.mu.Lock()
74 | defer l.mu.Unlock()
75 |
76 | l.intlMap[lang] = mo
77 | }
78 |
79 | // Modified from k8s.io/kubectl/pkg/util/i18n
80 |
81 | func (l *Localizer) T(orig string) string {
82 | l.mu.RLock()
83 | intl, ok := l.intlMap[l.lang]
84 | l.mu.RUnlock()
85 | if !ok {
86 | return orig
87 | }
88 |
89 | return intl.Get(orig)
90 | }
91 |
92 | // N translates a string, possibly substituting arguments into it along
93 | // the way. If len(args) is > 0, args1 is assumed to be the plural value
94 | // and plural translation is used.
95 | func (l *Localizer) N(orig string, args ...int) string {
96 | l.mu.RLock()
97 | intl, ok := l.intlMap[l.lang]
98 | l.mu.RUnlock()
99 | if !ok {
100 | return orig
101 | }
102 |
103 | if len(args) == 0 {
104 | return intl.Get(orig)
105 | }
106 | return fmt.Sprintf(intl.GetN(orig, orig+".plural", args[0]),
107 | args[0])
108 | }
109 |
110 | // ErrorT produces an error with a translated error string.
111 | // Substitution is performed via the `T` function above, following
112 | // the same rules.
113 | func (l *Localizer) ErrorT(defaultValue string, args ...any) error {
114 | return fmt.Errorf(l.T(defaultValue), args...)
115 | }
116 |
117 | func (l *Localizer) Tf(defaultValue string, args ...any) string {
118 | return fmt.Sprintf(l.T(defaultValue), args...)
119 | }
120 |
121 | // https://github.com/leonelquinteros/gotext/blob/v1.7.1/locale.go
122 | func (l *Localizer) findExt(lang, ext string) string {
123 | filename := path.Join(l.path, lang, "LC_MESSAGES", l.domain+"."+ext)
124 | if l.fileExists(filename) {
125 | return filename
126 | }
127 |
128 | if len(lang) > 2 {
129 | filename = path.Join(l.path, lang[:2], "LC_MESSAGES", l.domain+"."+ext)
130 | if l.fileExists(filename) {
131 | return filename
132 | }
133 | }
134 |
135 | filename = path.Join(l.path, lang, l.domain+"."+ext)
136 | if l.fileExists(filename) {
137 | return filename
138 | }
139 |
140 | if len(lang) > 2 {
141 | filename = path.Join(l.path, lang[:2], l.domain+"."+ext)
142 | if l.fileExists(filename) {
143 | return filename
144 | }
145 | }
146 |
147 | return ""
148 | }
149 |
150 | func (l *Localizer) fileExists(filename string) bool {
151 | _, err := fs.Stat(l.fs, filename)
152 | return err == nil
153 | }
154 |
--------------------------------------------------------------------------------
/pkg/i18n/i18n_test.go:
--------------------------------------------------------------------------------
1 | package i18n
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestI18n(t *testing.T) {
8 | const testStr = "database error"
9 |
10 | t.Run("SwitchLocale", func(t *testing.T) {
11 | loc := NewLocalizer("zh_CN", "nezha", "translations", Translations)
12 | translated := loc.T(testStr)
13 | if translated != "数据库错误" {
14 | t.Fatalf("expected %s, but got %s", "数据库错误", translated)
15 | }
16 |
17 | loc.AppendIntl("zh_TW")
18 | loc.SetLanguage("zh_TW")
19 | translated = loc.T(testStr)
20 | if translated != "資料庫錯誤" {
21 | t.Fatalf("expected %s, but got %s", "資料庫錯誤", translated)
22 | }
23 | })
24 |
25 | t.Run("Fallback", func(t *testing.T) {
26 | loc := NewLocalizer("invalid", "nezha", "translations", Translations)
27 | fallbackStr := loc.T(testStr)
28 | if fallbackStr != testStr {
29 | t.Fatalf("expected %s, but got %s", testStr, fallbackStr)
30 | }
31 | })
32 | }
33 |
--------------------------------------------------------------------------------
/pkg/i18n/translations/de_DE/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/de_DE/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/i18n/translations/en_US/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/en_US/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/i18n/translations/es_ES/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/es_ES/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/i18n/translations/ru_RU/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/ru_RU/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/i18n/translations/ta_IN/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/ta_IN/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/i18n/translations/zh_CN/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/zh_CN/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/i18n/translations/zh_TW/LC_MESSAGES/nezha.mo:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/nezhahq/nezha/33325045846a3ba9b5e35cfc25a349e94477afe2/pkg/i18n/translations/zh_TW/LC_MESSAGES/nezha.mo
--------------------------------------------------------------------------------
/pkg/utils/gin_writer_wrapper.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import "github.com/gin-gonic/gin"
4 |
5 | type GinCustomWriter struct {
6 | gin.ResponseWriter
7 |
8 | customCode int
9 | }
10 |
11 | func NewGinCustomWriter(c *gin.Context, code int) *GinCustomWriter {
12 | return &GinCustomWriter{
13 | ResponseWriter: c.Writer,
14 | customCode: code,
15 | }
16 | }
17 |
18 | func (w *GinCustomWriter) WriteHeader(code int) {
19 | w.ResponseWriter.WriteHeader(w.customCode)
20 | }
21 |
--------------------------------------------------------------------------------
/pkg/utils/gjson.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "errors"
5 | "iter"
6 |
7 | "github.com/tidwall/gjson"
8 | )
9 |
10 | var (
11 | ErrGjsonWrongType = errors.New("wrong type")
12 | )
13 |
14 | var emptyIterator = func(yield func(string, string) bool) {}
15 |
16 | func GjsonIter(json string) (iter.Seq2[string, string], error) {
17 | if json == "" {
18 | return emptyIterator, nil
19 | }
20 |
21 | result := gjson.Parse(json)
22 | if !result.IsObject() {
23 | return nil, ErrGjsonWrongType
24 | }
25 |
26 | return ConvertSeq2(result.ForEach, func(k, v gjson.Result) (string, string) {
27 | return k.String(), v.String()
28 | }), nil
29 | }
30 |
--------------------------------------------------------------------------------
/pkg/utils/http.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "crypto/tls"
5 | "net/http"
6 | "time"
7 | )
8 |
9 | var (
10 | HttpClientSkipTlsVerify *http.Client
11 | HttpClient *http.Client
12 | )
13 |
14 | func init() {
15 | HttpClientSkipTlsVerify = httpClient(_httpClient{
16 | Transport: httpTransport(_httpTransport{
17 | SkipVerifyTLS: true,
18 | }),
19 | })
20 | HttpClient = httpClient(_httpClient{
21 | Transport: httpTransport(_httpTransport{
22 | SkipVerifyTLS: false,
23 | }),
24 | })
25 |
26 | http.DefaultClient.Timeout = time.Minute * 10
27 | }
28 |
29 | type _httpTransport struct {
30 | SkipVerifyTLS bool
31 | }
32 |
33 | func httpTransport(conf _httpTransport) *http.Transport {
34 | return &http.Transport{
35 | TLSClientConfig: &tls.Config{InsecureSkipVerify: conf.SkipVerifyTLS},
36 | Proxy: http.ProxyFromEnvironment,
37 | }
38 | }
39 |
40 | type _httpClient struct {
41 | Transport *http.Transport
42 | }
43 |
44 | func httpClient(conf _httpClient) *http.Client {
45 | return &http.Client{
46 | Transport: conf.Transport,
47 | Timeout: time.Minute * 10,
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/pkg/utils/koanf.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "encoding"
5 | "reflect"
6 |
7 | "github.com/go-viper/mapstructure/v2"
8 | "sigs.k8s.io/yaml"
9 | )
10 |
11 | // TextUnmarshalerHookFunc is a fixed version of mapstructure.TextUnmarshallerHookFunc.
12 | // This hook allows to additionally unmarshal text into custom string types that implement the encoding.Text(Un)Marshaler interface(s).
13 | func TextUnmarshalerHookFunc() mapstructure.DecodeHookFuncType {
14 | return func(
15 | f reflect.Type,
16 | t reflect.Type,
17 | data any,
18 | ) (any, error) {
19 | if f.Kind() != reflect.String {
20 | return data, nil
21 | }
22 | result := reflect.New(t).Interface()
23 | unmarshaller, ok := result.(encoding.TextUnmarshaler)
24 | if !ok {
25 | return data, nil
26 | }
27 |
28 | // default text representation is the actual value of the `from` string
29 | var (
30 | dataVal = reflect.ValueOf(data)
31 | text = []byte(dataVal.String())
32 | )
33 | if f.Kind() == t.Kind() {
34 | // source and target are of underlying type string
35 | var (
36 | err error
37 | ptrVal = reflect.New(dataVal.Type())
38 | )
39 | if !ptrVal.Elem().CanSet() {
40 | // cannot set, skip, this should not happen
41 | if err := unmarshaller.UnmarshalText(text); err != nil {
42 | return nil, err
43 | }
44 | return result, nil
45 | }
46 | ptrVal.Elem().Set(dataVal)
47 |
48 | // We need to assert that both, the value type and the pointer type
49 | // do (not) implement the TextMarshaller interface before proceeding and simply
50 | // using the string value of the string type.
51 | // it might be the case that the internal string representation differs from
52 | // the (un)marshalled string.
53 |
54 | for _, v := range []reflect.Value{dataVal, ptrVal} {
55 | if marshaller, ok := v.Interface().(encoding.TextMarshaler); ok {
56 | text, err = marshaller.MarshalText()
57 | if err != nil {
58 | return nil, err
59 | }
60 | break
61 | }
62 | }
63 | }
64 |
65 | // text is either the source string's value or the source string type's marshaled value
66 | // which may differ from its internal string value.
67 | if err := unmarshaller.UnmarshalText(text); err != nil {
68 | return nil, err
69 | }
70 | return result, nil
71 | }
72 | }
73 |
74 | // KubeYAML implements a YAML parser.
75 | type KubeYAML struct{}
76 |
77 | // Unmarshal parses the given YAML bytes.
78 | func (p *KubeYAML) Unmarshal(b []byte) (map[string]any, error) {
79 | var out map[string]any
80 | if err := yaml.Unmarshal(b, &out); err != nil {
81 | return nil, err
82 | }
83 |
84 | return out, nil
85 | }
86 |
87 | // Marshal marshals the given config map to YAML bytes.
88 | func (p *KubeYAML) Marshal(o map[string]any) ([]byte, error) {
89 | return yaml.Marshal(o)
90 | }
91 |
--------------------------------------------------------------------------------
/pkg/utils/request_wrapper.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "bytes"
5 | "errors"
6 | "io"
7 | "net"
8 | "net/http"
9 | )
10 |
11 | var _ io.ReadWriteCloser = (*RequestWrapper)(nil)
12 |
13 | type RequestWrapper struct {
14 | req *http.Request
15 | reader *bytes.Buffer
16 | writer net.Conn
17 | }
18 |
19 | func NewRequestWrapper(req *http.Request, writer http.ResponseWriter) (*RequestWrapper, error) {
20 | hj, ok := writer.(http.Hijacker)
21 | if !ok {
22 | return nil, errors.New("http server does not support hijacking")
23 | }
24 | conn, _, err := hj.Hijack()
25 | if err != nil {
26 | return nil, err
27 | }
28 | buf := bytes.NewBuffer(nil)
29 | if err = req.Write(buf); err != nil {
30 | return nil, err
31 | }
32 | return &RequestWrapper{
33 | req: req,
34 | reader: buf,
35 | writer: conn,
36 | }, nil
37 | }
38 |
39 | func (rw *RequestWrapper) Read(p []byte) (int, error) {
40 | count, err := rw.reader.Read(p)
41 | if err == nil {
42 | return count, nil
43 | }
44 | if err != io.EOF {
45 | return count, err
46 | }
47 | // request 数据读完之后等待客户端断开连接或 grpc 超时
48 | return rw.writer.Read(p)
49 | }
50 |
51 | func (rw *RequestWrapper) Write(p []byte) (int, error) {
52 | return rw.writer.Write(p)
53 | }
54 |
55 | func (rw *RequestWrapper) Close() error {
56 | rw.req.Body.Close()
57 | rw.writer.Close()
58 | return nil
59 | }
60 |
--------------------------------------------------------------------------------
/pkg/utils/utils.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "cmp"
5 | "crypto/rand"
6 | "errors"
7 | "fmt"
8 | "iter"
9 | "maps"
10 | "math/big"
11 | "net/netip"
12 | "regexp"
13 | "slices"
14 | "strconv"
15 | "strings"
16 |
17 | "golang.org/x/exp/constraints"
18 | )
19 |
20 | var (
21 | DNSServersV4 = []string{"8.8.8.8:53", "8.8.4.4:53", "1.1.1.1:53", "1.0.0.1:53"}
22 | DNSServersV6 = []string{"[2001:4860:4860::8888]:53", "[2001:4860:4860::8844]:53", "[2606:4700:4700::1111]:53", "[2606:4700:4700::1001]:53"}
23 | DNSServers = append(DNSServersV4, DNSServersV6...)
24 |
25 | ipv4Re = regexp.MustCompile(`(\d*\.).*(\.\d*)`)
26 | ipv6Re = regexp.MustCompile(`(\w*:\w*:).*(:\w*:\w*)`)
27 | )
28 |
29 | func ipv4Desensitize(ipv4Addr string) string {
30 | return ipv4Re.ReplaceAllString(ipv4Addr, "$1****$2")
31 | }
32 |
33 | func ipv6Desensitize(ipv6Addr string) string {
34 | return ipv6Re.ReplaceAllString(ipv6Addr, "$1****$2")
35 | }
36 |
37 | func IPDesensitize(ipAddr string) string {
38 | ipAddr = ipv4Desensitize(ipAddr)
39 | ipAddr = ipv6Desensitize(ipAddr)
40 | return ipAddr
41 | }
42 |
43 | func IPStringToBinary(ip string) ([]byte, error) {
44 | addr, err := netip.ParseAddr(ip)
45 | if err != nil {
46 | return nil, err
47 | }
48 | b := addr.As16()
49 | return b[:], nil
50 | }
51 |
52 | func BinaryToIPString(b []byte) string {
53 | if len(b) < 16 {
54 | return "::"
55 | }
56 |
57 | addr := netip.AddrFrom16([16]byte(b))
58 | return addr.Unmap().String()
59 | }
60 |
61 | func GetIPFromHeader(headerValue string) (string, error) {
62 | a := strings.Split(headerValue, ",")
63 | h := strings.TrimSpace(a[len(a)-1])
64 | ip, err := netip.ParseAddr(h)
65 | if err != nil {
66 | return "", err
67 | }
68 | if !ip.IsValid() {
69 | return "", errors.New("invalid ip")
70 | }
71 | return ip.String(), nil
72 | }
73 |
74 | func GenerateRandomString(n int) (string, error) {
75 | const letters = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
76 | lettersLength := big.NewInt(int64(len(letters)))
77 | ret := make([]byte, n)
78 | for i := range n {
79 | num, err := rand.Int(rand.Reader, lettersLength)
80 | if err != nil {
81 | return "", err
82 | }
83 | ret[i] = letters[num.Int64()]
84 | }
85 | return string(ret), nil
86 | }
87 |
88 | func MustGenerateRandomString(n int) string {
89 | str, err := GenerateRandomString(n)
90 | if err != nil {
91 | panic(fmt.Errorf("MustGenerateRandomString: %v", err))
92 | }
93 | return str
94 | }
95 |
96 | func IfOr[T any](a bool, x, y T) T {
97 | if a {
98 | return x
99 | }
100 | return y
101 | }
102 |
103 | func Itoa[T constraints.Integer](i T) string {
104 | switch any(i).(type) {
105 | case int, int8, int16, int32, int64:
106 | return strconv.FormatInt(int64(i), 10)
107 | case uint, uint8, uint16, uint32, uint64:
108 | return strconv.FormatUint(uint64(i), 10)
109 | default:
110 | return ""
111 | }
112 | }
113 |
114 | func MapValuesToSlice[Map ~map[K]V, K comparable, V any](m Map) []V {
115 | s := make([]V, 0, len(m))
116 | return slices.AppendSeq(s, maps.Values(m))
117 | }
118 |
119 | func MapKeysToSlice[Map ~map[K]V, K comparable, V any](m Map) []K {
120 | s := make([]K, 0, len(m))
121 | return slices.AppendSeq(s, maps.Keys(m))
122 | }
123 |
124 | func Unique[S ~[]E, E cmp.Ordered](list S) S {
125 | if list == nil {
126 | return nil
127 | }
128 | out := make([]E, len(list))
129 | copy(out, list)
130 | slices.Sort(out)
131 | return slices.Compact(out)
132 | }
133 |
134 | func ConvertSeq[In, Out any](seq iter.Seq[In], f func(In) Out) iter.Seq[Out] {
135 | return func(yield func(Out) bool) {
136 | for in := range seq {
137 | if !yield(f(in)) {
138 | return
139 | }
140 | }
141 | }
142 | }
143 |
144 | func ConvertSeq2[KIn, VIn, KOut, VOut any](seq iter.Seq2[KIn, VIn], f func(KIn, VIn) (KOut, VOut)) iter.Seq2[KOut, VOut] {
145 | return func(yield func(KOut, VOut) bool) {
146 | for k, v := range seq {
147 | if !yield(f(k, v)) {
148 | return
149 | }
150 | }
151 | }
152 | }
153 |
154 | func Seq2To1[K, V any](seq iter.Seq2[K, V]) iter.Seq[V] {
155 | return func(yield func(V) bool) {
156 | for _, v := range seq {
157 | if !yield(v) {
158 | return
159 | }
160 | }
161 | }
162 | }
163 |
164 | type WrapError struct {
165 | err, errIn error
166 | }
167 |
168 | func NewWrapError(err, errIn error) error {
169 | return &WrapError{err, errIn}
170 | }
171 |
172 | func (e *WrapError) Error() string {
173 | return e.err.Error()
174 | }
175 |
176 | func (e *WrapError) Unwrap() error {
177 | return e.errIn
178 | }
179 |
180 | func FirstError(errorer ...func() error) error {
181 | for _, fn := range errorer {
182 | if err := fn(); err != nil {
183 | return err
184 | }
185 | }
186 | return nil
187 | }
188 |
189 | func SubUintChecked[T constraints.Unsigned](a, b T) T {
190 | if a < b {
191 | return 0
192 | }
193 |
194 | return a - b
195 | }
196 |
--------------------------------------------------------------------------------
/pkg/utils/utils_test.go:
--------------------------------------------------------------------------------
1 | package utils
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | type testSt struct {
9 | input string
10 | output string
11 | }
12 |
13 | func TestNotification(t *testing.T) {
14 | cases := []testSt{
15 | {
16 | input: "103.80.236.249/d5ce:d811:cdb8:067a:a873:2076:9521:9d2d",
17 | output: "103.****.249/d5ce:d811:****:9521:9d2d",
18 | },
19 | {
20 | input: "3.80.236.29/d5ce::cdb8:067a:a873:2076:9521:9d2d",
21 | output: "3.****.29/d5ce::****:9521:9d2d",
22 | },
23 | {
24 | input: "3.80.236.29/d5ce::cdb8:067a:a873:2076::9d2d",
25 | output: "3.****.29/d5ce::****::9d2d",
26 | },
27 | {
28 | input: "3.80.236.9/d5ce::cdb8:067a:a873:2076::9d2d",
29 | output: "3.****.9/d5ce::****::9d2d",
30 | },
31 | {
32 | input: "3.80.236.9/d5ce::cdb8:067a:a873:2076::9d2d",
33 | output: "3.****.9/d5ce::****::9d2d",
34 | },
35 | }
36 |
37 | for _, c := range cases {
38 | if c.output != IPDesensitize(c.input) {
39 | t.Fatalf("Expected %s, but got %s", c.output, IPDesensitize(c.input))
40 | }
41 | }
42 | }
43 |
44 | func TestGenerGenerateRandomString(t *testing.T) {
45 | generatedString := make(map[string]bool)
46 | for range 100 {
47 | str, err := GenerateRandomString(32)
48 | if err != nil {
49 | t.Fatalf("Error: %s", err)
50 | }
51 | if len(str) != 32 {
52 | t.Fatalf("Expected 32, but got %d", len(str))
53 | }
54 | if generatedString[str] {
55 | t.Fatalf("Duplicated string: %s", str)
56 | }
57 | generatedString[str] = true
58 | }
59 | }
60 |
61 | func TestIPStringToBinary(t *testing.T) {
62 | cases := []struct {
63 | ip string
64 | want []byte
65 | expectError bool
66 | }{
67 | // 有效的 IPv4 地址
68 | {
69 | ip: "192.168.1.1",
70 | want: []byte{
71 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 192, 168, 1, 1,
72 | },
73 | expectError: false,
74 | },
75 | // 有效的 IPv6 地址
76 | {
77 | ip: "2001:db8::68",
78 | want: []byte{
79 | 32, 1, 13, 184, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 104,
80 | },
81 | expectError: false,
82 | },
83 | // 无效的 IP 地址
84 | {
85 | ip: "invalid_ip",
86 | want: []byte{},
87 | expectError: true,
88 | },
89 | }
90 |
91 | for _, c := range cases {
92 | got, err := IPStringToBinary(c.ip)
93 | if (err != nil) != c.expectError {
94 | t.Errorf("IPStringToBinary(%q) error = %v, expect error = %v", c.ip, err, c.expectError)
95 | continue
96 | }
97 | if err == nil && !reflect.DeepEqual(got, c.want) {
98 | t.Errorf("IPStringToBinary(%q) = %v, want %v", c.ip, got, c.want)
99 | }
100 | }
101 | }
102 |
103 | func TestBinaryToIPString(t *testing.T) {
104 | cases := []struct {
105 | binary []byte
106 | want string
107 | }{
108 | // IPv4 地址(IPv4 映射的 IPv6 地址格式)
109 | {
110 | binary: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 192, 168, 1, 1},
111 | want: "192.168.1.1",
112 | },
113 | // 其他测试用例
114 | {
115 | binary: []byte{32, 1, 13, 184, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 104},
116 | want: "2001:db8::68",
117 | },
118 | // 全零值
119 | {
120 | binary: []byte{},
121 | want: "::",
122 | },
123 | // IPv4 映射的 IPv6 地址
124 | {
125 | binary: []byte{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 255, 255, 127, 0, 0, 1},
126 | want: "127.0.0.1",
127 | },
128 | }
129 |
130 | for _, c := range cases {
131 | got := BinaryToIPString(c.binary)
132 | if got != c.want {
133 | t.Errorf("BinaryToIPString(%v) = %q, 期望 %q", c.binary, got, c.want)
134 | }
135 | }
136 | }
137 |
138 | func TestUnique(t *testing.T) {
139 | cases := []struct {
140 | input []string
141 | output []string
142 | }{
143 | {
144 | input: []string{"a", "b", "c", "a", "b", "c"},
145 | output: []string{"a", "b", "c"},
146 | },
147 | {
148 | input: []string{"a", "b", "c"},
149 | output: []string{"a", "b", "c"},
150 | },
151 | {
152 | input: []string{"a", "a", "a"},
153 | output: []string{"a"},
154 | },
155 | {
156 | input: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"},
157 | output: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"},
158 | },
159 | {
160 | input: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "a"},
161 | output: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
162 | },
163 | {
164 | input: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "a", "b", "c", "d", "e", "f", "g", "h", "i"},
165 | output: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i"},
166 | },
167 | {
168 | input: []string{},
169 | output: []string{},
170 | },
171 | {
172 | input: []string{"a"},
173 | output: []string{"a"},
174 | },
175 | }
176 |
177 | for _, c := range cases {
178 | if !reflect.DeepEqual(Unique(c.input), c.output) {
179 | t.Fatalf("Expected %v, but got %v", c.output, Unique(c.input))
180 | }
181 | }
182 | }
183 |
--------------------------------------------------------------------------------
/pkg/websocketx/safe_conn.go:
--------------------------------------------------------------------------------
1 | package websocketx
2 |
3 | import (
4 | "io"
5 | "sync"
6 |
7 | "github.com/gorilla/websocket"
8 | )
9 |
10 | var _ io.ReadWriteCloser = (*Conn)(nil)
11 |
12 | type Conn struct {
13 | *websocket.Conn
14 | writeLock *sync.Mutex
15 | dataBuf []byte
16 | }
17 |
18 | func NewConn(conn *websocket.Conn) *Conn {
19 | return &Conn{Conn: conn, writeLock: new(sync.Mutex)}
20 | }
21 |
22 | func (conn *Conn) Write(data []byte) (int, error) {
23 | conn.writeLock.Lock()
24 | defer conn.writeLock.Unlock()
25 | if err := conn.Conn.WriteMessage(websocket.BinaryMessage, data); err != nil {
26 | return 0, err
27 | }
28 | return len(data), nil
29 | }
30 |
31 | func (conn *Conn) WriteMessage(messageType int, data []byte) error {
32 | conn.writeLock.Lock()
33 | defer conn.writeLock.Unlock()
34 | return conn.Conn.WriteMessage(messageType, data)
35 | }
36 |
37 | func (conn *Conn) Read(data []byte) (int, error) {
38 | if len(conn.dataBuf) > 0 {
39 | n := copy(data, conn.dataBuf)
40 | conn.dataBuf = conn.dataBuf[n:]
41 | return n, nil
42 | }
43 | mType, innerData, err := conn.Conn.ReadMessage()
44 | if err != nil {
45 | return 0, err
46 | }
47 | // 将文本消息转换为命令输入
48 | if mType == websocket.TextMessage {
49 | innerData = append([]byte{0}, innerData...)
50 | }
51 | n := copy(data, innerData)
52 | if n < len(innerData) {
53 | conn.dataBuf = innerData[n:]
54 | }
55 | return n, nil
56 | }
57 |
--------------------------------------------------------------------------------
/proto/nezha.proto:
--------------------------------------------------------------------------------
1 | syntax = "proto3";
2 | option go_package = "./proto";
3 |
4 | package proto;
5 |
6 | service NezhaService {
7 | rpc ReportSystemState(stream State) returns (stream Receipt) {}
8 | rpc ReportSystemInfo(Host) returns (Receipt) {}
9 | rpc RequestTask(stream TaskResult) returns (stream Task) {}
10 | rpc IOStream(stream IOStreamData) returns (stream IOStreamData) {}
11 | rpc ReportGeoIP(GeoIP) returns (GeoIP) {}
12 | rpc ReportSystemInfo2(Host) returns (Uint64Receipt) {}
13 | }
14 |
15 | message Host {
16 | string platform = 1;
17 | string platform_version = 2;
18 | repeated string cpu = 3;
19 | uint64 mem_total = 4;
20 | uint64 disk_total = 5;
21 | uint64 swap_total = 6;
22 | string arch = 7;
23 | string virtualization = 8;
24 | uint64 boot_time = 9;
25 | string version = 10;
26 | repeated string gpu = 11;
27 | }
28 |
29 | message State {
30 | double cpu = 1;
31 | uint64 mem_used = 2;
32 | uint64 swap_used = 3;
33 | uint64 disk_used = 4;
34 | uint64 net_in_transfer = 5;
35 | uint64 net_out_transfer = 6;
36 | uint64 net_in_speed = 7;
37 | uint64 net_out_speed = 8;
38 | uint64 uptime = 9;
39 | double load1 = 10;
40 | double load5 = 11;
41 | double load15 = 12;
42 | uint64 tcp_conn_count = 13;
43 | uint64 udp_conn_count = 14;
44 | uint64 process_count = 15;
45 | repeated State_SensorTemperature temperatures = 16;
46 | repeated double gpu = 17;
47 | }
48 |
49 | message State_SensorTemperature {
50 | string name = 1;
51 | double temperature = 2;
52 | }
53 |
54 | message Task {
55 | uint64 id = 1;
56 | uint64 type = 2;
57 | string data = 3;
58 | }
59 |
60 | message TaskResult {
61 | uint64 id = 1;
62 | uint64 type = 2;
63 | float delay = 3;
64 | string data = 4;
65 | bool successful = 5;
66 | }
67 |
68 | message Receipt { bool proced = 1; }
69 |
70 | message Uint64Receipt { uint64 data = 1; }
71 |
72 | message IOStreamData { bytes data = 1; }
73 |
74 | message GeoIP {
75 | bool use6 = 1;
76 | IP ip = 2;
77 | string country_code = 3;
78 | uint64 dashboard_boot_time = 4;
79 | }
80 |
81 | message IP {
82 | string ipv4 = 1;
83 | string ipv6 = 2;
84 | }
85 |
--------------------------------------------------------------------------------
/script/bootstrap.sh:
--------------------------------------------------------------------------------
1 | swag init --pd -d . -g ./cmd/dashboard/main.go -o ./cmd/dashboard/docs --requiredByDefault
2 | protoc --go-grpc_out="require_unimplemented_servers=false:." --go_out="." proto/*.proto
3 | rm -rf ../agent/proto
4 | cp -r proto ../agent
--------------------------------------------------------------------------------
/script/config.yaml:
--------------------------------------------------------------------------------
1 | debug: false
2 | httpport: 80
3 | language: nz_language
4 | grpcport: nz_grpc_port
5 | oauth2:
6 | type: "nz_oauth2_type" #Oauth2 登录接入类型,github/gitlab/jihulab/gitee/gitea
7 | admin: "nz_admin_logins" #管理员列表,半角逗号隔开
8 | clientid: "nz_github_oauth_client_id" # 在 https://github.com/settings/developers 创建,无需审核 Callback 填 http(s)://域名或IP/oauth2/callback
9 | clientsecret: "nz_github_oauth_client_secret"
10 | endpoint: "" # 如gitea自建需要设置
11 | site:
12 | brand: "nz_site_title"
13 | cookiename: "nezha-dashboard" #浏览器 Cookie 字段名,可不改
14 | theme: "default"
15 |
--------------------------------------------------------------------------------
/script/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | printf "nameserver 127.0.0.11\nnameserver 8.8.4.4\nnameserver 223.5.5.5\n" > /etc/resolv.conf
3 | exec /dashboard/app
--------------------------------------------------------------------------------
/script/fetch-frontends.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | set -euo pipefail
4 |
5 | ROOT_DIR="$(realpath "$(dirname "${BASH_SOURCE[0]}")/..")"
6 | TEMPLATES_FILE="$ROOT_DIR/service/singleton/frontend-templates.yaml"
7 |
8 | download_and_extract() {
9 | local repository="$1"
10 | local version="$2"
11 | local targetDir="$3"
12 | local TMP_DIR
13 |
14 | TMP_DIR="$(mktemp -d)"
15 |
16 | echo "Downloading from repository: $repository, version: $version"
17 |
18 | pushd "$TMP_DIR" || exit
19 |
20 | curl -L -o "dist.zip" "$repository/releases/download/$version/dist.zip"
21 |
22 | [ -e "$targetDir" ] && rm -r "$targetDir"
23 | unzip -q dist.zip
24 | mv dist "$targetDir"
25 |
26 | rm "dist.zip"
27 | popd || exit
28 | }
29 |
30 | count=$(yq eval '. | length' "$TEMPLATES_FILE")
31 |
32 | for i in $(seq 0 $(("$count"-1))); do
33 | path=$(yq -r ".[$i].path" "$TEMPLATES_FILE")
34 | repository=$(yq -r ".[$i].repository" "$TEMPLATES_FILE")
35 | version=$(yq -r ".[$i].version" "$TEMPLATES_FILE")
36 |
37 | if [[ -n $path && -n $repository && -n $version ]]; then
38 | download_and_extract "$repository" "$version" "$ROOT_DIR/cmd/dashboard/$path"
39 | fi
40 | done
41 |
--------------------------------------------------------------------------------
/script/i18n.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | mapfile -t LANG < <(ls pkg/i18n/translations)
4 | TEMPLATE="pkg/i18n/template.pot"
5 | PODIR="pkg/i18n/translations/%s/LC_MESSAGES"
6 | GIT_ROOT=$(git rev-parse --show-toplevel)
7 |
8 | red='\033[0;31m'
9 | green='\033[0;32m'
10 | yellow='\033[0;33m'
11 | plain='\033[0m'
12 |
13 | err() {
14 | printf "${red}%s${plain}\n" "$*" >&2
15 | }
16 |
17 | success() {
18 | printf "${green}%s${plain}\n" "$*"
19 | }
20 |
21 | info() {
22 | printf "${yellow}%s${plain}\n" "$*"
23 | }
24 |
25 | generate() {
26 | case $1 in
27 | "template")
28 | generate_template
29 | ;;
30 | "en")
31 | generate_en
32 | ;;
33 | *)
34 | err "invalid argument"
35 | ;;
36 | esac
37 | }
38 |
39 | generate_template() {
40 | mapfile -t src < <(find . -name "*.go" | sort)
41 | xgettext -C --add-comments=TRANSLATORS: -kErrorT -kT -kTf -kN:1,2 --from-code=UTF-8 -o $TEMPLATE "${src[@]}"
42 | }
43 |
44 | generate_en() {
45 | local po_file
46 | po_file=$(printf "$PODIR/nezha.po" "en_US")
47 | local mo_file
48 | mo_file=$(printf "$PODIR/nezha.mo" "en_US")
49 | msginit --input=$TEMPLATE --locale=en_US.UTF-8 --output-file="$po_file" --no-translator
50 | msgfmt "$po_file" -o "$mo_file"
51 | }
52 |
53 | compile() {
54 | if [[ $# != 0 && "$1" != "" ]]; then
55 | compile_single "$1"
56 | else
57 | compile_all
58 | fi
59 | }
60 |
61 | compile_single() {
62 | local param="$1"
63 | local found=0
64 |
65 | for lang in "${LANG[@]}"; do
66 | if [[ "$lang" == "$param" ]]; then
67 | found=1
68 | break
69 | fi
70 | done
71 |
72 | if [[ $found == 0 ]]; then
73 | err "the language does not exist."
74 | return
75 | fi
76 |
77 | local po_file
78 | po_file=$(printf "$PODIR/nezha.po" "$param")
79 | local mo_file
80 | mo_file=$(printf "$PODIR/nezha.mo" "$param")
81 |
82 | msgfmt "$po_file" -o "$mo_file"
83 | }
84 |
85 | compile_all() {
86 | local po_file
87 | local mo_file
88 | for lang in "${LANG[@]}"; do
89 | po_file=$(printf "$PODIR/nezha.po" "$lang")
90 | mo_file=$(printf "$PODIR/nezha.mo" "$lang")
91 |
92 | msgfmt "$po_file" -o "$mo_file"
93 | done
94 | }
95 |
96 | update() {
97 | if [[ $# != 0 && "$1" != "" ]]; then
98 | update_single "$1"
99 | else
100 | update_all
101 | fi
102 | }
103 |
104 | update_single() {
105 | local param="$1"
106 | local found=0
107 |
108 | for lang in "${LANG[@]}"; do
109 | if [[ "$lang" == "$param" ]]; then
110 | found=1
111 | break
112 | fi
113 | done
114 |
115 | if [[ $found == 0 ]]; then
116 | err "the language does not exist."
117 | return
118 | fi
119 |
120 | local po_file
121 | po_file=$(printf "$PODIR/nezha.po" "$param")
122 | msgmerge -U "$po_file" $TEMPLATE
123 | }
124 |
125 | update_all() {
126 | for lang in "${LANG[@]}"; do
127 | local po_file
128 | po_file=$(printf "$PODIR/nezha.po" "$lang")
129 | msgmerge -U "$po_file" $TEMPLATE
130 | done
131 | }
132 |
133 | show_help() {
134 | echo "Usage: $0 [command] args"
135 | echo ""
136 | echo "Available commands:"
137 | echo " update Update .po from .pot"
138 | echo " compile Compile .mo from .po"
139 | echo " generate Generate template or en_US locale"
140 | echo ""
141 | echo "Examples:"
142 | echo " $0 update # Update all locales"
143 | echo " $0 update zh_CN # Update zh_CN locale"
144 | echo " $0 compile # Compile all locales"
145 | echo " $0 compile zh_CN # Compile zh_CN locale"
146 | echo " $0 generate template # Generate template"
147 | echo " $0 generate en # Generate en_US locale"
148 | }
149 |
150 | version() { echo "$@" | awk -F. '{ printf("%d%03d%03d%03d\n", $1,$2,$3,$4); }'; }
151 |
152 | main() {
153 | if [[ $(version "$BASH_VERSION") < $(version "4.0") ]]; then
154 | err "This version of bash does not support mapfile"
155 | exit 1
156 | fi
157 |
158 | if [[ $PWD != "$GIT_ROOT" ]]; then
159 | err "Must execute in the project root"
160 | exit 1
161 | fi
162 |
163 | case "$1" in
164 | "update")
165 | update "$2"
166 | ;;
167 | "compile")
168 | compile "$2"
169 | ;;
170 | "generate")
171 | generate "$2"
172 | ;;
173 | *)
174 | echo "Error: Unknown command '$1'"
175 | show_help
176 | exit 1
177 | ;;
178 | esac
179 | }
180 |
181 | main "$@"
182 |
--------------------------------------------------------------------------------
/script/install.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #========================================================
4 | # master 分支安装脚本重定向
5 | #========================================================
6 |
7 | # 判断是否应使用中国镜像
8 |
9 | geo_check() {
10 | api_list="https://blog.cloudflare.com/cdn-cgi/trace https://developers.cloudflare.com/cdn-cgi/trace"
11 | ua="Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Firefox/81.0"
12 | set -- "$api_list"
13 | for url in $api_list; do
14 | text="$(curl -A "$ua" -m 10 -s "$url")"
15 | endpoint="$(echo "$text" | sed -n 's/.*h=\([^ ]*\).*/\1/p')"
16 | if echo "$text" | grep -qw 'CN'; then
17 | isCN=true
18 | break
19 | elif echo "$url" | grep -q "$endpoint"; then
20 | break
21 | fi
22 | done
23 | }
24 |
25 | # 向用户确认是否使用中国镜像
26 | geo_check
27 |
28 | if [ "$isCN" = true ]; then
29 | read -p "检测到您的IP可能来自中国大陆,是否使用中国镜像? [y/n] " choice
30 | case "$choice" in
31 | y|Y)
32 | echo "将使用中国镜像..."
33 | USE_CN_MIRROR=true
34 | ;;
35 | n|N)
36 | echo "将使用国际镜像..."
37 | USE_CN_MIRROR=false
38 | ;;
39 | *)
40 | echo "输入无效,将使用国际镜像..."
41 | USE_CN_MIRROR=false
42 | ;;
43 | esac
44 | else
45 | USE_CN_MIRROR=false
46 | fi
47 |
48 | # 默认引导用户使用 master 分支 v1 新面板安装脚本,但若使用了 install_agent 参数,则默认重定向至 v0
49 | if echo "$@" | grep -q "install_agent"; then
50 | echo "检测到 v0 面板 install_agent 参数,将使用 v0 分支脚本..."
51 | echo "警告: v0 面板已停止维护,请尽快升级至 v1 面板,详见文档:https://nezha.wiki/,5s 后继续运行脚本"
52 | # 强制等待 5 秒
53 | sleep 5
54 | is_v1=false
55 | else
56 | # 让用户选择是否执行新脚本
57 | echo "v1 面板已正式发布,v0 已停止维护,若您已安装 v0 面板,请尽快升级至 v1 面板"
58 | echo "v1 与 v0 有较大差异,详见文档:https://nezha.wiki/"
59 | echo "若您暂时不想升级,请输入 n 并按回车键以继续使用 v0 面板脚本"
60 | read -p "是否执行 v1 面板安装脚本? [y/n] " choice
61 | case "$choice" in
62 | n|N)
63 | is_v1=false
64 | ;;
65 | *)
66 | is_v1=true
67 | ;;
68 | esac
69 | fi
70 |
71 | if [ "$is_v1" = true ]; then
72 | echo "将使用 v1 面板安装脚本..."
73 | if [ "$USE_CN_MIRROR" = true ]; then
74 | shell_url="https://gitee.com/naibahq/scripts/raw/main/install.sh"
75 | else
76 | shell_url="https://raw.githubusercontent.com/nezhahq/scripts/main/install.sh"
77 | fi
78 | file_name="nezha.sh"
79 | else
80 | echo "将使用 v0 面板安装脚本,脚本将会下载为nezha_v0.sh"
81 | if [ "$USE_CN_MIRROR" = true ]; then
82 | shell_url="https://gitee.com/naibahq/scripts/raw/v0/install.sh"
83 | else
84 | shell_url="https://raw.githubusercontent.com/nezhahq/scripts/refs/heads/v0/install.sh"
85 | fi
86 | file_name="nezha_v0.sh"
87 | fi
88 |
89 |
90 | if command -v wget >/dev/null 2>&1; then
91 | wget -O "/tmp/nezha.sh" "$shell_url"
92 | elif command -v curl >/dev/null 2>&1; then
93 | curl -o "/tmp/nezha.sh" "$shell_url"
94 | else
95 | echo "错误: 未找到 wget 或 curl,请安装其中任意一个后再试"
96 | exit 1
97 | fi
98 |
99 | chmod +x "/tmp/nezha.sh"
100 | mv "/tmp/nezha.sh" "./$file_name"
101 | # 携带原参数运行新脚本
102 | exec ./"$file_name" "$@"
--------------------------------------------------------------------------------
/script/install_en.sh:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | #========================================================
4 | # master install script redirect
5 | #========================================================
6 |
7 | # Default: Guide users to use master branch v1 new panel installation script
8 | # However, if install_agent parameter is used, redirect to v0 by default
9 | if echo "$@" | grep -q "install_agent"; then
10 | echo "Detected v0 panel install_agent parameter, will use v0 branch script..."
11 | echo "Warning: v0 panel is no longer maintained, please upgrade to v1 panel ASAP. See docs: https://nezha.wiki/, script will continue in 5s"
12 | sleep 5
13 | is_v1=false
14 | else
15 | echo "v1 panel has been officially released, v0 is no longer maintained. If you have v0 panel installed, please upgrade to v1 ASAP"
16 | echo "v1 differs significantly from v0, see documentation: https://nezha.wiki/"
17 | echo "If you don't want to upgrade now, enter 'n' and press Enter to continue using v0 panel script"
18 | read -p "Execute v1 panel installation script? [y/n] " choice
19 | case "$choice" in
20 | n|N)
21 | is_v1=false
22 | ;;
23 | *)
24 | is_v1=true
25 | ;;
26 | esac
27 | fi
28 |
29 | if [ "$is_v1" = true ]; then
30 | echo "Will use v1 panel installation script..."
31 | shell_url="https://raw.githubusercontent.com/nezhahq/scripts/main/install_en.sh"
32 | file_name="nezha.sh"
33 | else
34 | echo "Will use v0 panel installation script, script will be downloaded as nezha_v0.sh"
35 | shell_url="https://raw.githubusercontent.com/nezhahq/scripts/refs/heads/v0/install_en.sh"
36 | file_name="nezha_v0.sh"
37 | fi
38 |
39 |
40 | if command -v wget >/dev/null 2>&1; then
41 | wget -O "/tmp/nezha.sh" "$shell_url"
42 | elif command -v curl >/dev/null 2>&1; then
43 | curl -o "/tmp/nezha.sh" "$shell_url"
44 | else
45 | echo "Error: wget or curl not found, please install either one and try again"
46 | exit 1
47 | fi
48 |
49 | chmod +x "/tmp/nezha.sh"
50 | mv "/tmp/nezha.sh" "./$file_name"
51 | # Run the new script with the original parameters
52 | exec ./"$file_name" "$@"
53 |
--------------------------------------------------------------------------------
/service/rpc/auth.go:
--------------------------------------------------------------------------------
1 | package rpc
2 |
3 | import (
4 | "context"
5 | "strings"
6 |
7 | petname "github.com/dustinkirkland/golang-petname"
8 | "github.com/hashicorp/go-uuid"
9 | "google.golang.org/grpc/codes"
10 | "google.golang.org/grpc/metadata"
11 | "google.golang.org/grpc/status"
12 |
13 | "github.com/nezhahq/nezha/model"
14 | "github.com/nezhahq/nezha/service/singleton"
15 | )
16 |
17 | type authHandler struct {
18 | ClientSecret string
19 | ClientUUID string
20 | }
21 |
22 | func (a *authHandler) Check(ctx context.Context) (uint64, error) {
23 | md, ok := metadata.FromIncomingContext(ctx)
24 | if !ok {
25 | return 0, status.Errorf(codes.Unauthenticated, "获取 metaData 失败")
26 | }
27 |
28 | var clientSecret string
29 | if value, ok := md["client_secret"]; ok {
30 | clientSecret = strings.TrimSpace(value[0])
31 | }
32 |
33 | if clientSecret == "" {
34 | return 0, status.Error(codes.Unauthenticated, "客户端认证失败")
35 | }
36 |
37 | ip, _ := ctx.Value(model.CtxKeyRealIP{}).(string)
38 |
39 | singleton.UserLock.RLock()
40 | userId, ok := singleton.AgentSecretToUserId[clientSecret]
41 | if !ok {
42 | singleton.UserLock.RUnlock()
43 | model.BlockIP(singleton.DB, ip, model.WAFBlockReasonTypeAgentAuthFail, model.BlockIDgRPC)
44 | return 0, status.Error(codes.Unauthenticated, "客户端认证失败")
45 | }
46 | singleton.UserLock.RUnlock()
47 |
48 | model.UnblockIP(singleton.DB, ip, model.BlockIDgRPC)
49 |
50 | var clientUUID string
51 | if value, ok := md["client_uuid"]; ok {
52 | clientUUID = value[0]
53 | }
54 |
55 | if _, err := uuid.ParseUUID(clientUUID); err != nil {
56 | return 0, status.Error(codes.Unauthenticated, "客户端 UUID 不合法")
57 | }
58 |
59 | clientID, hasID := singleton.ServerShared.UUIDToID(clientUUID)
60 | if !hasID {
61 | s := model.Server{UUID: clientUUID, Name: petname.Generate(2, "-"), Common: model.Common{
62 | UserID: userId,
63 | }}
64 | if err := singleton.DB.Create(&s).Error; err != nil {
65 | return 0, status.Error(codes.Unauthenticated, err.Error())
66 | }
67 |
68 | model.InitServer(&s)
69 | singleton.ServerShared.Update(&s, clientUUID)
70 |
71 | clientID = s.ID
72 | }
73 |
74 | return clientID, nil
75 | }
76 |
--------------------------------------------------------------------------------
/service/rpc/io_stream.go:
--------------------------------------------------------------------------------
1 | package rpc
2 |
3 | import (
4 | "errors"
5 | "io"
6 | "sync"
7 | "sync/atomic"
8 | "time"
9 |
10 | "github.com/nezhahq/nezha/service/singleton"
11 | )
12 |
13 | type ioStreamContext struct {
14 | userIo io.ReadWriteCloser
15 | agentIo io.ReadWriteCloser
16 | userIoConnectCh chan struct{}
17 | agentIoConnectCh chan struct{}
18 | userIoChOnce sync.Once
19 | agentIoChOnce sync.Once
20 | }
21 |
22 | type bp struct {
23 | buf []byte
24 | }
25 |
26 | var bufPool = sync.Pool{
27 | New: func() any {
28 | return &bp{
29 | buf: make([]byte, 1024*1024),
30 | }
31 | },
32 | }
33 |
34 | func (s *NezhaHandler) CreateStream(streamId string) {
35 | s.ioStreamMutex.Lock()
36 | defer s.ioStreamMutex.Unlock()
37 |
38 | s.ioStreams[streamId] = &ioStreamContext{
39 | userIoConnectCh: make(chan struct{}),
40 | agentIoConnectCh: make(chan struct{}),
41 | }
42 | }
43 |
44 | func (s *NezhaHandler) GetStream(streamId string) (*ioStreamContext, error) {
45 | s.ioStreamMutex.RLock()
46 | defer s.ioStreamMutex.RUnlock()
47 |
48 | if ctx, ok := s.ioStreams[streamId]; ok {
49 | return ctx, nil
50 | }
51 |
52 | return nil, errors.New("stream not found")
53 | }
54 |
55 | func (s *NezhaHandler) CloseStream(streamId string) error {
56 | s.ioStreamMutex.Lock()
57 | defer s.ioStreamMutex.Unlock()
58 |
59 | if ctx, ok := s.ioStreams[streamId]; ok {
60 | if ctx.userIo != nil {
61 | ctx.userIo.Close()
62 | }
63 | if ctx.agentIo != nil {
64 | ctx.agentIo.Close()
65 | }
66 | delete(s.ioStreams, streamId)
67 | }
68 |
69 | return nil
70 | }
71 |
72 | func (s *NezhaHandler) UserConnected(streamId string, userIo io.ReadWriteCloser) error {
73 | stream, err := s.GetStream(streamId)
74 | if err != nil {
75 | return err
76 | }
77 |
78 | stream.userIo = userIo
79 | stream.userIoChOnce.Do(func() {
80 | close(stream.userIoConnectCh)
81 | })
82 |
83 | return nil
84 | }
85 |
86 | func (s *NezhaHandler) AgentConnected(streamId string, agentIo io.ReadWriteCloser) error {
87 | stream, err := s.GetStream(streamId)
88 | if err != nil {
89 | return err
90 | }
91 |
92 | stream.agentIo = agentIo
93 | stream.agentIoChOnce.Do(func() {
94 | close(stream.agentIoConnectCh)
95 | })
96 |
97 | return nil
98 | }
99 |
100 | func (s *NezhaHandler) StartStream(streamId string, timeout time.Duration) error {
101 | stream, err := s.GetStream(streamId)
102 | if err != nil {
103 | return err
104 | }
105 |
106 | timeoutTimer := time.NewTimer(timeout)
107 |
108 | LOOP:
109 | for {
110 | select {
111 | case <-stream.userIoConnectCh:
112 | if stream.agentIo != nil {
113 | timeoutTimer.Stop()
114 | break LOOP
115 | }
116 | case <-stream.agentIoConnectCh:
117 | if stream.userIo != nil {
118 | timeoutTimer.Stop()
119 | break LOOP
120 | }
121 | case <-time.After(timeout):
122 | break LOOP
123 | }
124 | time.Sleep(time.Millisecond * 500)
125 | }
126 |
127 | if stream.userIo == nil && stream.agentIo == nil {
128 | return singleton.Localizer.ErrorT("timeout: no connection established")
129 | }
130 | if stream.userIo == nil {
131 | return singleton.Localizer.ErrorT("timeout: user connection not established")
132 | }
133 | if stream.agentIo == nil {
134 | return singleton.Localizer.ErrorT("timeout: agent connection not established")
135 | }
136 |
137 | isDone := new(atomic.Bool)
138 | endCh := make(chan struct{})
139 |
140 | go func() {
141 | bp := bufPool.Get().(*bp)
142 | defer bufPool.Put(bp)
143 | _, innerErr := io.CopyBuffer(stream.userIo, stream.agentIo, bp.buf)
144 | if innerErr != nil {
145 | err = innerErr
146 | }
147 | if isDone.CompareAndSwap(false, true) {
148 | close(endCh)
149 | }
150 | }()
151 | go func() {
152 | bp := bufPool.Get().(*bp)
153 | defer bufPool.Put(bp)
154 | _, innerErr := io.CopyBuffer(stream.agentIo, stream.userIo, bp.buf)
155 | if innerErr != nil {
156 | err = innerErr
157 | }
158 | if isDone.CompareAndSwap(false, true) {
159 | close(endCh)
160 | }
161 | }()
162 |
163 | <-endCh
164 | return err
165 | }
166 |
--------------------------------------------------------------------------------
/service/rpc/io_stream_test.go:
--------------------------------------------------------------------------------
1 | package rpc
2 |
3 | import (
4 | "io"
5 | "reflect"
6 | "testing"
7 | "time"
8 | )
9 |
10 | func TestIOStream(t *testing.T) {
11 | handler := NewNezhaHandler()
12 |
13 | const testStreamID = "ffffffff-ffff-ffff-ffff-ffffffffffff"
14 |
15 | handler.CreateStream(testStreamID)
16 | userIo, agentIo := newPipeReadWriter(), newPipeReadWriter()
17 | defer func() {
18 | userIo.Close()
19 | agentIo.Close()
20 | }()
21 |
22 | handler.AgentConnected(testStreamID, agentIo)
23 | handler.UserConnected(testStreamID, userIo)
24 |
25 | go handler.StartStream(testStreamID, time.Second*10)
26 |
27 | cases := [][]byte{
28 | {0, 9, 1, 3, 2, 9, 1, 4, 8},
29 | {3, 1, 3, 5, 2, 9, 5, 13, 53, 23},
30 | make([]byte, 1024),
31 | make([]byte, 1024*1024),
32 | }
33 |
34 | t.Run("WriteUserIO", func(t *testing.T) {
35 | for i, c := range cases {
36 | _, err := userIo.Write(c)
37 | if err != nil {
38 | t.Fatalf("write to userIo failed at case %d: %v", i, err)
39 | }
40 |
41 | b := make([]byte, len(c))
42 | n, err := agentIo.Read(b)
43 | if err != nil {
44 | t.Fatalf("read agentIo failed at case %d: %v", i, err)
45 | }
46 |
47 | if !reflect.DeepEqual(c, b[:n]) {
48 | t.Fatalf("expected %v, but got %v", c, b[:n])
49 | }
50 | }
51 | })
52 |
53 | t.Run("WriteAgentIO", func(t *testing.T) {
54 | for i, c := range cases {
55 | _, err := agentIo.Write(c)
56 | if err != nil {
57 | t.Fatalf("write to agentIo failed at case %d: %v", i, err)
58 | }
59 |
60 | b := make([]byte, len(c))
61 | n, err := userIo.Read(b)
62 | if err != nil {
63 | t.Fatalf("read userIo failed at case %d: %v", i, err)
64 | }
65 |
66 | if !reflect.DeepEqual(c, b[:n]) {
67 | t.Fatalf("Expected %v, but got %v", c, b[:n])
68 | }
69 | }
70 | })
71 |
72 | t.Run("WriteUserIOReadTwice", func(t *testing.T) {
73 | data := []byte{1, 2, 3, 4, 5, 6, 7, 8}
74 | _, err := agentIo.Write(data)
75 | if err != nil {
76 | t.Fatalf("write to agentIo failed: %v", err)
77 | }
78 |
79 | b := make([]byte, len(data)/2)
80 | n, err := userIo.Read(b)
81 | if err != nil {
82 | t.Fatalf("read userIo failed: %v", err)
83 | }
84 |
85 | b2 := make([]byte, len(data)-n)
86 | _, err = userIo.Read(b2)
87 | if err != nil {
88 | t.Fatalf("read userIo failed: %v", err)
89 | }
90 |
91 | if !reflect.DeepEqual(data[:len(data)/2], b) {
92 | t.Fatalf("expected %v, but got %v", data[:len(data)/2], b)
93 | }
94 |
95 | if !reflect.DeepEqual(data[len(data)/2:], b2) {
96 | t.Fatalf("expected %v, but got %v", data[len(data)/2:], b2)
97 | }
98 | })
99 | }
100 |
101 | func newPipeReadWriter() io.ReadWriteCloser {
102 | r, w := io.Pipe()
103 | return struct {
104 | io.Reader
105 | io.WriteCloser
106 | }{r, w}
107 | }
108 |
--------------------------------------------------------------------------------
/service/singleton/config.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "strconv"
5 | "strings"
6 |
7 | "github.com/nezhahq/nezha/model"
8 | "github.com/nezhahq/nezha/pkg/utils"
9 | )
10 |
11 | var Conf *ConfigClass
12 |
13 | type ConfigClass struct {
14 | *model.Config
15 |
16 | IgnoredIPNotificationServerIDs map[uint64]bool `json:"ignored_ip_notification_server_ids,omitempty"`
17 | Oauth2Providers []string `json:"oauth2_providers,omitempty"`
18 | }
19 |
20 | // InitConfigFromPath 从给出的文件路径中加载配置
21 | func InitConfigFromPath(path string) error {
22 | Conf = &ConfigClass{
23 | Config: &model.Config{},
24 | }
25 | err := Conf.Read(path, FrontendTemplates)
26 | if err != nil {
27 | return err
28 | }
29 |
30 | Conf.updateIgnoredIPNotificationID()
31 | Conf.Oauth2Providers = utils.MapKeysToSlice(Conf.Oauth2)
32 | return nil
33 | }
34 |
35 | func (c *ConfigClass) Save() error {
36 | c.updateIgnoredIPNotificationID()
37 | return c.Config.Save()
38 | }
39 |
40 | // updateIgnoredIPNotificationID 更新用于判断服务器ID是否属于特定服务器的map
41 | func (c *ConfigClass) updateIgnoredIPNotificationID() {
42 | if c.IgnoredIPNotification == "" {
43 | return
44 | }
45 |
46 | c.IgnoredIPNotificationServerIDs = make(map[uint64]bool)
47 | for splitedID := range strings.SplitSeq(c.IgnoredIPNotification, ",") {
48 | id, _ := strconv.ParseUint(splitedID, 10, 64)
49 | if id > 0 {
50 | c.IgnoredIPNotificationServerIDs[id] = true
51 | }
52 | }
53 | }
54 |
--------------------------------------------------------------------------------
/service/singleton/crontask.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "cmp"
5 | "fmt"
6 | "slices"
7 | "strings"
8 |
9 | "github.com/jinzhu/copier"
10 |
11 | "github.com/robfig/cron/v3"
12 |
13 | "github.com/nezhahq/nezha/model"
14 | "github.com/nezhahq/nezha/pkg/utils"
15 | pb "github.com/nezhahq/nezha/proto"
16 | )
17 |
18 | type CronClass struct {
19 | class[uint64, *model.Cron]
20 | *cron.Cron
21 | }
22 |
23 | func NewCronClass() *CronClass {
24 | cronx := cron.New(cron.WithSeconds(), cron.WithLocation(Loc))
25 | list := make(map[uint64]*model.Cron)
26 |
27 | var sortedList []*model.Cron
28 | DB.Find(&sortedList)
29 |
30 | var err error
31 | var notificationGroupList []uint64
32 | notificationMsgMap := make(map[uint64]*strings.Builder)
33 |
34 | for _, cron := range sortedList {
35 | // 触发任务类型无需注册
36 | if cron.TaskType == model.CronTypeTriggerTask {
37 | list[cron.ID] = cron
38 | continue
39 | }
40 | // 注册计划任务
41 | cron.CronJobID, err = cronx.AddFunc(cron.Scheduler, CronTrigger(cron))
42 | if err == nil {
43 | list[cron.ID] = cron
44 | } else {
45 | // 当前通知组首次出现 将其加入通知组列表并初始化通知组消息缓存
46 | if _, ok := notificationMsgMap[cron.NotificationGroupID]; !ok {
47 | notificationGroupList = append(notificationGroupList, cron.NotificationGroupID)
48 | notificationMsgMap[cron.NotificationGroupID] = new(strings.Builder)
49 | notificationMsgMap[cron.NotificationGroupID].WriteString(Localizer.T("Tasks failed to register: ["))
50 | }
51 | notificationMsgMap[cron.NotificationGroupID].WriteString(fmt.Sprintf("%d,", cron.ID))
52 | }
53 | }
54 |
55 | // 向注册错误的计划任务所在通知组发送通知
56 | for _, gid := range notificationGroupList {
57 | notificationMsgMap[gid].WriteString(Localizer.T("] These tasks will not execute properly. Fix them in the admin dashboard."))
58 | NotificationShared.SendNotification(gid, notificationMsgMap[gid].String(), "")
59 | }
60 | cronx.Start()
61 |
62 | return &CronClass{
63 | class: class[uint64, *model.Cron]{
64 | list: list,
65 | sortedList: sortedList,
66 | },
67 | Cron: cronx,
68 | }
69 | }
70 |
71 | func (c *CronClass) Update(cr *model.Cron) {
72 | c.listMu.Lock()
73 | crOld := c.list[cr.ID]
74 | if crOld != nil && crOld.CronJobID != 0 {
75 | c.Cron.Remove(crOld.CronJobID)
76 | }
77 |
78 | delete(c.list, cr.ID)
79 | c.list[cr.ID] = cr
80 | c.listMu.Unlock()
81 |
82 | c.sortList()
83 | }
84 |
85 | func (c *CronClass) Delete(idList []uint64) {
86 | c.listMu.Lock()
87 | for _, id := range idList {
88 | cr := c.list[id]
89 | if cr != nil && cr.CronJobID != 0 {
90 | c.Cron.Remove(cr.CronJobID)
91 | }
92 | delete(c.list, id)
93 | }
94 | c.listMu.Unlock()
95 |
96 | c.sortList()
97 | }
98 |
99 | func (c *CronClass) sortList() {
100 | c.listMu.RLock()
101 | defer c.listMu.RUnlock()
102 |
103 | sortedList := utils.MapValuesToSlice(c.list)
104 | slices.SortFunc(sortedList, func(a, b *model.Cron) int {
105 | return cmp.Compare(a.ID, b.ID)
106 | })
107 |
108 | c.sortedListMu.Lock()
109 | defer c.sortedListMu.Unlock()
110 | c.sortedList = sortedList
111 | }
112 |
113 | func (c *CronClass) SendTriggerTasks(taskIDs []uint64, triggerServer uint64) {
114 | c.listMu.RLock()
115 | var cronLists []*model.Cron
116 | for _, taskID := range taskIDs {
117 | if c, ok := c.list[taskID]; ok {
118 | cronLists = append(cronLists, c)
119 | }
120 | }
121 | c.listMu.RUnlock()
122 |
123 | // 依次调用CronTrigger发送任务
124 | for _, c := range cronLists {
125 | go CronTrigger(c, triggerServer)()
126 | }
127 | }
128 |
129 | func ManualTrigger(cr *model.Cron) {
130 | CronTrigger(cr)()
131 | }
132 |
133 | func CronTrigger(cr *model.Cron, triggerServer ...uint64) func() {
134 | crIgnoreMap := make(map[uint64]bool)
135 | for _, server := range cr.Servers {
136 | crIgnoreMap[server] = true
137 | }
138 | return func() {
139 | if cr.Cover == model.CronCoverAlertTrigger {
140 | if len(triggerServer) == 0 {
141 | return
142 | }
143 | if s, ok := ServerShared.Get(triggerServer[0]); ok {
144 | if s.TaskStream != nil {
145 | s.TaskStream.Send(&pb.Task{
146 | Id: cr.ID,
147 | Data: cr.Command,
148 | Type: model.TaskTypeCommand,
149 | })
150 | } else {
151 | // 保存当前服务器状态信息
152 | curServer := model.Server{}
153 | copier.Copy(&curServer, s)
154 | go NotificationShared.SendNotification(cr.NotificationGroupID, Localizer.Tf("[Task failed] %s: server %s is offline and cannot execute the task", cr.Name, s.Name), "", &curServer)
155 | }
156 | }
157 | return
158 | }
159 |
160 | for _, s := range ServerShared.Range {
161 | if cr.Cover == model.CronCoverAll && crIgnoreMap[s.ID] {
162 | continue
163 | }
164 | if cr.Cover == model.CronCoverIgnoreAll && !crIgnoreMap[s.ID] {
165 | continue
166 | }
167 | if s.TaskStream != nil {
168 | s.TaskStream.Send(&pb.Task{
169 | Id: cr.ID,
170 | Data: cr.Command,
171 | Type: model.TaskTypeCommand,
172 | })
173 | } else {
174 | // 保存当前服务器状态信息
175 | curServer := model.Server{}
176 | copier.Copy(&curServer, s)
177 | go NotificationShared.SendNotification(cr.NotificationGroupID, Localizer.Tf("[Task failed] %s: server %s is offline and cannot execute the task", cr.Name, s.Name), "", &curServer)
178 | }
179 | }
180 | }
181 | }
182 |
--------------------------------------------------------------------------------
/service/singleton/ddns.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "cmp"
5 | "fmt"
6 | "slices"
7 |
8 | "github.com/libdns/cloudflare"
9 | "github.com/libdns/he"
10 | tencentcloud "github.com/nezhahq/libdns-tencentcloud"
11 |
12 | "github.com/nezhahq/nezha/model"
13 | ddns2 "github.com/nezhahq/nezha/pkg/ddns"
14 | "github.com/nezhahq/nezha/pkg/ddns/dummy"
15 | "github.com/nezhahq/nezha/pkg/ddns/webhook"
16 | "github.com/nezhahq/nezha/pkg/utils"
17 | )
18 |
19 | type DDNSClass struct {
20 | class[uint64, *model.DDNSProfile]
21 | }
22 |
23 | func NewDDNSClass() *DDNSClass {
24 | var sortedList []*model.DDNSProfile
25 |
26 | DB.Find(&sortedList)
27 | list := make(map[uint64]*model.DDNSProfile, len(sortedList))
28 | for _, profile := range sortedList {
29 | list[profile.ID] = profile
30 | }
31 |
32 | dc := &DDNSClass{
33 | class: class[uint64, *model.DDNSProfile]{
34 | list: list,
35 | sortedList: sortedList,
36 | },
37 | }
38 | return dc
39 | }
40 |
41 | func (c *DDNSClass) Update(p *model.DDNSProfile) {
42 | c.listMu.Lock()
43 | c.list[p.ID] = p
44 | c.listMu.Unlock()
45 |
46 | c.sortList()
47 | }
48 |
49 | func (c *DDNSClass) Delete(idList []uint64) {
50 | c.listMu.Lock()
51 | for _, id := range idList {
52 | delete(c.list, id)
53 | }
54 | c.listMu.Unlock()
55 |
56 | c.sortList()
57 | }
58 |
59 | func (c *DDNSClass) GetDDNSProvidersFromProfiles(profileId []uint64, ip *model.IP) ([]*ddns2.Provider, error) {
60 | profiles := make([]*model.DDNSProfile, 0, len(profileId))
61 |
62 | c.listMu.RLock()
63 | for _, id := range profileId {
64 | if profile, ok := c.list[id]; ok {
65 | profiles = append(profiles, profile)
66 | } else {
67 | c.listMu.RUnlock()
68 | return nil, fmt.Errorf("cannot find DDNS profile %d", id)
69 | }
70 | }
71 | c.listMu.RUnlock()
72 |
73 | providers := make([]*ddns2.Provider, 0, len(profiles))
74 | for _, profile := range profiles {
75 | provider := &ddns2.Provider{DDNSProfile: profile, IPAddrs: ip}
76 | switch profile.Provider {
77 | case model.ProviderDummy:
78 | provider.Setter = &dummy.Provider{}
79 | providers = append(providers, provider)
80 | case model.ProviderWebHook:
81 | provider.Setter = &webhook.Provider{DDNSProfile: profile}
82 | providers = append(providers, provider)
83 | case model.ProviderCloudflare:
84 | provider.Setter = &cloudflare.Provider{APIToken: profile.AccessSecret}
85 | providers = append(providers, provider)
86 | case model.ProviderTencentCloud:
87 | provider.Setter = &tencentcloud.Provider{SecretId: profile.AccessID, SecretKey: profile.AccessSecret}
88 | providers = append(providers, provider)
89 | case model.ProviderHE:
90 | provider.Setter = &he.Provider{APIKey: profile.AccessSecret}
91 | providers = append(providers, provider)
92 | default:
93 | return nil, fmt.Errorf("cannot find DDNS provider %s", profile.Provider)
94 | }
95 | }
96 | return providers, nil
97 | }
98 |
99 | func (c *DDNSClass) sortList() {
100 | c.listMu.RLock()
101 | defer c.listMu.RUnlock()
102 |
103 | sortedList := utils.MapValuesToSlice(c.list)
104 | slices.SortFunc(sortedList, func(a, b *model.DDNSProfile) int {
105 | return cmp.Compare(a.ID, b.ID)
106 | })
107 |
108 | c.sortedListMu.Lock()
109 | defer c.sortedListMu.Unlock()
110 | c.sortedList = sortedList
111 | }
112 |
--------------------------------------------------------------------------------
/service/singleton/frontend-templates.yaml:
--------------------------------------------------------------------------------
1 | - path: "admin-dist"
2 | name: "OfficialAdmin"
3 | repository: "https://github.com/nezhahq/admin-frontend"
4 | author: "nezhahq"
5 | version: "v1.12.0"
6 | is_admin: true
7 | is_official: true
8 | - path: "user-dist"
9 | name: "Official"
10 | repository: "https://github.com/hamster1963/nezha-dash-v1"
11 | author: "hamster1963"
12 | version: "v1.30.2"
13 | is_official: true
14 | - path: "nezha-ascii-dist"
15 | name: "Nezha-ASCII"
16 | repository: "https://github.com/hamster1963/nezha-ascii"
17 | author: "hamster1963"
18 | version: "v1.1.0"
19 | - path: "nazhua-dist"
20 | name: "Nazhua"
21 | repository: "https://github.com/hi2shark/nazhua"
22 | author: "hi2hi"
23 | version: "v0.6.6"
24 |
--------------------------------------------------------------------------------
/service/singleton/i18n.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "log"
5 | "strings"
6 |
7 | "github.com/nezhahq/nezha/pkg/i18n"
8 | )
9 |
10 | const domain = "nezha"
11 |
12 | var Localizer *i18n.Localizer
13 |
14 | func initI18n() {
15 | if err := loadTranslation(); err != nil {
16 | log.Printf("NEZHA>> init i18n failed: %v", err)
17 | }
18 | }
19 |
20 | func loadTranslation() error {
21 | lang := Conf.Language
22 | if lang == "" {
23 | lang = "zh_CN"
24 | }
25 |
26 | lang = strings.Replace(lang, "-", "_", 1)
27 | Localizer = i18n.NewLocalizer(lang, domain, "translations", i18n.Translations)
28 | return nil
29 | }
30 |
31 | func OnUpdateLang(lang string) error {
32 | lang = strings.Replace(lang, "-", "_", 1)
33 | if Localizer.Exists(lang) {
34 | Localizer.SetLanguage(lang)
35 | return nil
36 | }
37 |
38 | Localizer.AppendIntl(lang)
39 | Localizer.SetLanguage(lang)
40 | return nil
41 | }
42 |
--------------------------------------------------------------------------------
/service/singleton/nat.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "cmp"
5 | "slices"
6 |
7 | "github.com/nezhahq/nezha/model"
8 | "github.com/nezhahq/nezha/pkg/utils"
9 | )
10 |
11 | type NATClass struct {
12 | class[string, *model.NAT]
13 |
14 | idToDomain map[uint64]string
15 | }
16 |
17 | func NewNATClass() *NATClass {
18 | var sortedList []*model.NAT
19 |
20 | DB.Find(&sortedList)
21 | list := make(map[string]*model.NAT, len(sortedList))
22 | idToDomain := make(map[uint64]string, len(sortedList))
23 | for _, profile := range sortedList {
24 | list[profile.Domain] = profile
25 | idToDomain[profile.ID] = profile.Domain
26 | }
27 |
28 | return &NATClass{
29 | class: class[string, *model.NAT]{
30 | list: list,
31 | sortedList: sortedList,
32 | },
33 | idToDomain: idToDomain,
34 | }
35 | }
36 |
37 | func (c *NATClass) Update(n *model.NAT) {
38 | c.listMu.Lock()
39 |
40 | if oldDomain, ok := c.idToDomain[n.ID]; ok && oldDomain != n.Domain {
41 | delete(c.list, oldDomain)
42 | }
43 |
44 | c.list[n.Domain] = n
45 | c.idToDomain[n.ID] = n.Domain
46 |
47 | c.listMu.Unlock()
48 | c.sortList()
49 | }
50 |
51 | func (c *NATClass) Delete(idList []uint64) {
52 | c.listMu.Lock()
53 |
54 | for _, id := range idList {
55 | if domain, ok := c.idToDomain[id]; ok {
56 | delete(c.list, domain)
57 | delete(c.idToDomain, id)
58 | }
59 | }
60 |
61 | c.listMu.Unlock()
62 | c.sortList()
63 | }
64 |
65 | func (c *NATClass) GetNATConfigByDomain(domain string) *model.NAT {
66 | c.listMu.RLock()
67 | defer c.listMu.RUnlock()
68 |
69 | return c.list[domain]
70 | }
71 |
72 | func (c *NATClass) GetDomain(id uint64) string {
73 | c.listMu.RLock()
74 | defer c.listMu.RUnlock()
75 |
76 | return c.idToDomain[id]
77 | }
78 |
79 | func (c *NATClass) sortList() {
80 | c.listMu.RLock()
81 | defer c.listMu.RUnlock()
82 |
83 | sortedList := utils.MapValuesToSlice(c.list)
84 | slices.SortFunc(sortedList, func(a, b *model.NAT) int {
85 | return cmp.Compare(a.ID, b.ID)
86 | })
87 |
88 | c.sortedListMu.Lock()
89 | defer c.sortedListMu.Unlock()
90 | c.sortedList = sortedList
91 | }
92 |
--------------------------------------------------------------------------------
/service/singleton/online_user.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "slices"
5 | "sync"
6 |
7 | "github.com/nezhahq/nezha/model"
8 | "github.com/nezhahq/nezha/pkg/utils"
9 | )
10 |
11 | var (
12 | OnlineUserMap = make(map[string]*model.OnlineUser)
13 | OnlineUserMapLock sync.Mutex
14 | )
15 |
16 | func AddOnlineUser(connId string, user *model.OnlineUser) {
17 | OnlineUserMapLock.Lock()
18 | defer OnlineUserMapLock.Unlock()
19 | OnlineUserMap[connId] = user
20 | }
21 |
22 | func RemoveOnlineUser(connId string) {
23 | OnlineUserMapLock.Lock()
24 | defer OnlineUserMapLock.Unlock()
25 | delete(OnlineUserMap, connId)
26 | }
27 |
28 | func BlockByIPs(ipList []string) error {
29 | OnlineUserMapLock.Lock()
30 | defer OnlineUserMapLock.Unlock()
31 |
32 | for _, ip := range ipList {
33 | if err := model.BlockIP(DB, ip, model.WAFBlockReasonTypeManual, model.BlockIDManual); err != nil {
34 | return err
35 | }
36 | for _, user := range OnlineUserMap {
37 | if user.IP == ip && user.Conn != nil {
38 | user.Conn.Close()
39 | }
40 | }
41 | }
42 |
43 | return nil
44 | }
45 |
46 | func GetOnlineUsers(limit, offset int) []*model.OnlineUser {
47 | OnlineUserMapLock.Lock()
48 | defer OnlineUserMapLock.Unlock()
49 | users := utils.MapValuesToSlice(OnlineUserMap)
50 | slices.SortFunc(users, func(i, j *model.OnlineUser) int {
51 | return i.ConnectedAt.Compare(j.ConnectedAt)
52 | })
53 | if offset > len(users) {
54 | return nil
55 | }
56 | if offset+limit > len(users) {
57 | return users[offset:]
58 | }
59 | return users[offset : offset+limit]
60 | }
61 |
62 | func GetOnlineUserCount() int {
63 | OnlineUserMapLock.Lock()
64 | defer OnlineUserMapLock.Unlock()
65 | return len(OnlineUserMap)
66 | }
67 |
--------------------------------------------------------------------------------
/service/singleton/server.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "cmp"
5 | "context"
6 | "log"
7 | "slices"
8 | "strings"
9 |
10 | "github.com/nezhahq/nezha/model"
11 | "github.com/nezhahq/nezha/pkg/ddns"
12 | "github.com/nezhahq/nezha/pkg/utils"
13 | )
14 |
15 | type ServerClass struct {
16 | class[uint64, *model.Server]
17 |
18 | uuidToID map[string]uint64
19 |
20 | sortedListForGuest []*model.Server
21 | }
22 |
23 | func NewServerClass() *ServerClass {
24 | sc := &ServerClass{
25 | class: class[uint64, *model.Server]{
26 | list: make(map[uint64]*model.Server),
27 | },
28 | uuidToID: make(map[string]uint64),
29 | }
30 |
31 | var servers []model.Server
32 | DB.Find(&servers)
33 | for _, s := range servers {
34 | innerS := s
35 | model.InitServer(&innerS)
36 | sc.list[innerS.ID] = &innerS
37 | sc.uuidToID[innerS.UUID] = innerS.ID
38 | }
39 | sc.sortList()
40 |
41 | return sc
42 | }
43 |
44 | func (c *ServerClass) Update(s *model.Server, uuid string) {
45 | c.listMu.Lock()
46 |
47 | c.list[s.ID] = s
48 | if uuid != "" {
49 | c.uuidToID[uuid] = s.ID
50 | }
51 |
52 | c.listMu.Unlock()
53 |
54 | if s.EnableDDNS {
55 | if err := c.UpdateDDNS(s, nil); err != nil {
56 | log.Printf("NEZHA>> Failed to update DDNS for server %d: %v", err, s.ID)
57 | }
58 | }
59 |
60 | c.sortList()
61 | }
62 |
63 | func (c *ServerClass) Delete(idList []uint64) {
64 | c.listMu.Lock()
65 |
66 | for _, id := range idList {
67 | serverUUID := c.list[id].UUID
68 | delete(c.uuidToID, serverUUID)
69 | delete(c.list, id)
70 | }
71 |
72 | c.listMu.Unlock()
73 |
74 | c.sortList()
75 | }
76 |
77 | func (c *ServerClass) GetSortedListForGuest() []*model.Server {
78 | c.sortedListMu.RLock()
79 | defer c.sortedListMu.RUnlock()
80 |
81 | return slices.Clone(c.sortedListForGuest)
82 | }
83 |
84 | func (c *ServerClass) UUIDToID(uuid string) (id uint64, ok bool) {
85 | c.listMu.RLock()
86 | defer c.listMu.RUnlock()
87 |
88 | id, ok = c.uuidToID[uuid]
89 | return
90 | }
91 |
92 | func (c *ServerClass) UpdateDDNS(server *model.Server, ip *model.IP) error {
93 | confServers := strings.Split(Conf.DNSServers, ",")
94 | ctx := context.WithValue(context.Background(), ddns.DNSServerKey{}, utils.IfOr(confServers[0] != "", confServers, utils.DNSServers))
95 |
96 | providers, err := DDNSShared.GetDDNSProvidersFromProfiles(server.DDNSProfiles, utils.IfOr(ip != nil, ip, &server.GeoIP.IP))
97 | if err != nil {
98 | return err
99 | }
100 |
101 | for _, provider := range providers {
102 | domains := server.OverrideDDNSDomains[provider.GetProfileID()]
103 | go func(provider *ddns.Provider) {
104 | provider.UpdateDomain(ctx, domains...)
105 | }(provider)
106 | }
107 |
108 | return nil
109 | }
110 |
111 | func (c *ServerClass) sortList() {
112 | c.listMu.RLock()
113 | defer c.listMu.RUnlock()
114 | c.sortedListMu.Lock()
115 | defer c.sortedListMu.Unlock()
116 |
117 | c.sortedList = utils.MapValuesToSlice(c.list)
118 | // 按照服务器 ID 排序的具体实现(ID越大越靠前)
119 | slices.SortStableFunc(c.sortedList, func(a, b *model.Server) int {
120 | if a.DisplayIndex == b.DisplayIndex {
121 | return cmp.Compare(a.ID, b.ID)
122 | }
123 | return cmp.Compare(b.DisplayIndex, a.DisplayIndex)
124 | })
125 |
126 | c.sortedListForGuest = make([]*model.Server, 0, len(c.sortedList))
127 | for _, s := range c.sortedList {
128 | if !s.HideForGuest {
129 | c.sortedListForGuest = append(c.sortedListForGuest, s)
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/service/singleton/user.go:
--------------------------------------------------------------------------------
1 | package singleton
2 |
3 | import (
4 | "fmt"
5 | "sync"
6 |
7 | "github.com/nezhahq/nezha/model"
8 | "github.com/nezhahq/nezha/pkg/utils"
9 | "gorm.io/gorm"
10 | )
11 |
12 | var (
13 | UserInfoMap map[uint64]model.UserInfo
14 | AgentSecretToUserId map[string]uint64
15 |
16 | UserLock sync.RWMutex
17 | )
18 |
19 | func initUser() {
20 | UserInfoMap = make(map[uint64]model.UserInfo)
21 | AgentSecretToUserId = make(map[string]uint64)
22 |
23 | var users []model.User
24 | DB.Find(&users)
25 |
26 | // for backward compatibility
27 | UserInfoMap[0] = model.UserInfo{
28 | Role: model.RoleAdmin,
29 | AgentSecret: Conf.AgentSecretKey,
30 | }
31 | AgentSecretToUserId[Conf.AgentSecretKey] = 0
32 |
33 | for _, u := range users {
34 | if u.AgentSecret == "" {
35 | u.AgentSecret = utils.MustGenerateRandomString(model.DefaultAgentSecretLength)
36 | if err := DB.Save(&u).Error; err != nil {
37 | panic(fmt.Errorf("update of user %d failed: %v", u.ID, err))
38 | }
39 | }
40 |
41 | UserInfoMap[u.ID] = model.UserInfo{
42 | Role: u.Role,
43 | AgentSecret: u.AgentSecret,
44 | }
45 | AgentSecretToUserId[u.AgentSecret] = u.ID
46 | }
47 | }
48 |
49 | func OnUserUpdate(u *model.User) {
50 | UserLock.Lock()
51 | defer UserLock.Unlock()
52 |
53 | if u == nil {
54 | return
55 | }
56 |
57 | UserInfoMap[u.ID] = model.UserInfo{
58 | Role: u.Role,
59 | AgentSecret: u.AgentSecret,
60 | }
61 | AgentSecretToUserId[u.AgentSecret] = u.ID
62 | }
63 |
64 | func OnUserDelete(id []uint64, errorFunc func(string, ...any) error) error {
65 | UserLock.Lock()
66 | defer UserLock.Unlock()
67 |
68 | if len(id) < 1 {
69 | return Localizer.ErrorT("user id not specified")
70 | }
71 |
72 | var (
73 | cron, server bool
74 | crons, servers []uint64
75 | )
76 |
77 | slist := ServerShared.GetSortedList()
78 | clist := CronShared.GetSortedList()
79 | for _, uid := range id {
80 | err := DB.Transaction(func(tx *gorm.DB) error {
81 | crons = model.FindByUserID(clist, uid)
82 | cron = len(crons) > 0
83 | if cron {
84 | if err := tx.Unscoped().Delete(&model.Cron{}, "id in (?)", crons).Error; err != nil {
85 | return err
86 | }
87 | }
88 |
89 | servers = model.FindByUserID(slist, uid)
90 | server = len(servers) > 0
91 | if server {
92 | if err := tx.Unscoped().Delete(&model.Server{}, "id in (?)", servers).Error; err != nil {
93 | return err
94 | }
95 | if err := tx.Unscoped().Delete(&model.ServerGroupServer{}, "server_id in (?)", servers).Error; err != nil {
96 | return err
97 | }
98 | }
99 |
100 | if err := tx.Unscoped().Delete(&model.Transfer{}, "server_id in (?)", servers).Error; err != nil {
101 | return err
102 | }
103 |
104 | if err := tx.Where("id IN (?)", id).Delete(&model.User{}).Error; err != nil {
105 | return err
106 | }
107 | return nil
108 | })
109 |
110 | if err != nil {
111 | return errorFunc("%v", err)
112 | }
113 |
114 | if cron {
115 | CronShared.Delete(crons)
116 | }
117 |
118 | if server {
119 | AlertsLock.Lock()
120 | for _, sid := range servers {
121 | for _, alert := range Alerts {
122 | if AlertsCycleTransferStatsStore[alert.ID] != nil {
123 | delete(AlertsCycleTransferStatsStore[alert.ID].ServerName, sid)
124 | delete(AlertsCycleTransferStatsStore[alert.ID].Transfer, sid)
125 | delete(AlertsCycleTransferStatsStore[alert.ID].NextUpdate, sid)
126 | }
127 | }
128 | }
129 | AlertsLock.Unlock()
130 | ServerShared.Delete(servers)
131 | }
132 |
133 | secret := UserInfoMap[uid].AgentSecret
134 | delete(AgentSecretToUserId, secret)
135 | delete(UserInfoMap, uid)
136 | }
137 | return nil
138 | }
139 |
--------------------------------------------------------------------------------