├── .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 | --------------------------------------------------------------------------------