Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6112a919 | ||
|
|
23751440c6 | ||
|
|
989c9f9740 | ||
|
|
71508f8724 | ||
|
|
eb6dd2855e | ||
|
|
64da3c7438 | ||
|
|
fd85f599ba | ||
|
|
cae443d5c8 | ||
|
|
0ec1bb2c54 | ||
|
|
47f8447a22 | ||
|
|
9c986d06cb |
+16
-12
@@ -97,20 +97,24 @@ def sync_to_gitee(tag: str, body: str, files: slice):
|
|||||||
asset_api_uri = f"{release_api_uri}/{release_id}/attach_files"
|
asset_api_uri = f"{release_api_uri}/{release_id}/attach_files"
|
||||||
|
|
||||||
for file_path in files:
|
for file_path in files:
|
||||||
files = {
|
success = False
|
||||||
'file': open(file_path, 'rb')
|
|
||||||
}
|
|
||||||
|
|
||||||
asset_api_response = requests.post(
|
while not success:
|
||||||
asset_api_uri, params={'access_token': access_token}, files=files)
|
files = {
|
||||||
|
'file': open(file_path, 'rb')
|
||||||
|
}
|
||||||
|
|
||||||
if asset_api_response.status_code == 201:
|
asset_api_response = requests.post(
|
||||||
asset_info = asset_api_response.json()
|
asset_api_uri, params={'access_token': access_token}, files=files)
|
||||||
asset_name = asset_info.get('name')
|
|
||||||
print(f"Successfully uploaded {asset_name}!")
|
if asset_api_response.status_code == 201:
|
||||||
else:
|
asset_info = asset_api_response.json()
|
||||||
print(
|
asset_name = asset_info.get('name')
|
||||||
f"Request failed with status code {asset_api_response.status_code}")
|
print(f"Successfully uploaded {asset_name}!")
|
||||||
|
success = True
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"Request failed with status code {asset_api_response.status_code}")
|
||||||
|
|
||||||
# 仅保留最新 Release 以防超出 Gitee 仓库配额
|
# 仅保留最新 Release 以防超出 Gitee 仓库配额
|
||||||
try:
|
try:
|
||||||
|
|||||||
+118
-73
@@ -4,54 +4,138 @@ on:
|
|||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
build:
|
||||||
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
goos: [linux, windows]
|
||||||
|
goarch: [amd64]
|
||||||
|
include:
|
||||||
|
- goos: linux
|
||||||
|
goarch: s390x
|
||||||
|
- goos: linux
|
||||||
|
goarch: arm64
|
||||||
|
|
||||||
|
name: Build artifacts
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: goreleaser/goreleaser-cross:v1.21
|
||||||
|
env:
|
||||||
|
GOOS: ${{ matrix.goos }}
|
||||||
|
GOARCH: ${{ matrix.goarch }}
|
||||||
|
GOARM: ${{ matrix.goarm }}
|
||||||
|
outputs:
|
||||||
|
tag_name: ${{ steps.extract_branch.outputs.tag }}
|
||||||
steps:
|
steps:
|
||||||
|
- run: git config --global --add safe.directory /__w/nezha/nezha
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Fetch IPInfo GeoIP Database
|
- name: Fetch IPInfo GeoIP Database
|
||||||
env:
|
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
|
||||||
|
env:
|
||||||
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
|
IPINFO_TOKEN: ${{ secrets.IPINFO_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
rm pkg/geoip/geoip.db
|
rm pkg/geoip/geoip.db
|
||||||
wget -O pkg/geoip/geoip.db https://ipinfo.io/data/free/country.mmdb?token=${IPINFO_TOKEN}
|
wget -qO pkg/geoip/geoip.db https://ipinfo.io/data/free/country.mmdb?token=${IPINFO_TOKEN}
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version: "1.21.x"
|
||||||
|
|
||||||
|
- name: Build
|
||||||
|
uses: goreleaser/goreleaser-action@v6
|
||||||
|
with:
|
||||||
|
distribution: goreleaser
|
||||||
|
version: '~> v2'
|
||||||
|
args: build --single-target --clean --skip=validate
|
||||||
|
|
||||||
|
- name: Upload artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: dashboard-${{ env.GOOS }}-${{ env.GOARCH }}
|
||||||
|
path: |
|
||||||
|
./dist/*/*
|
||||||
|
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
|
||||||
|
needs: build
|
||||||
|
name: Release
|
||||||
|
steps:
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./assets
|
||||||
|
|
||||||
|
- name: Archive and compress binaries
|
||||||
|
run: |
|
||||||
|
for file in assets/*/*/*; do
|
||||||
|
if [ -f "$file" ]; then
|
||||||
|
chmod +x "$file"
|
||||||
|
zip -jr "$file.zip" "$file"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Release
|
||||||
|
uses: ncipollo/release-action@v1
|
||||||
|
with:
|
||||||
|
artifacts: "assets/*/*/*.zip"
|
||||||
|
generateReleaseNotes: true
|
||||||
|
|
||||||
|
- name: Purge jsdelivr cache
|
||||||
|
run: |
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/install.sh
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/nezha-agent.service
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/docker-compose.yaml
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/config.yaml
|
||||||
|
LOWER_USERNAME=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/install.sh
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/nezha-agent.service
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/docker-compose.yaml
|
||||||
|
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/config.yaml
|
||||||
|
|
||||||
|
- name: Trigger sync
|
||||||
|
env:
|
||||||
|
GH_REPO: ${{ github.repository }}
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
GH_DEBUG: api
|
||||||
|
run: |
|
||||||
|
gh workflow run sync-release.yml
|
||||||
|
|
||||||
|
release-docker:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
if: github.event_name == 'push' && contains(github.ref, 'refs/tags/')
|
||||||
|
needs: build
|
||||||
|
name: Release Docker images
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Download artifacts
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
path: ./assets
|
||||||
|
|
||||||
|
- name: Fix permissions
|
||||||
|
run: |
|
||||||
|
chmod -R +x ./assets/*
|
||||||
|
mkdir dist
|
||||||
|
mv ./assets/*/*/* ./dist
|
||||||
|
|
||||||
- name: Extract branch name
|
- name: Extract branch name
|
||||||
run: |
|
run: |
|
||||||
export TAG_NAME=$(echo ${GITHUB_REF#refs/tags/})
|
export TAG_NAME=$(echo ${GITHUB_REF#refs/tags/})
|
||||||
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
|
||||||
id: extract_branch
|
id: extract_branch
|
||||||
|
|
||||||
- name: xgo build
|
- name: Log into GHCR
|
||||||
uses: crazy-max/ghaction-xgo@v2
|
|
||||||
with:
|
|
||||||
xgo_version: latest
|
|
||||||
go_version: 1.21.x
|
|
||||||
dest: dist
|
|
||||||
pkg: cmd/dashboard
|
|
||||||
prefix: dashboard
|
|
||||||
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
|
|
||||||
v: true
|
|
||||||
x: false
|
|
||||||
race: false
|
|
||||||
tags: timetzdata
|
|
||||||
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=${{ steps.extract_branch.outputs.tag }}
|
|
||||||
buildmode: default
|
|
||||||
|
|
||||||
- name: fix dist
|
|
||||||
run: |
|
|
||||||
ls -al dist/
|
|
||||||
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
|
|
||||||
|
|
||||||
- name: Log in to the GHCR
|
|
||||||
uses: docker/login-action@master
|
uses: docker/login-action@master
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.repository_owner }}
|
username: ${{ github.repository_owner }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ github.token }}
|
||||||
|
|
||||||
- name: Log in to the AliyunCS
|
- name: Log in to the AliyunCS
|
||||||
uses: docker/login-action@master
|
uses: docker/login-action@master
|
||||||
@@ -68,13 +152,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Set up image name
|
- name: Set up image name
|
||||||
run: |
|
run: |
|
||||||
GHRC_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
|
GHCR_IMAGE_NAME=$(echo "ghcr.io/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
|
||||||
if [ ${{ github.repository_owner }} = "naiba" ]
|
if [ ${{ github.repository_owner }} = "naiba" ]
|
||||||
then ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/naibahq/nezha-dashboard")
|
then ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/naibahq/nezha-dashboard")
|
||||||
else ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
|
else ALI_IMAGE_NAME=$(echo "registry.cn-shanghai.aliyuncs.com/${{ github.repository_owner }}/nezha-dashboard" | tr '[:upper:]' '[:lower:]')
|
||||||
fi
|
fi
|
||||||
echo "::set-output name=GHRC_IMAGE_NAME::$GHRC_IMAGE_NAME"
|
echo "GHCR_IMAGE_NAME=$GHCR_IMAGE_NAME" >> $GITHUB_OUTPUT
|
||||||
echo "::set-output name=ALI_IMAGE_NAME::$ALI_IMAGE_NAME"
|
echo "ALI_IMAGE_NAME=$ALI_IMAGE_NAME" >> $GITHUB_OUTPUT
|
||||||
id: image-name
|
id: image-name
|
||||||
|
|
||||||
- name: Build dasbboard image And Push
|
- name: Build dasbboard image And Push
|
||||||
@@ -82,49 +166,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
platforms: linux/amd64,linux/arm64,linux/arm,linux/s390x,linux/riscv64 # linux/386,
|
platforms: linux/amd64,linux/arm64,linux/s390x
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ steps.image-name.outputs.GHRC_IMAGE_NAME }}:latest
|
${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:latest
|
||||||
${{ steps.image-name.outputs.GHRC_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
|
${{ steps.image-name.outputs.GHCR_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
|
||||||
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:latest
|
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:latest
|
||||||
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
|
${{ steps.image-name.outputs.ALI_IMAGE_NAME }}:${{ steps.extract_branch.outputs.tag }}
|
||||||
|
|
||||||
- name: Compress dist files
|
|
||||||
run: |
|
|
||||||
for file in dist/*; do
|
|
||||||
if [ -f "$file" ]; then
|
|
||||||
zip -r "$file.zip" "$file"
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: ncipollo/release-action@v1
|
|
||||||
with:
|
|
||||||
artifacts: "dist/*.zip"
|
|
||||||
generateReleaseNotes: true
|
|
||||||
|
|
||||||
- name: Purge jsdelivr cache
|
|
||||||
run: |
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/install.sh
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/nezha-agent.service
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/docker-compose.yaml
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/${{ github.repository_owner }}/nezha@master/script/config.yaml
|
|
||||||
LOWER_USERNAME=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]')
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/install.sh
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/nezha-agent.service
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/docker-compose.yaml
|
|
||||||
curl -s https://purge.jsdelivr.net/gh/$LOWER_USERNAME/nezha@master/script/config.yaml
|
|
||||||
|
|
||||||
- name: Trigger sync
|
|
||||||
if: ${{ env.SYNCED == 0 }}
|
|
||||||
uses: actions/github-script@v6
|
|
||||||
with:
|
|
||||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
script: |
|
|
||||||
await github.rest.actions.createWorkflowDispatch({
|
|
||||||
owner: 'naiba',
|
|
||||||
repo: 'nezha',
|
|
||||||
workflow_id: 'sync-release.yml',
|
|
||||||
ref: 'master'
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
name: Run Tests on PR
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
branches:
|
|
||||||
- master
|
|
||||||
jobs:
|
|
||||||
tests:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
GO111MODULE: on
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-go@v4
|
|
||||||
with:
|
|
||||||
go-version: "^1.21.3"
|
|
||||||
- name: Unit test
|
|
||||||
run: |
|
|
||||||
go test -v ./...
|
|
||||||
- name: Run Gosec Security Scanner
|
|
||||||
uses: securego/gosec@master
|
|
||||||
with:
|
|
||||||
args: --exclude=G104,G402 ./...
|
|
||||||
- name: xgo build
|
|
||||||
uses: crazy-max/ghaction-xgo@v2
|
|
||||||
with:
|
|
||||||
xgo_version: latest
|
|
||||||
go_version: 1.21.x
|
|
||||||
dest: dist
|
|
||||||
pkg: cmd/dashboard
|
|
||||||
prefix: dashboard
|
|
||||||
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
|
|
||||||
v: true
|
|
||||||
x: false
|
|
||||||
race: false
|
|
||||||
tags: timetzdata
|
|
||||||
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=test
|
|
||||||
buildmode: default
|
|
||||||
- name: fix dist
|
|
||||||
run: |
|
|
||||||
ls -al dist/
|
|
||||||
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
|
|
||||||
|
|
||||||
+18
-22
@@ -10,40 +10,36 @@ on:
|
|||||||
- "go.sum"
|
- "go.sum"
|
||||||
- "resource/**"
|
- "resource/**"
|
||||||
- ".github/workflows/test.yml"
|
- ".github/workflows/test.yml"
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
tests:
|
tests:
|
||||||
runs-on: ubuntu-latest
|
strategy:
|
||||||
|
fail-fast: true
|
||||||
|
matrix:
|
||||||
|
os: [ubuntu, windows, macos]
|
||||||
|
|
||||||
|
runs-on: ${{ matrix.os }}-latest
|
||||||
env:
|
env:
|
||||||
GO111MODULE: on
|
GO111MODULE: on
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-go@v4
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version: "^1.21.3"
|
go-version: "1.21.x"
|
||||||
|
|
||||||
- name: Unit test
|
- name: Unit test
|
||||||
run: |
|
run: |
|
||||||
go test -v ./...
|
go test -v ./...
|
||||||
|
|
||||||
|
- name: Build test
|
||||||
|
run: go build -v ./cmd/dashboard
|
||||||
|
|
||||||
- name: Run Gosec Security Scanner
|
- name: Run Gosec Security Scanner
|
||||||
|
if: runner.os == 'Linux'
|
||||||
uses: securego/gosec@master
|
uses: securego/gosec@master
|
||||||
with:
|
with:
|
||||||
args: --exclude=G104,G402 ./...
|
args: --exclude=G104,G402 ./...
|
||||||
- name: xgo build
|
|
||||||
uses: crazy-max/ghaction-xgo@v2
|
|
||||||
with:
|
|
||||||
xgo_version: latest
|
|
||||||
go_version: 1.21.x
|
|
||||||
dest: dist
|
|
||||||
pkg: cmd/dashboard
|
|
||||||
prefix: dashboard
|
|
||||||
targets: linux/amd64,linux/arm64,linux/arm-7,linux/s390x,linux/riscv64,windows/amd64,windows/386,windows/arm64 # linux/386,
|
|
||||||
v: true
|
|
||||||
x: false
|
|
||||||
race: false
|
|
||||||
tags: timetzdata
|
|
||||||
ldflags: -s -w --extldflags '-static -fpic' -X github.com/naiba/nezha/service/singleton.Version=test
|
|
||||||
buildmode: default
|
|
||||||
- name: fix dist
|
|
||||||
run: |
|
|
||||||
ls -al dist/
|
|
||||||
mv dist/dashboard-linux-arm-7 dist/dashboard-linux-arm
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
version: 2
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy -v
|
||||||
|
builds:
|
||||||
|
- id: linux_arm64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=aarch64-linux-gnu-gcc
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
|
||||||
|
- -extldflags "-static -fpic"
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- arm64
|
||||||
|
main: ./cmd/dashboard
|
||||||
|
binary: dashboard-{{ .Os }}-{{ .Arch }}
|
||||||
|
- id: linux_amd64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=x86_64-linux-gnu-gcc
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
|
||||||
|
- -extldflags "-static -fpic"
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
main: ./cmd/dashboard
|
||||||
|
binary: dashboard-{{ .Os }}-{{ .Arch }}
|
||||||
|
- id: linux_s390x
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=s390x-linux-gnu-gcc
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
|
||||||
|
- -extldflags "-static -fpic"
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
goos:
|
||||||
|
- linux
|
||||||
|
goarch:
|
||||||
|
- s390x
|
||||||
|
main: ./cmd/dashboard
|
||||||
|
binary: dashboard-{{ .Os }}-{{ .Arch }}
|
||||||
|
- id: windows_amd64
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=x86_64-w64-mingw32-gcc
|
||||||
|
ldflags:
|
||||||
|
- -s -w
|
||||||
|
- -X github.com/naiba/nezha/service/singleton.Version={{.Version}}
|
||||||
|
- -extldflags "-static -fpic"
|
||||||
|
flags:
|
||||||
|
- -trimpath
|
||||||
|
goos:
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
main: ./cmd/dashboard
|
||||||
|
binary: dashboard-{{ .Os }}-{{ .Arch }}
|
||||||
|
snapshot:
|
||||||
|
version_template: "dashboard"
|
||||||
+7
-9
@@ -1,16 +1,12 @@
|
|||||||
# latest 镜像还没有 riscv64 https://hub.docker.com/_/alpine/tags
|
FROM alpine AS certs
|
||||||
FROM alpine:edge
|
RUN apk update && apk add ca-certificates
|
||||||
|
|
||||||
|
FROM busybox:stable-musl
|
||||||
|
|
||||||
ARG TARGETOS
|
ARG TARGETOS
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
|
|
||||||
RUN apk update && \
|
COPY --from=certs /etc/ssl/certs /etc/ssl/certs
|
||||||
apk upgrade --no-cache && \
|
|
||||||
apk add --no-cache tzdata && \
|
|
||||||
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
|
|
||||||
echo 'Asia/Shanghai' >/etc/timezone && \
|
|
||||||
rm -rf /var/cache/apk/*
|
|
||||||
|
|
||||||
COPY ./script/entrypoint.sh /entrypoint.sh
|
COPY ./script/entrypoint.sh /entrypoint.sh
|
||||||
RUN chmod +x /entrypoint.sh
|
RUN chmod +x /entrypoint.sh
|
||||||
|
|
||||||
@@ -19,4 +15,6 @@ COPY dist/dashboard-${TARGETOS}-${TARGETARCH} ./app
|
|||||||
|
|
||||||
VOLUME ["/dashboard/data"]
|
VOLUME ["/dashboard/data"]
|
||||||
EXPOSE 80 5555
|
EXPOSE 80 5555
|
||||||
|
ARG TZ=Asia/Shanghai
|
||||||
|
ENV TZ=$TZ
|
||||||
ENTRYPOINT ["/entrypoint.sh"]
|
ENTRYPOINT ["/entrypoint.sh"]
|
||||||
@@ -91,7 +91,7 @@ You can change the dashboard language in the settings page (`/setting`) after th
|
|||||||
<a href="https://github.com/techotaku" title="Ian Li"><img src="https://avatars.githubusercontent.com/u/1948179?v=4" width="50;" alt="Ian Li"/></a>
|
<a href="https://github.com/techotaku" title="Ian Li"><img src="https://avatars.githubusercontent.com/u/1948179?v=4" width="50;" alt="Ian Li"/></a>
|
||||||
<a href="https://github.com/GreenTeodoro839" title="GreenTeodoro839"><img src="https://avatars.githubusercontent.com/u/77104800?v=4" width="50;" alt="GreenTeodoro839"/></a>
|
<a href="https://github.com/GreenTeodoro839" title="GreenTeodoro839"><img src="https://avatars.githubusercontent.com/u/77104800?v=4" width="50;" alt="GreenTeodoro839"/></a>
|
||||||
<a href="https://github.com/Es-dese" title="Esdese"><img src="https://avatars.githubusercontent.com/u/71542548?v=4" width="50;" alt="Esdese"/></a>
|
<a href="https://github.com/Es-dese" title="Esdese"><img src="https://avatars.githubusercontent.com/u/71542548?v=4" width="50;" alt="Esdese"/></a>
|
||||||
<a href="https://github.com/wwng2333" title="Crazy"><img src="https://avatars.githubusercontent.com/u/17147265?v=4" width="50;" alt="Crazy"/></a>
|
<a href="https://github.com/wwng2333" title=":D"><img src="https://avatars.githubusercontent.com/u/17147265?v=4" width="50;" alt=":D"/></a>
|
||||||
<a href="https://github.com/wellcoming" title="Coming"><img src="https://avatars.githubusercontent.com/u/74850890?v=4" width="50;" alt="Coming"/></a><!--GAMFC_DELIMITER_END-->
|
<a href="https://github.com/wellcoming" title="Coming"><img src="https://avatars.githubusercontent.com/u/74850890?v=4" width="50;" alt="Coming"/></a><!--GAMFC_DELIMITER_END-->
|
||||||
|
|
||||||
## Special Thanks
|
## Special Thanks
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ func (cp *commonPage) serve() {
|
|||||||
cr.GET("/network", cp.network)
|
cr.GET("/network", cp.network)
|
||||||
cr.GET("/ws", cp.ws)
|
cr.GET("/ws", cp.ws)
|
||||||
cr.POST("/terminal", cp.createTerminal)
|
cr.POST("/terminal", cp.createTerminal)
|
||||||
|
cr.GET("/file", cp.createFM)
|
||||||
|
cr.GET("/file/:id", cp.fm)
|
||||||
}
|
}
|
||||||
|
|
||||||
type viewPasswordForm struct {
|
type viewPasswordForm struct {
|
||||||
@@ -257,8 +259,8 @@ func (cp *commonPage) home(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
ReadBufferSize: 10240,
|
ReadBufferSize: 32768,
|
||||||
WriteBufferSize: 10240,
|
WriteBufferSize: 32768,
|
||||||
}
|
}
|
||||||
|
|
||||||
type Data struct {
|
type Data struct {
|
||||||
@@ -427,5 +429,130 @@ func (cp *commonPage) createTerminal(c *gin.Context) {
|
|||||||
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
|
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/terminal", mygin.CommonEnvironment(c, gin.H{
|
||||||
"SessionID": streamId,
|
"SessionID": streamId,
|
||||||
"ServerName": server.Name,
|
"ServerName": server.Name,
|
||||||
|
"ServerID": server.ID,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *commonPage) fm(c *gin.Context) {
|
||||||
|
streamId := c.Param("id")
|
||||||
|
if _, err := rpc.NezhaHandlerSingleton.GetStream(streamId); err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "FM会话不存在",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
|
||||||
|
|
||||||
|
wsConn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: "NetworkError",
|
||||||
|
}),
|
||||||
|
Msg: "Websocket协议切换失败",
|
||||||
|
Link: "/",
|
||||||
|
Btn: "返回首页",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer wsConn.Close()
|
||||||
|
conn := websocketx.NewConn(wsConn)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// PING 保活
|
||||||
|
for {
|
||||||
|
if err = conn.WriteMessage(websocket.PingMessage, []byte{}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(time.Second * 10)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err = rpc.NezhaHandlerSingleton.UserConnected(streamId, conn); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc.NezhaHandlerSingleton.StartStream(streamId, time.Second*10)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cp *commonPage) createFM(c *gin.Context) {
|
||||||
|
IdString := c.Query("id")
|
||||||
|
if _, authorized := c.Get(model.CtxKeyAuthorizedUser); !authorized {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "无权访问",
|
||||||
|
Msg: "用户未登录",
|
||||||
|
Link: "/login",
|
||||||
|
Btn: "去登录",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
streamId, err := uuid.GenerateUUID()
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusInternalServerError,
|
||||||
|
Title: singleton.Localizer.MustLocalize(&i18n.LocalizeConfig{
|
||||||
|
MessageID: "SystemError",
|
||||||
|
}),
|
||||||
|
Msg: "生成会话ID失败",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rpc.NezhaHandlerSingleton.CreateStream(streamId)
|
||||||
|
|
||||||
|
serverId, err := strconv.Atoi(IdString)
|
||||||
|
if err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "请求参数有误:" + err.Error(),
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
singleton.ServerLock.RLock()
|
||||||
|
server := singleton.ServerList[uint64(serverId)]
|
||||||
|
singleton.ServerLock.RUnlock()
|
||||||
|
if server == nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "服务器不存在或处于离线状态",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmData, _ := utils.Json.Marshal(&model.TaskFM{
|
||||||
|
StreamID: streamId,
|
||||||
|
})
|
||||||
|
if err := server.TaskStream.Send(&proto.Task{
|
||||||
|
Type: model.TaskTypeFM,
|
||||||
|
Data: string(fmData),
|
||||||
|
}); err != nil {
|
||||||
|
mygin.ShowErrorPage(c, mygin.ErrInfo{
|
||||||
|
Code: http.StatusForbidden,
|
||||||
|
Title: "请求失败",
|
||||||
|
Msg: "Agent信令下发失败",
|
||||||
|
Link: "/server",
|
||||||
|
Btn: "返回重试",
|
||||||
|
}, true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.HTML(http.StatusOK, "dashboard-"+singleton.Conf.Site.DashboardTheme+"/file", mygin.CommonEnvironment(c, gin.H{
|
||||||
|
"SessionID": streamId,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
@@ -277,7 +276,7 @@ func natGateway(c *gin.Context) {
|
|||||||
rpc.NezhaHandlerSingleton.CreateStream(streamId)
|
rpc.NezhaHandlerSingleton.CreateStream(streamId)
|
||||||
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
|
defer rpc.NezhaHandlerSingleton.CloseStream(streamId)
|
||||||
|
|
||||||
taskData, err := json.Marshal(model.TaskNAT{
|
taskData, err := utils.Json.Marshal(model.TaskNAT{
|
||||||
StreamID: streamId,
|
StreamID: streamId,
|
||||||
Host: natConfig.Host,
|
Host: natConfig.Host,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -199,7 +198,7 @@ func (oa *oauth2controller) callback(c *gin.Context) {
|
|||||||
if err == nil {
|
if err == nil {
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
var cloudflareUserInfo *cloudflare.UserInfo
|
var cloudflareUserInfo *cloudflare.UserInfo
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&cloudflareUserInfo); err == nil {
|
if err := utils.Json.NewDecoder(resp.Body).Decode(&cloudflareUserInfo); err == nil {
|
||||||
user = cloudflareUserInfo.MapToNezhaUser()
|
user = cloudflareUserInfo.MapToNezhaUser()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/naiba/nezha/cmd/dashboard/controller"
|
"github.com/naiba/nezha/cmd/dashboard/controller"
|
||||||
"github.com/naiba/nezha/cmd/dashboard/rpc"
|
"github.com/naiba/nezha/cmd/dashboard/rpc"
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
TaskTypeTerminalGRPC
|
TaskTypeTerminalGRPC
|
||||||
TaskTypeNAT
|
TaskTypeNAT
|
||||||
TaskTypeReportHostInfo
|
TaskTypeReportHostInfo
|
||||||
|
TaskTypeFM
|
||||||
)
|
)
|
||||||
|
|
||||||
type TerminalTask struct {
|
type TerminalTask struct {
|
||||||
@@ -34,6 +35,10 @@ type TaskNAT struct {
|
|||||||
Host string
|
Host string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TaskFM struct {
|
||||||
|
StreamID string
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MonitorCoverAll = iota
|
MonitorCoverAll = iota
|
||||||
MonitorCoverIgnoreAll
|
MonitorCoverIgnoreAll
|
||||||
|
|||||||
+2
-14
@@ -1,6 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -44,19 +45,6 @@ func percentage(used, total uint64) float64 {
|
|||||||
return float64(used) * 100 / float64(total)
|
return float64(used) * 100 / float64(total)
|
||||||
}
|
}
|
||||||
|
|
||||||
func maxSliceValue(slice []float64) float64 {
|
|
||||||
if len(slice) != 0 {
|
|
||||||
max := slice[0]
|
|
||||||
for _, val := range slice {
|
|
||||||
if max < val {
|
|
||||||
max = val
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return max
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// Snapshot 未通过规则返回 struct{}{}, 通过返回 nil
|
// Snapshot 未通过规则返回 struct{}{}, 通过返回 nil
|
||||||
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) interface{} {
|
func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server, db *gorm.DB) interface{} {
|
||||||
// 监控全部但是排除了此服务器
|
// 监控全部但是排除了此服务器
|
||||||
@@ -145,7 +133,7 @@ func (u *Rule) Snapshot(cycleTransferStats *CycleTransferStats, server *Server,
|
|||||||
temp = append(temp, tempStat.Temperature)
|
temp = append(temp, tempStat.Temperature)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
src = maxSliceValue(temp)
|
src = slices.Max(temp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+131
-83
@@ -2,169 +2,217 @@ package ddns
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
|
||||||
|
"github.com/naiba/nezha/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const baseEndpoint = "https://api.cloudflare.com/client/v4/zones"
|
||||||
|
|
||||||
type ProviderCloudflare struct {
|
type ProviderCloudflare struct {
|
||||||
Secret string
|
secret string
|
||||||
|
zoneId string
|
||||||
|
recordId string
|
||||||
|
domainConfig *DomainConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderCloudflare) UpdateDomain(domainConfig *DomainConfig) bool {
|
type cfReq struct {
|
||||||
if domainConfig == nil {
|
Name string `json:"name"`
|
||||||
return false
|
Type string `json:"type"`
|
||||||
}
|
Content string `json:"content"`
|
||||||
|
TTL uint32 `json:"ttl"`
|
||||||
|
Proxied bool `json:"proxied"`
|
||||||
|
}
|
||||||
|
|
||||||
zoneID, err := provider.getZoneID(domainConfig.FullDomain)
|
type cfResp struct {
|
||||||
|
Result []struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderCloudflare(s string) *ProviderCloudflare {
|
||||||
|
return &ProviderCloudflare{
|
||||||
|
secret: s,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *ProviderCloudflare) UpdateDomain(domainConfig *DomainConfig) error {
|
||||||
|
if domainConfig == nil {
|
||||||
|
return fmt.Errorf("获取 DDNS 配置失败")
|
||||||
|
}
|
||||||
|
provider.domainConfig = domainConfig
|
||||||
|
|
||||||
|
err := provider.getZoneID()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("无法获取 zone ID: %s\n", err)
|
return fmt.Errorf("无法获取 zone ID: %s", err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当IPv4和IPv6同时成功才算作成功
|
// 当IPv4和IPv6同时成功才算作成功
|
||||||
var resultV4 = true
|
if provider.domainConfig.EnableIPv4 {
|
||||||
var resultV6 = true
|
if err = provider.addDomainRecord(true); err != nil {
|
||||||
if domainConfig.EnableIPv4 {
|
return err
|
||||||
if !provider.addDomainRecord(zoneID, domainConfig, true) {
|
|
||||||
resultV4 = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if domainConfig.EnableIpv6 {
|
if provider.domainConfig.EnableIpv6 {
|
||||||
if !provider.addDomainRecord(zoneID, domainConfig, false) {
|
if err = provider.addDomainRecord(false); err != nil {
|
||||||
resultV6 = false
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultV4 && resultV6
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderCloudflare) addDomainRecord(zoneID string, domainConfig *DomainConfig, isIpv4 bool) bool {
|
func (provider *ProviderCloudflare) addDomainRecord(isIpv4 bool) error {
|
||||||
record, err := provider.findDNSRecord(zoneID, domainConfig.FullDomain, isIpv4)
|
err := provider.findDNSRecord(isIpv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("查找 DNS 记录时出错: %s\n", err)
|
return fmt.Errorf("查找 DNS 记录时出错: %s", err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if record == nil {
|
if provider.recordId == "" {
|
||||||
// 添加 DNS 记录
|
// 添加 DNS 记录
|
||||||
return provider.createDNSRecord(zoneID, domainConfig, isIpv4)
|
return provider.createDNSRecord(isIpv4)
|
||||||
} else {
|
} else {
|
||||||
// 更新 DNS 记录
|
// 更新 DNS 记录
|
||||||
return provider.updateDNSRecord(zoneID, record["id"].(string), domainConfig, isIpv4)
|
return provider.updateDNSRecord(isIpv4)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderCloudflare) getZoneID(domain string) (string, error) {
|
func (provider *ProviderCloudflare) getZoneID() error {
|
||||||
_, realDomain := SplitDomain(domain)
|
_, realDomain := splitDomain(provider.domainConfig.FullDomain)
|
||||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones?name=%s", realDomain)
|
zu, _ := url.Parse(baseEndpoint)
|
||||||
body, err := provider.sendRequest("GET", url, nil)
|
|
||||||
|
q := zu.Query()
|
||||||
|
q.Set("name", realDomain)
|
||||||
|
zu.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
body, err := provider.sendRequest("GET", zu.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res map[string]interface{}
|
res := &cfResp{}
|
||||||
err = json.Unmarshal(body, &res)
|
err = utils.Json.Unmarshal(body, res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := res["result"].([]interface{})
|
result := res.Result
|
||||||
if len(result) > 0 {
|
if len(result) > 0 {
|
||||||
zoneID := result[0].(map[string]interface{})["id"].(string)
|
provider.zoneId = result[0].ID
|
||||||
return zoneID, nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", fmt.Errorf("找不到 Zone ID")
|
return fmt.Errorf("找不到 Zone ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderCloudflare) findDNSRecord(zoneID string, domain string, isIPv4 bool) (map[string]interface{}, error) {
|
func (provider *ProviderCloudflare) findDNSRecord(isIPv4 bool) error {
|
||||||
var ipType = "A"
|
var ipType string
|
||||||
if !isIPv4 {
|
if isIPv4 {
|
||||||
|
ipType = "A"
|
||||||
|
} else {
|
||||||
ipType = "AAAA"
|
ipType = "AAAA"
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=%s&name=%s", zoneID, ipType, domain)
|
|
||||||
body, err := provider.sendRequest("GET", url, nil)
|
de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records")
|
||||||
|
du, _ := url.Parse(de)
|
||||||
|
|
||||||
|
q := du.Query()
|
||||||
|
q.Set("name", provider.domainConfig.FullDomain)
|
||||||
|
q.Set("type", ipType)
|
||||||
|
du.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
body, err := provider.sendRequest("GET", du.String(), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res map[string]interface{}
|
res := &cfResp{}
|
||||||
err = json.Unmarshal(body, &res)
|
err = utils.Json.Unmarshal(body, res)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := res["result"].([]interface{})
|
result := res.Result
|
||||||
if len(result) > 0 {
|
if len(result) > 0 {
|
||||||
return result[0].(map[string]interface{}), nil
|
provider.recordId = result[0].ID
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil // 没有找到 DNS 记录
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderCloudflare) createDNSRecord(zoneID string, domainConfig *DomainConfig, isIPv4 bool) bool {
|
func (provider *ProviderCloudflare) createDNSRecord(isIPv4 bool) error {
|
||||||
var ipType = "A"
|
var ipType, ipAddr string
|
||||||
var ipAddr = domainConfig.Ipv4Addr
|
if isIPv4 {
|
||||||
if !isIPv4 {
|
ipType = "A"
|
||||||
|
ipAddr = provider.domainConfig.Ipv4Addr
|
||||||
|
} else {
|
||||||
ipType = "AAAA"
|
ipType = "AAAA"
|
||||||
ipAddr = domainConfig.Ipv6Addr
|
ipAddr = provider.domainConfig.Ipv6Addr
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records", zoneID)
|
|
||||||
data := map[string]interface{}{
|
de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records")
|
||||||
"type": ipType,
|
data := &cfReq{
|
||||||
"name": domainConfig.FullDomain,
|
Name: provider.domainConfig.FullDomain,
|
||||||
"content": ipAddr,
|
Type: ipType,
|
||||||
"ttl": 60,
|
Content: ipAddr,
|
||||||
"proxied": false,
|
TTL: 60,
|
||||||
|
Proxied: false,
|
||||||
}
|
}
|
||||||
jsonData, _ := json.Marshal(data)
|
|
||||||
_, err := provider.sendRequest("POST", url, jsonData)
|
jsonData, _ := utils.Json.Marshal(data)
|
||||||
return err == nil
|
_, err := provider.sendRequest("POST", de, jsonData)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderCloudflare) updateDNSRecord(zoneID string, recordID string, domainConfig *DomainConfig, isIPv4 bool) bool {
|
func (provider *ProviderCloudflare) updateDNSRecord(isIPv4 bool) error {
|
||||||
var ipType = "A"
|
var ipType, ipAddr string
|
||||||
var ipAddr = domainConfig.Ipv4Addr
|
if isIPv4 {
|
||||||
if !isIPv4 {
|
ipType = "A"
|
||||||
|
ipAddr = provider.domainConfig.Ipv4Addr
|
||||||
|
} else {
|
||||||
ipType = "AAAA"
|
ipType = "AAAA"
|
||||||
ipAddr = domainConfig.Ipv6Addr
|
ipAddr = provider.domainConfig.Ipv6Addr
|
||||||
}
|
}
|
||||||
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s", zoneID, recordID)
|
|
||||||
data := map[string]interface{}{
|
de, _ := url.JoinPath(baseEndpoint, provider.zoneId, "dns_records", provider.recordId)
|
||||||
"type": ipType,
|
data := &cfReq{
|
||||||
"name": domainConfig.FullDomain,
|
Name: provider.domainConfig.FullDomain,
|
||||||
"content": ipAddr,
|
Type: ipType,
|
||||||
"ttl": 60,
|
Content: ipAddr,
|
||||||
"proxied": false,
|
TTL: 60,
|
||||||
|
Proxied: false,
|
||||||
}
|
}
|
||||||
jsonData, _ := json.Marshal(data)
|
|
||||||
_, err := provider.sendRequest("PATCH", url, jsonData)
|
jsonData, _ := utils.Json.Marshal(data)
|
||||||
return err == nil
|
_, err := provider.sendRequest("PATCH", de, jsonData)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以下为辅助方法,如发送 HTTP 请求等
|
// 以下为辅助方法,如发送 HTTP 请求等
|
||||||
func (provider *ProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) {
|
func (provider *ProviderCloudflare) sendRequest(method string, url string, data []byte) ([]byte, error) {
|
||||||
client := &http.Client{}
|
|
||||||
req, err := http.NewRequest(method, url, bytes.NewBuffer(data))
|
req, err := http.NewRequest(method, url, bytes.NewBuffer(data))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", provider.Secret))
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", provider.secret))
|
||||||
req.Header.Add("Content-Type", "application/json")
|
req.Header.Add("Content-Type", "application/json")
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
resp, err := utils.HttpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func(Body io.ReadCloser) {
|
defer func(Body io.ReadCloser) {
|
||||||
err := Body.Close()
|
err := Body.Close()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s\n", err.Error())
|
log.Printf("NEZHA>> 无法关闭HTTP响应体流: %s", err.Error())
|
||||||
}
|
}
|
||||||
}(resp.Body)
|
}(resp.Body)
|
||||||
|
|
||||||
|
|||||||
+9
-1
@@ -1,5 +1,7 @@
|
|||||||
package ddns
|
package ddns
|
||||||
|
|
||||||
|
import "golang.org/x/net/publicsuffix"
|
||||||
|
|
||||||
type DomainConfig struct {
|
type DomainConfig struct {
|
||||||
EnableIPv4 bool
|
EnableIPv4 bool
|
||||||
EnableIpv6 bool
|
EnableIpv6 bool
|
||||||
@@ -10,5 +12,11 @@ type DomainConfig struct {
|
|||||||
|
|
||||||
type Provider interface {
|
type Provider interface {
|
||||||
// UpdateDomain Return is updated
|
// UpdateDomain Return is updated
|
||||||
UpdateDomain(domainConfig *DomainConfig) bool
|
UpdateDomain(*DomainConfig) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitDomain(domain string) (prefix string, realDomain string) {
|
||||||
|
realDomain, _ = publicsuffix.EffectiveTLDPlusOne(domain)
|
||||||
|
prefix = domain[:len(domain)-len(realDomain)-1]
|
||||||
|
return prefix, realDomain
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,6 +2,6 @@ package ddns
|
|||||||
|
|
||||||
type ProviderDummy struct{}
|
type ProviderDummy struct{}
|
||||||
|
|
||||||
func (provider *ProviderDummy) UpdateDomain(domainConfig *DomainConfig) bool {
|
func (provider *ProviderDummy) UpdateDomain(domainConfig *DomainConfig) error {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
package ddns
|
|
||||||
|
|
||||||
import (
|
|
||||||
"golang.org/x/net/publicsuffix"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (provider ProviderWebHook) FormatWebhookString(s string, config *DomainConfig, ipType string) string {
|
|
||||||
if config == nil {
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
|
|
||||||
result := strings.TrimSpace(s)
|
|
||||||
result = strings.Replace(s, "{ip}", config.Ipv4Addr, -1)
|
|
||||||
result = strings.Replace(result, "{domain}", config.FullDomain, -1)
|
|
||||||
result = strings.Replace(result, "{type}", ipType, -1)
|
|
||||||
// remove \r
|
|
||||||
result = strings.Replace(result, "\r", "", -1)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetStringHeadersToRequest(req *http.Request, headers []string) {
|
|
||||||
if req == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, element := range headers {
|
|
||||||
kv := strings.SplitN(element, ":", 2)
|
|
||||||
if len(kv) == 2 {
|
|
||||||
req.Header.Add(kv[0], kv[1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SplitDomain 分割域名为前缀和一级域名
|
|
||||||
func SplitDomain(domain string) (prefix string, realDomain string) {
|
|
||||||
realDomain, _ = publicsuffix.EffectiveTLDPlusOne(domain)
|
|
||||||
prefix = domain[:len(domain)-len(realDomain)-1]
|
|
||||||
return prefix, realDomain
|
|
||||||
}
|
|
||||||
+119
-84
@@ -5,145 +5,180 @@ import (
|
|||||||
"crypto/hmac"
|
"crypto/hmac"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/naiba/nezha/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const te = "https://dnspod.tencentcloudapi.com"
|
||||||
url = "https://dnspod.tencentcloudapi.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ProviderTencentCloud struct {
|
type ProviderTencentCloud struct {
|
||||||
SecretID string
|
secretID string
|
||||||
SecretKey string
|
secretKey string
|
||||||
|
domainConfig *DomainConfig
|
||||||
|
resp *tcResp
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderTencentCloud) UpdateDomain(domainConfig *DomainConfig) bool {
|
type tcReq struct {
|
||||||
if domainConfig == nil {
|
RecordType string `json:"RecordType"`
|
||||||
return false
|
Domain string `json:"Domain"`
|
||||||
|
RecordLine string `json:"RecordLine"`
|
||||||
|
Subdomain string `json:"Subdomain,omitempty"`
|
||||||
|
SubDomain string `json:"SubDomain,omitempty"` // As is
|
||||||
|
Value string `json:"Value,omitempty"`
|
||||||
|
TTL uint32 `json:"TTL,omitempty"`
|
||||||
|
RecordId uint64 `json:"RecordId,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type tcResp struct {
|
||||||
|
Response struct {
|
||||||
|
RecordList []struct {
|
||||||
|
RecordId uint64
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
Error struct {
|
||||||
|
Code string
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProviderTencentCloud(id, key string) *ProviderTencentCloud {
|
||||||
|
return &ProviderTencentCloud{
|
||||||
|
secretID: id,
|
||||||
|
secretKey: key,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *ProviderTencentCloud) UpdateDomain(domainConfig *DomainConfig) error {
|
||||||
|
if domainConfig == nil {
|
||||||
|
return fmt.Errorf("获取 DDNS 配置失败")
|
||||||
|
}
|
||||||
|
provider.domainConfig = domainConfig
|
||||||
|
|
||||||
// 当IPv4和IPv6同时成功才算作成功
|
// 当IPv4和IPv6同时成功才算作成功
|
||||||
var resultV4 = true
|
var err error
|
||||||
var resultV6 = true
|
if provider.domainConfig.EnableIPv4 {
|
||||||
if domainConfig.EnableIPv4 {
|
if err = provider.addDomainRecord(true); err != nil {
|
||||||
if !provider.addDomainRecord(domainConfig, true) {
|
return err
|
||||||
resultV4 = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if domainConfig.EnableIpv6 {
|
if provider.domainConfig.EnableIpv6 {
|
||||||
if !provider.addDomainRecord(domainConfig, false) {
|
if err = provider.addDomainRecord(false); err != nil {
|
||||||
resultV6 = false
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resultV4 && resultV6
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderTencentCloud) addDomainRecord(domainConfig *DomainConfig, isIpv4 bool) bool {
|
func (provider *ProviderTencentCloud) addDomainRecord(isIpv4 bool) error {
|
||||||
record, err := provider.findDNSRecord(domainConfig.FullDomain, isIpv4)
|
err := provider.findDNSRecord(isIpv4)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("查找 DNS 记录时出错: %s\n", err)
|
return fmt.Errorf("查找 DNS 记录时出错: %s", err)
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if errResponse, ok := record["Error"].(map[string]interface{}); ok {
|
if provider.resp.Response.Error.Code == "ResourceNotFound.NoDataOfRecord" { // 没有找到 DNS 记录
|
||||||
if errCode, ok := errResponse["Code"].(string); ok && errCode == "ResourceNotFound.NoDataOfRecord" { // 没有找到 DNS 记录
|
return provider.createDNSRecord(isIpv4)
|
||||||
// 添加 DNS 记录
|
} else if provider.resp.Response.Error.Code != "" {
|
||||||
return provider.createDNSRecord(domainConfig.FullDomain, domainConfig, isIpv4)
|
return fmt.Errorf("查询 DNS 记录时出错,错误代码为: %s", provider.resp.Response.Error.Code)
|
||||||
} else {
|
|
||||||
log.Printf("查询 DNS 记录时出错,错误代码为: %s\n", errCode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 默认情况下更新 DNS 记录
|
// 默认情况下更新 DNS 记录
|
||||||
return provider.updateDNSRecord(domainConfig.FullDomain, record["RecordList"].([]interface{})[0].(map[string]interface{})["RecordId"].(float64), domainConfig, isIpv4)
|
return provider.updateDNSRecord(isIpv4)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderTencentCloud) findDNSRecord(domain string, isIPv4 bool) (map[string]interface{}, error) {
|
func (provider *ProviderTencentCloud) findDNSRecord(isIPv4 bool) error {
|
||||||
var ipType = "A"
|
var ipType string
|
||||||
if !isIPv4 {
|
if isIPv4 {
|
||||||
|
ipType = "A"
|
||||||
|
} else {
|
||||||
ipType = "AAAA"
|
ipType = "AAAA"
|
||||||
}
|
}
|
||||||
_, realDomain := SplitDomain(domain)
|
|
||||||
prefix, _ := SplitDomain(domain)
|
prefix, realDomain := splitDomain(provider.domainConfig.FullDomain)
|
||||||
data := map[string]interface{}{
|
data := &tcReq{
|
||||||
"RecordType": ipType,
|
RecordType: ipType,
|
||||||
"Domain": realDomain,
|
Domain: realDomain,
|
||||||
"RecordLine": "默认",
|
RecordLine: "默认",
|
||||||
"Subdomain": prefix,
|
Subdomain: prefix,
|
||||||
}
|
}
|
||||||
jsonData, _ := json.Marshal(data)
|
|
||||||
|
jsonData, _ := utils.Json.Marshal(data)
|
||||||
body, err := provider.sendRequest("DescribeRecordList", jsonData)
|
body, err := provider.sendRequest("DescribeRecordList", jsonData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var res map[string]interface{}
|
provider.resp = &tcResp{}
|
||||||
err = json.Unmarshal(body, &res)
|
err = utils.Json.Unmarshal(body, provider.resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := res["Response"].(map[string]interface{})
|
return nil
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderTencentCloud) createDNSRecord(domain string, domainConfig *DomainConfig, isIPv4 bool) bool {
|
func (provider *ProviderTencentCloud) createDNSRecord(isIPv4 bool) error {
|
||||||
var ipType = "A"
|
var ipType, ipAddr string
|
||||||
var ipAddr = domainConfig.Ipv4Addr
|
if isIPv4 {
|
||||||
if !isIPv4 {
|
ipType = "A"
|
||||||
|
ipAddr = provider.domainConfig.Ipv4Addr
|
||||||
|
} else {
|
||||||
ipType = "AAAA"
|
ipType = "AAAA"
|
||||||
ipAddr = domainConfig.Ipv6Addr
|
ipAddr = provider.domainConfig.Ipv6Addr
|
||||||
}
|
}
|
||||||
_, realDomain := SplitDomain(domain)
|
|
||||||
prefix, _ := SplitDomain(domain)
|
prefix, realDomain := splitDomain(provider.domainConfig.FullDomain)
|
||||||
data := map[string]interface{}{
|
data := &tcReq{
|
||||||
"RecordType": ipType,
|
RecordType: ipType,
|
||||||
"RecordLine": "默认",
|
RecordLine: "默认",
|
||||||
"Domain": realDomain,
|
Domain: realDomain,
|
||||||
"SubDomain": prefix,
|
SubDomain: prefix,
|
||||||
"Value": ipAddr,
|
Value: ipAddr,
|
||||||
"TTL": 600,
|
TTL: 600,
|
||||||
}
|
}
|
||||||
jsonData, _ := json.Marshal(data)
|
|
||||||
|
jsonData, _ := utils.Json.Marshal(data)
|
||||||
_, err := provider.sendRequest("CreateRecord", jsonData)
|
_, err := provider.sendRequest("CreateRecord", jsonData)
|
||||||
return err == nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderTencentCloud) updateDNSRecord(domain string, recordID float64, domainConfig *DomainConfig, isIPv4 bool) bool {
|
func (provider *ProviderTencentCloud) updateDNSRecord(isIPv4 bool) error {
|
||||||
var ipType = "A"
|
var ipType, ipAddr string
|
||||||
var ipAddr = domainConfig.Ipv4Addr
|
if isIPv4 {
|
||||||
if !isIPv4 {
|
ipType = "A"
|
||||||
|
ipAddr = provider.domainConfig.Ipv4Addr
|
||||||
|
} else {
|
||||||
ipType = "AAAA"
|
ipType = "AAAA"
|
||||||
ipAddr = domainConfig.Ipv6Addr
|
ipAddr = provider.domainConfig.Ipv6Addr
|
||||||
}
|
}
|
||||||
_, realDomain := SplitDomain(domain)
|
|
||||||
prefix, _ := SplitDomain(domain)
|
prefix, realDomain := splitDomain(provider.domainConfig.FullDomain)
|
||||||
data := map[string]interface{}{
|
data := &tcReq{
|
||||||
"RecordType": ipType,
|
RecordType: ipType,
|
||||||
"RecordLine": "默认",
|
RecordLine: "默认",
|
||||||
"Domain": realDomain,
|
Domain: realDomain,
|
||||||
"SubDomain": prefix,
|
SubDomain: prefix,
|
||||||
"Value": ipAddr,
|
Value: ipAddr,
|
||||||
"TTL": 600,
|
TTL: 600,
|
||||||
"RecordId": recordID,
|
RecordId: provider.resp.Response.RecordList[0].RecordId,
|
||||||
}
|
}
|
||||||
jsonData, _ := json.Marshal(data)
|
|
||||||
|
jsonData, _ := utils.Json.Marshal(data)
|
||||||
_, err := provider.sendRequest("ModifyRecord", jsonData)
|
_, err := provider.sendRequest("ModifyRecord", jsonData)
|
||||||
return err == nil
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以下为辅助方法,如发送 HTTP 请求等
|
// 以下为辅助方法,如发送 HTTP 请求等
|
||||||
func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([]byte, error) {
|
func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([]byte, error) {
|
||||||
client := &http.Client{}
|
req, err := http.NewRequest("POST", te, bytes.NewBuffer(data))
|
||||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(data))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -151,8 +186,8 @@ func (provider *ProviderTencentCloud) sendRequest(action string, data []byte) ([
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set("X-TC-Version", "2021-03-23")
|
req.Header.Set("X-TC-Version", "2021-03-23")
|
||||||
|
|
||||||
provider.signRequest(provider.SecretID, provider.SecretKey, req, action, string(data))
|
provider.signRequest(provider.secretID, provider.secretKey, req, action, string(data))
|
||||||
resp, err := client.Do(req)
|
resp, err := utils.HttpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+91
-40
@@ -2,58 +2,109 @@ package ddns
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"log"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/naiba/nezha/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProviderWebHook struct {
|
type ProviderWebHook struct {
|
||||||
URL string
|
url string
|
||||||
RequestMethod string
|
requestMethod string
|
||||||
RequestBody string
|
requestBody string
|
||||||
RequestHeader string
|
requestHeader string
|
||||||
|
domainConfig *DomainConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
func (provider *ProviderWebHook) UpdateDomain(domainConfig *DomainConfig) bool {
|
func NewProviderWebHook(s, rm, rb, rh string) *ProviderWebHook {
|
||||||
|
return &ProviderWebHook{
|
||||||
|
url: s,
|
||||||
|
requestMethod: rm,
|
||||||
|
requestBody: rb,
|
||||||
|
requestHeader: rh,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *ProviderWebHook) UpdateDomain(domainConfig *DomainConfig) error {
|
||||||
if domainConfig == nil {
|
if domainConfig == nil {
|
||||||
return false
|
return fmt.Errorf("获取 DDNS 配置失败")
|
||||||
|
}
|
||||||
|
provider.domainConfig = domainConfig
|
||||||
|
|
||||||
|
if provider.domainConfig.FullDomain == "" {
|
||||||
|
return fmt.Errorf("failed to update an empty domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
if domainConfig.FullDomain == "" {
|
if provider.domainConfig.EnableIPv4 && provider.domainConfig.Ipv4Addr != "" {
|
||||||
log.Println("NEZHA>> Failed to update an empty domain")
|
req, err := provider.prepareRequest(true)
|
||||||
return false
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
|
||||||
updated := false
|
}
|
||||||
client := &http.Client{}
|
if _, err := utils.HttpClient.Do(req); err != nil {
|
||||||
if domainConfig.EnableIPv4 && domainConfig.Ipv4Addr != "" {
|
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
|
||||||
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv4")
|
|
||||||
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv4")
|
|
||||||
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv4")
|
|
||||||
headers := strings.Split(header, "\n")
|
|
||||||
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
|
|
||||||
if err == nil && req != nil {
|
|
||||||
SetStringHeadersToRequest(req, headers)
|
|
||||||
if _, err := client.Do(req); err != nil {
|
|
||||||
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
|
|
||||||
} else {
|
|
||||||
updated = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if domainConfig.EnableIpv6 && domainConfig.Ipv6Addr != "" {
|
|
||||||
url := provider.FormatWebhookString(provider.URL, domainConfig, "ipv6")
|
if provider.domainConfig.EnableIpv6 && provider.domainConfig.Ipv6Addr != "" {
|
||||||
body := provider.FormatWebhookString(provider.RequestBody, domainConfig, "ipv6")
|
req, err := provider.prepareRequest(false)
|
||||||
header := provider.FormatWebhookString(provider.RequestHeader, domainConfig, "ipv6")
|
if err != nil {
|
||||||
headers := strings.Split(header, "\n")
|
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
|
||||||
req, err := http.NewRequest(provider.RequestMethod, url, bytes.NewBufferString(body))
|
}
|
||||||
if err == nil && req != nil {
|
if _, err := utils.HttpClient.Do(req); err != nil {
|
||||||
SetStringHeadersToRequest(req, headers)
|
return fmt.Errorf("failed to update a domain: %s. Cause by: %v", provider.domainConfig.FullDomain, err)
|
||||||
if _, err := client.Do(req); err != nil {
|
|
||||||
log.Printf("NEZHA>> Failed to update a domain: %s. Cause by: %s\n", domainConfig.FullDomain, err.Error())
|
|
||||||
} else {
|
|
||||||
updated = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *ProviderWebHook) prepareRequest(isIPv4 bool) (*http.Request, error) {
|
||||||
|
u, err := url.Parse(provider.url)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed parsing url: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only handle queries here
|
||||||
|
q := u.Query()
|
||||||
|
for p, vals := range q {
|
||||||
|
for n, v := range vals {
|
||||||
|
vals[n] = provider.formatWebhookString(v, isIPv4)
|
||||||
|
}
|
||||||
|
q[p] = vals
|
||||||
|
}
|
||||||
|
|
||||||
|
u.RawQuery = q.Encode()
|
||||||
|
body := provider.formatWebhookString(provider.requestBody, isIPv4)
|
||||||
|
header := provider.formatWebhookString(provider.requestHeader, isIPv4)
|
||||||
|
headers := strings.Split(header, "\n")
|
||||||
|
|
||||||
|
req, err := http.NewRequest(provider.requestMethod, u.String(), bytes.NewBufferString(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed creating new request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SetStringHeadersToRequest(req, headers)
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *ProviderWebHook) formatWebhookString(s string, isIPv4 bool) string {
|
||||||
|
var ipAddr, ipType string
|
||||||
|
if isIPv4 {
|
||||||
|
ipAddr = provider.domainConfig.Ipv4Addr
|
||||||
|
ipType = "ipv4"
|
||||||
|
} else {
|
||||||
|
ipAddr = provider.domainConfig.Ipv6Addr
|
||||||
|
ipType = "ipv6"
|
||||||
|
}
|
||||||
|
|
||||||
|
r := strings.NewReplacer(
|
||||||
|
"{ip}", ipAddr,
|
||||||
|
"{domain}", provider.domainConfig.FullDomain,
|
||||||
|
"{type}", ipType,
|
||||||
|
"\r", "",
|
||||||
|
)
|
||||||
|
|
||||||
|
result := r.Replace(strings.TrimSpace(s))
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -86,3 +87,15 @@ func Uint64SubInt64(a uint64, b int64) uint64 {
|
|||||||
}
|
}
|
||||||
return a - uint64(b)
|
return a - uint64(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func SetStringHeadersToRequest(req *http.Request, headers []string) {
|
||||||
|
if req == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, element := range headers {
|
||||||
|
kv := strings.SplitN(element, ":", 2)
|
||||||
|
if len(kv) == 2 {
|
||||||
|
req.Header.Add(kv[0], kv[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+32
-2
@@ -653,7 +653,37 @@ other = "Disable Switch Template in Frontend"
|
|||||||
other = "Servers On World Map"
|
other = "Servers On World Map"
|
||||||
|
|
||||||
[NAT]
|
[NAT]
|
||||||
other = "NAT"
|
other = "NAT Traversal"
|
||||||
|
|
||||||
|
[LocalService]
|
||||||
|
other = "Local service"
|
||||||
|
|
||||||
|
[LocalServicePlaceholder]
|
||||||
|
other = "192.168.1.1:80 (with port)"
|
||||||
|
|
||||||
|
[BindHostname]
|
||||||
|
other = "Bind hostname"
|
||||||
|
|
||||||
[NetworkSpiterList]
|
[NetworkSpiterList]
|
||||||
other = "Network Monitor"
|
other = "Network Monitor"
|
||||||
|
|
||||||
|
[Refresh]
|
||||||
|
other = "Refresh"
|
||||||
|
|
||||||
|
[CopyPath]
|
||||||
|
other = "Copy Path"
|
||||||
|
|
||||||
|
[Goto]
|
||||||
|
other = "Go to"
|
||||||
|
|
||||||
|
[GotoHeadline]
|
||||||
|
other = "Go to a Folder"
|
||||||
|
|
||||||
|
[GotoGo]
|
||||||
|
other = "Go"
|
||||||
|
|
||||||
|
[GotoClose]
|
||||||
|
other = "Cancel"
|
||||||
|
|
||||||
|
[FMError]
|
||||||
|
other = "Agent returned an error, please view the console for details. To open a new connection, reopen the FM again."
|
||||||
|
|||||||
Vendored
+32
-2
@@ -653,7 +653,37 @@ other = "Deshabilitar Cambio de Plantilla en Frontend"
|
|||||||
other = "Servidores en el mapa mundial"
|
other = "Servidores en el mapa mundial"
|
||||||
|
|
||||||
[NAT]
|
[NAT]
|
||||||
other = "NAT"
|
other = "NAT traversal"
|
||||||
|
|
||||||
|
[LocalService]
|
||||||
|
other = "Servicio de red local"
|
||||||
|
|
||||||
|
[LocalServicePlaceholder]
|
||||||
|
other = "192.168.1.1:80 (con puerto)"
|
||||||
|
|
||||||
|
[BindHostname]
|
||||||
|
other = "Vincular nombre de host"
|
||||||
|
|
||||||
[NetworkSpiterList]
|
[NetworkSpiterList]
|
||||||
other = "Red Monitor"
|
other = "Monitor de red"
|
||||||
|
|
||||||
|
[Refresh]
|
||||||
|
other = "Actualizar"
|
||||||
|
|
||||||
|
[CopyPath]
|
||||||
|
other = "Copiar ruta"
|
||||||
|
|
||||||
|
[Goto]
|
||||||
|
other = "Ir a"
|
||||||
|
|
||||||
|
[GotoHeadline]
|
||||||
|
other = "Ir a una carpeta"
|
||||||
|
|
||||||
|
[GotoGo]
|
||||||
|
other = "Ir"
|
||||||
|
|
||||||
|
[GotoClose]
|
||||||
|
other = "Cancelar"
|
||||||
|
|
||||||
|
[FMError]
|
||||||
|
other = "Agent devolvió un error, consulte la consola para obtener más detalles. Para abrir una nueva conexión, vuelva a abrir el FM."
|
||||||
|
|||||||
Vendored
+30
@@ -655,5 +655,35 @@ other = "服务器世界分布图"
|
|||||||
[NAT]
|
[NAT]
|
||||||
other = "内网穿透"
|
other = "内网穿透"
|
||||||
|
|
||||||
|
[LocalService]
|
||||||
|
other = "内网服务"
|
||||||
|
|
||||||
|
[LocalServicePlaceholder]
|
||||||
|
other = "192.168.1.1:80(带端口)"
|
||||||
|
|
||||||
|
[BindHostname]
|
||||||
|
other = "绑定域名"
|
||||||
|
|
||||||
[NetworkSpiterList]
|
[NetworkSpiterList]
|
||||||
other = "网络监控"
|
other = "网络监控"
|
||||||
|
|
||||||
|
[Refresh]
|
||||||
|
other = "刷新"
|
||||||
|
|
||||||
|
[CopyPath]
|
||||||
|
other = "复制路径"
|
||||||
|
|
||||||
|
[Goto]
|
||||||
|
other = "跳往"
|
||||||
|
|
||||||
|
[GotoHeadline]
|
||||||
|
other = "跳往文件夹"
|
||||||
|
|
||||||
|
[GotoGo]
|
||||||
|
other = "确认"
|
||||||
|
|
||||||
|
[GotoClose]
|
||||||
|
other = "取消"
|
||||||
|
|
||||||
|
[FMError]
|
||||||
|
other = "Agent 返回了错误,请查看控制台获取详细信息。要建立新连接,请重新打开 FM。"
|
||||||
|
|||||||
Vendored
+50
-20
@@ -50,7 +50,7 @@ other = "新增計劃任務"
|
|||||||
other = "名稱"
|
other = "名稱"
|
||||||
|
|
||||||
[Scheduler]
|
[Scheduler]
|
||||||
other = "計劃"
|
other = "排程"
|
||||||
|
|
||||||
[BackUp]
|
[BackUp]
|
||||||
other = "備份"
|
other = "備份"
|
||||||
@@ -80,37 +80,37 @@ other = "特定伺服器"
|
|||||||
other = "輸入ID/名稱以搜尋"
|
other = "輸入ID/名稱以搜尋"
|
||||||
|
|
||||||
[NotificationMethodGroup]
|
[NotificationMethodGroup]
|
||||||
other = "通知方式組"
|
other = "通知群組"
|
||||||
|
|
||||||
[PushSuccessMessages]
|
[PushSuccessMessages]
|
||||||
other = "推送成功的消息"
|
other = "推送成功的訊息"
|
||||||
|
|
||||||
[TaskType]
|
[TaskType]
|
||||||
other = "任務類型"
|
other = "任務類型"
|
||||||
|
|
||||||
[CronTask]
|
[CronTask]
|
||||||
other = "計劃任務"
|
other = "排程任務"
|
||||||
|
|
||||||
[TriggerTask]
|
[TriggerTask]
|
||||||
other = "觸發任務"
|
other = "觸發任務"
|
||||||
|
|
||||||
[TheFormaOfTheScheduleIs]
|
[TheFormaOfTheScheduleIs]
|
||||||
other = "計劃的格式為:"
|
other = "排程的格式為:"
|
||||||
|
|
||||||
[SecondsMinutesHoursDaysMonthsWeeksSeeDetails]
|
[SecondsMinutesHoursDaysMonthsWeeksSeeDetails]
|
||||||
other = "秒 分 時 天 月 星期,詳情見"
|
other = "秒 分 時 天 月 星期,詳情見"
|
||||||
|
|
||||||
[ScheduleExpressionFormat]
|
[ScheduleExpressionFormat]
|
||||||
other = "計劃表達式格式"
|
other = "排程表達式格式"
|
||||||
|
|
||||||
[IntroductionOfCommands]
|
[IntroductionOfCommands]
|
||||||
other = "命令說明:編寫命令時類似於 shell/bat 腳本。建議不要換行,多個命令可用 <code>&&</code> 或 <code>&</code> 連接,若出現命令無法找到的情況,可能是由於 <code>PATH</code> 環境變量配置問題。在 <code>Linux</code> 伺服器上,可在命令開頭加入 <code>source ~/.bashrc</code>,或使用命令的絕對路徑執行。"
|
other = "命令說明:編寫命令時類似於 shell/bat 腳本。建議不要換行,多個命令可用 <code>&&</code> 或 <code>&</code> 連接,若出現命令無法找到的情況,可能是由於 <code>PATH</code> 環境變數配置問題。在 <code>Linux</code> 伺服器上,可在命令開頭加入 <code>source ~/.bashrc</code>,或使用命令的絕對路徑執行。"
|
||||||
|
|
||||||
[AddMonitor]
|
[AddMonitor]
|
||||||
other = "新增監控"
|
other = "新增監控"
|
||||||
|
|
||||||
[Blog]
|
[Blog]
|
||||||
other = "博客"
|
other = "部落格"
|
||||||
|
|
||||||
[Target]
|
[Target]
|
||||||
other = "目標"
|
other = "目標"
|
||||||
@@ -158,7 +158,7 @@ other = "新增通知方式"
|
|||||||
other = "分組"
|
other = "分組"
|
||||||
|
|
||||||
[DoNotSendTestMessages]
|
[DoNotSendTestMessages]
|
||||||
other = "不發送測試信息"
|
other = "不發送測試訊息"
|
||||||
|
|
||||||
[RequestMethod]
|
[RequestMethod]
|
||||||
other = "請求方式"
|
other = "請求方式"
|
||||||
@@ -221,7 +221,7 @@ other = "排序"
|
|||||||
other = "越大越靠前"
|
other = "越大越靠前"
|
||||||
|
|
||||||
[Secret]
|
[Secret]
|
||||||
other = "密鑰"
|
other = "金鑰"
|
||||||
|
|
||||||
[Note]
|
[Note]
|
||||||
other = "備註"
|
other = "備註"
|
||||||
@@ -254,10 +254,10 @@ other = "忽略所有"
|
|||||||
other = "觸發執行"
|
other = "觸發執行"
|
||||||
|
|
||||||
[DeleteScheduledTask]
|
[DeleteScheduledTask]
|
||||||
other = "刪除計劃任務"
|
other = "刪除排程任務"
|
||||||
|
|
||||||
[ConfirmToDeleteThisScheduledTask]
|
[ConfirmToDeleteThisScheduledTask]
|
||||||
other = "確認刪除此計劃任務?"
|
other = "確認刪除此排程任務?"
|
||||||
|
|
||||||
[AccessDenied]
|
[AccessDenied]
|
||||||
other = "訪問被拒絕"
|
other = "訪問被拒絕"
|
||||||
@@ -407,7 +407,7 @@ other = "流量"
|
|||||||
other = "負載"
|
other = "負載"
|
||||||
|
|
||||||
[ProcessCount]
|
[ProcessCount]
|
||||||
other = "進程數"
|
other = "行程數"
|
||||||
|
|
||||||
[ConnCount]
|
[ConnCount]
|
||||||
other = "連接數"
|
other = "連接數"
|
||||||
@@ -422,7 +422,7 @@ other = "活動"
|
|||||||
other = "版本"
|
other = "版本"
|
||||||
|
|
||||||
[NetSpeed]
|
[NetSpeed]
|
||||||
other = "網絡"
|
other = "網路"
|
||||||
|
|
||||||
[Uptime]
|
[Uptime]
|
||||||
other = "在線"
|
other = "在線"
|
||||||
@@ -458,7 +458,7 @@ other = "狀態"
|
|||||||
other = "可用性"
|
other = "可用性"
|
||||||
|
|
||||||
[AverageLatency]
|
[AverageLatency]
|
||||||
other = "平均響應時間"
|
other = "平均回應時間"
|
||||||
|
|
||||||
[CycleTransferStats]
|
[CycleTransferStats]
|
||||||
other = "周期性流量統計"
|
other = "周期性流量統計"
|
||||||
@@ -524,7 +524,7 @@ other = "發生錯誤"
|
|||||||
other = "系統錯誤"
|
other = "系統錯誤"
|
||||||
|
|
||||||
[NetworkError]
|
[NetworkError]
|
||||||
other = "網絡錯誤"
|
other = "網路錯誤"
|
||||||
|
|
||||||
[ServicesStatus]
|
[ServicesStatus]
|
||||||
other = "服務狀態"
|
other = "服務狀態"
|
||||||
@@ -536,7 +536,7 @@ other = "伺服器管理"
|
|||||||
other = "服務監控"
|
other = "服務監控"
|
||||||
|
|
||||||
[ScheduledTasks]
|
[ScheduledTasks]
|
||||||
other = "計劃任務"
|
other = "排程任務"
|
||||||
|
|
||||||
[ApiManagement]
|
[ApiManagement]
|
||||||
other = "API 管理"
|
other = "API 管理"
|
||||||
@@ -614,7 +614,7 @@ other = "對遊客隱藏"
|
|||||||
other = "菜單"
|
other = "菜單"
|
||||||
|
|
||||||
[NetworkSpiter]
|
[NetworkSpiter]
|
||||||
other = "網絡"
|
other = "網路"
|
||||||
|
|
||||||
[EnableShowInService]
|
[EnableShowInService]
|
||||||
other = "在服務中顯示"
|
other = "在服務中顯示"
|
||||||
@@ -653,7 +653,37 @@ other = "禁止前台切換主題"
|
|||||||
other = "伺服器世界分布圖"
|
other = "伺服器世界分布圖"
|
||||||
|
|
||||||
[NAT]
|
[NAT]
|
||||||
other = "NAT"
|
other = "NAT穿透"
|
||||||
|
|
||||||
|
[LocalService]
|
||||||
|
other = "內網服務"
|
||||||
|
|
||||||
|
[LocalServicePlaceholder]
|
||||||
|
other = "192.168.1.1:80(帶埠號)"
|
||||||
|
|
||||||
|
[BindHostname]
|
||||||
|
other = "綁定網域"
|
||||||
|
|
||||||
[NetworkSpiterList]
|
[NetworkSpiterList]
|
||||||
other = "網絡監控"
|
other = "網路監控"
|
||||||
|
|
||||||
|
[Refresh]
|
||||||
|
other = "重新整理"
|
||||||
|
|
||||||
|
[CopyPath]
|
||||||
|
other = "複製路徑"
|
||||||
|
|
||||||
|
[Goto]
|
||||||
|
other = "跳至"
|
||||||
|
|
||||||
|
[GotoHeadline]
|
||||||
|
other = "跳至資料夾"
|
||||||
|
|
||||||
|
[GotoGo]
|
||||||
|
other = "確定"
|
||||||
|
|
||||||
|
[GotoClose]
|
||||||
|
other = "取消"
|
||||||
|
|
||||||
|
[FMError]
|
||||||
|
other = "Agent 回傳了錯誤,請查看主控台獲取詳細資訊。要建立新連線,請重新開啟 FM。"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
let receivedLength = 0;
|
||||||
|
let expectedLength = 0;
|
||||||
|
let root;
|
||||||
|
let draftHandle;
|
||||||
|
let accessHandle;
|
||||||
|
|
||||||
|
const Operation = Object.freeze({
|
||||||
|
WriteHeader: 1,
|
||||||
|
WriteChunks: 2,
|
||||||
|
DeleteFiles: 3
|
||||||
|
});
|
||||||
|
|
||||||
|
onmessage = async function (event) {
|
||||||
|
try {
|
||||||
|
const { operation, arrayBuffer, fileName } = event.data;
|
||||||
|
|
||||||
|
switch (operation) {
|
||||||
|
case Operation.WriteHeader: {
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
expectedLength = Number(dataView.getBigUint64(4, false));
|
||||||
|
receivedLength = 0;
|
||||||
|
|
||||||
|
// Create a new temporary file
|
||||||
|
root = await navigator.storage.getDirectory();
|
||||||
|
draftHandle = await root.getFileHandle(fileName, { create: true });
|
||||||
|
accessHandle = await draftHandle.createSyncAccessHandle();
|
||||||
|
|
||||||
|
// Inform that file handle is created
|
||||||
|
const dataChunk = arrayBuffer.slice(12);
|
||||||
|
receivedLength += dataChunk.byteLength;
|
||||||
|
accessHandle.write(dataChunk, { at: 0 });
|
||||||
|
const progress = 'got handle';
|
||||||
|
postMessage({ type: 'progress', progress: progress });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Operation.WriteChunks: {
|
||||||
|
if (!accessHandle) {
|
||||||
|
throw new Error('accessHandle is undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataChunk = arrayBuffer;
|
||||||
|
accessHandle.write(dataChunk, { at: receivedLength });
|
||||||
|
receivedLength += dataChunk.byteLength;
|
||||||
|
|
||||||
|
if (receivedLength === expectedLength) {
|
||||||
|
accessHandle.flush();
|
||||||
|
accessHandle.close();
|
||||||
|
|
||||||
|
const fileBlob = await draftHandle.getFile();
|
||||||
|
const blob = new Blob([fileBlob], { type: 'application/octet-stream' });
|
||||||
|
|
||||||
|
postMessage({ type: 'result', blob: blob, fileName: fileName });
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case Operation.DeleteFiles: {
|
||||||
|
for await (const [name, handle] of root.entries()) {
|
||||||
|
if (handle.kind === 'file') {
|
||||||
|
await root.removeEntry(name);
|
||||||
|
} else if (handle.kind === 'directory') {
|
||||||
|
await root.removeEntry(name, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
postMessage({ error: error.message });
|
||||||
|
}
|
||||||
|
};
|
||||||
Vendored
+3
-3
@@ -13,11 +13,11 @@
|
|||||||
<input type="number" name="ServerID" placeholder="1">
|
<input type="number" name="ServerID" placeholder="1">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>内网服务</label>
|
<label>{{tr "LocalService"}}</label>
|
||||||
<input type="text" name="Host" placeholder="192.168.1.1:80(带端口)">
|
<input type="text" name="Host" placeholder="{{tr "LocalServicePlaceholder"}}">
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>绑定域名</label>
|
<label>{{tr "BindHostname"}}</label>
|
||||||
<input type="text" name="Domain" placeholder="router.app.yourdomain.com">
|
<input type="text" name="Domain" placeholder="router.app.yourdomain.com">
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
+495
@@ -0,0 +1,495 @@
|
|||||||
|
{{define "dashboard-default/file"}}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{.Conf.Language}}">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>File List</title>
|
||||||
|
<link rel="shortcut icon" type="image/png" href="/static/logo.svg?v20210804" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/mdui@2/mdui.css" />
|
||||||
|
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet" />
|
||||||
|
<script src="https://unpkg.com/mdui@2/mdui.global.js"></script>
|
||||||
|
</head>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: 'Roboto', sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-list {
|
||||||
|
list-style-type: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
margin: 5px 0;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 5px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
#top-app-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#current-directory {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: normal;
|
||||||
|
margin: 0 15px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
max-width: calc(100% - 100px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="top-app-bar">
|
||||||
|
<mdui-dropdown>
|
||||||
|
<mdui-button-icon slot="trigger" icon="menu"></mdui-button-icon>
|
||||||
|
<mdui-menu>
|
||||||
|
<mdui-menu-item id="refresh">{{tr "Refresh"}}</mdui-menu-item>
|
||||||
|
<mdui-menu-item id="copy">{{tr "CopyPath"}}</mdui-menu-item>
|
||||||
|
<mdui-menu-item id="goto">{{tr "Goto"}}</mdui-menu-item>
|
||||||
|
</mdui-menu>
|
||||||
|
</mdui-dropdown>
|
||||||
|
<span id="current-directory"></span>
|
||||||
|
<mdui-button-icon id="upload" icon="upload"></mdui-button-icon>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<mdui-list id="file-list" class="file-list"></mdui-list>
|
||||||
|
|
||||||
|
<mdui-dialog id="error-dialog" headline="Error"
|
||||||
|
description="{{tr "FMError"}}"></mdui-dialog>
|
||||||
|
|
||||||
|
<mdui-dialog id="upd-modal" class="modal">
|
||||||
|
<mdui-linear-progress id="upd-progress"></mdui-linear-progress>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<mdui-dialog id="goto-dialog" headline="{{tr "GotoHeadline"}}" close-on-overlay-click>
|
||||||
|
<mdui-text-field id="goto-text" variant="outlined" value=""></mdui-text-field>
|
||||||
|
<mdui-button id="goto-go" slot="action" variant="text">{{tr "GotoGo"}}</mdui-button>
|
||||||
|
<mdui-button id="goto-close" slot="action" variant="tonal">{{tr "GotoClose"}}</mdui-button>
|
||||||
|
</mdui-dialog>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentPath = '/opt/nezha/';
|
||||||
|
let fileName = '';
|
||||||
|
let receivedBuffer = []; // 用于缓存数据块
|
||||||
|
let expectedLength = 0;
|
||||||
|
let receivedLength = 0;
|
||||||
|
let isFirstChunk = true;
|
||||||
|
let isUpCompleted = false;
|
||||||
|
let handleReady = false;
|
||||||
|
let worker;
|
||||||
|
|
||||||
|
function updateDirectoryTitle() {
|
||||||
|
const directoryTitle = document.getElementById('current-directory');
|
||||||
|
directoryTitle.textContent = `${currentPath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFileList(items) {
|
||||||
|
const fileListElement = document.getElementById('file-list');
|
||||||
|
fileListElement.innerHTML = '';
|
||||||
|
|
||||||
|
if (currentPath !== '/') {
|
||||||
|
const upItem = document.createElement('mdui-list-item');
|
||||||
|
upItem.className = 'file-item up-directory';
|
||||||
|
upItem.setAttribute('icon', 'arrow_back');
|
||||||
|
upItem.textContent = "..";
|
||||||
|
upItem.onclick = function () {
|
||||||
|
const lastSlashIndex = currentPath.lastIndexOf('/', currentPath.length - 2);
|
||||||
|
currentPath = currentPath.substring(0, lastSlashIndex + 1) || '/';
|
||||||
|
listFile();
|
||||||
|
};
|
||||||
|
fileListElement.appendChild(upItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (a.fileType === 'dir' && b.fileType !== 'dir') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a.fileType !== 'dir' && b.fileType === 'dir') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const listItem = document.createElement('mdui-list-item');
|
||||||
|
listItem.className = `file-item ${item.fileType.toLowerCase()}`;
|
||||||
|
listItem.setAttribute('nonclickable', 'true');
|
||||||
|
listItem.setAttribute('icon', 'insert_drive_file');
|
||||||
|
listItem.textContent = `${item.name}`;
|
||||||
|
|
||||||
|
if (item.fileType === 'dir') {
|
||||||
|
listItem.setAttribute('nonclickable', 'false');
|
||||||
|
listItem.setAttribute('icon', 'folder');
|
||||||
|
listItem.style.cursor = 'pointer';
|
||||||
|
listItem.onclick = function () {
|
||||||
|
currentPath += `${item.name}/`;
|
||||||
|
listFile();
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
const downloadButton = document.createElement('mdui-button-icon');
|
||||||
|
downloadButton.setAttribute('slot', 'end-icon');
|
||||||
|
downloadButton.setAttribute('icon', 'download');
|
||||||
|
downloadButton.onclick = function () {
|
||||||
|
const filePath = currentPath + item.name;
|
||||||
|
fileName = item.name;
|
||||||
|
downloadFile(filePath);
|
||||||
|
};
|
||||||
|
listItem.appendChild(downloadButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
fileListElement.appendChild(listItem);
|
||||||
|
});
|
||||||
|
updateDirectoryTitle();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUpdState() {
|
||||||
|
receivedBuffer = [];
|
||||||
|
expectedLength = 0;
|
||||||
|
receivedLength = 0;
|
||||||
|
isFirstChunk = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile(filePath) {
|
||||||
|
showUpdModal('d');
|
||||||
|
|
||||||
|
const prefix = new Int8Array([1]); // Request download
|
||||||
|
const filePathMessage = new TextEncoder().encode(filePath);
|
||||||
|
|
||||||
|
const msg = new Int8Array(prefix.length + filePathMessage.length);
|
||||||
|
msg.set(prefix);
|
||||||
|
msg.set(filePathMessage, prefix.length);
|
||||||
|
|
||||||
|
socket.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listFile() {
|
||||||
|
const prefix = new Int8Array([0]);
|
||||||
|
const resizeMessage = new TextEncoder().encode(currentPath);
|
||||||
|
|
||||||
|
const msg = new Int8Array(prefix.length + resizeMessage.length);
|
||||||
|
msg.set(prefix);
|
||||||
|
msg.set(resizeMessage, prefix.length);
|
||||||
|
|
||||||
|
socket.send(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadFile(file) {
|
||||||
|
showUpdModal('u');
|
||||||
|
|
||||||
|
const chunkSize = 1048576; // 1MB chunk
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
const filePath = `${currentPath}${file.name}`;
|
||||||
|
const fileSize = file.size;
|
||||||
|
const messageType = 2;
|
||||||
|
|
||||||
|
// Build header (type + file size + path)
|
||||||
|
const filePathBytes = new TextEncoder().encode(filePath);
|
||||||
|
const header = new ArrayBuffer(1 + 8 + filePathBytes.length);
|
||||||
|
const headerView = new DataView(header);
|
||||||
|
|
||||||
|
headerView.setUint8(0, messageType);
|
||||||
|
headerView.setBigUint64(1, BigInt(fileSize), false);
|
||||||
|
|
||||||
|
new Uint8Array(header, 9).set(filePathBytes);
|
||||||
|
|
||||||
|
// Send header
|
||||||
|
socket.send(header);
|
||||||
|
|
||||||
|
// Send data chunks
|
||||||
|
while (offset < fileSize) {
|
||||||
|
const chunk = file.slice(offset, offset + chunkSize);
|
||||||
|
const arrayBuffer = await readFileAsArrayBuffer(chunk);
|
||||||
|
socket.send(arrayBuffer);
|
||||||
|
offset += chunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkCompletion = setInterval(() => {
|
||||||
|
if (isUpCompleted) {
|
||||||
|
clearInterval(checkCompletion);
|
||||||
|
hideUpdModal();
|
||||||
|
resetUpdState();
|
||||||
|
listFile();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseFileList(arrayBuffer) {
|
||||||
|
const dataView = new DataView(arrayBuffer);
|
||||||
|
const items = [];
|
||||||
|
let offset = 4;
|
||||||
|
|
||||||
|
const pathLength = dataView.getUint32(offset);
|
||||||
|
offset += 4;
|
||||||
|
|
||||||
|
const pathArray = new Uint8Array(arrayBuffer, offset, pathLength);
|
||||||
|
currentPath = new TextDecoder('utf-8').decode(pathArray);
|
||||||
|
offset += pathLength;
|
||||||
|
|
||||||
|
while (offset < dataView.byteLength) {
|
||||||
|
const fileType = dataView.getUint8(offset);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
const nameLength = dataView.getUint8(offset);
|
||||||
|
offset += 1;
|
||||||
|
|
||||||
|
if (offset + nameLength > dataView.byteLength) {
|
||||||
|
console.error('Error: Name length exceeds buffer size');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameArray = new Uint8Array(arrayBuffer, offset, nameLength);
|
||||||
|
const name = new TextDecoder('utf-8').decode(nameArray);
|
||||||
|
offset += nameLength;
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
fileType: fileType === 0x01 ? 'dir' : 'f',
|
||||||
|
name: name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { items };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFileAsArrayBuffer(blob) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = () => reject(reader.error);
|
||||||
|
reader.readAsArrayBuffer(blob);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function concatenateArrayBuffers(buffers) {
|
||||||
|
let totalLength = 0;
|
||||||
|
buffers.forEach(buf => totalLength += buf.byteLength);
|
||||||
|
|
||||||
|
const result = new Uint8Array(totalLength);
|
||||||
|
let offset = 0;
|
||||||
|
buffers.forEach(buf => {
|
||||||
|
result.set(new Uint8Array(buf), offset);
|
||||||
|
offset += buf.byteLength;
|
||||||
|
});
|
||||||
|
|
||||||
|
return result.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function arraysEqual(a, b) {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
for (let i = 0; i < a.length; i++) {
|
||||||
|
if (a[i] !== b[i]) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyTextToClipboard(text) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy text to clipboard: ', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleError(errMsg) {
|
||||||
|
try {
|
||||||
|
console.error('Received error: ', errMsg);
|
||||||
|
hideUpdModal();
|
||||||
|
const errorDialog = document.getElementById('error-dialog');
|
||||||
|
errorDialog.open = true;
|
||||||
|
if (socket.readyState === WebSocket.OPEN) {
|
||||||
|
socket.close(1000, 'Closing due to error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error while handling error and closing WebSocket:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUpdModal(operation) {
|
||||||
|
const modal = document.getElementById('upd-modal');
|
||||||
|
modal.open = true;
|
||||||
|
if (operation === 'd') {
|
||||||
|
modal.setAttribute('headline', 'Downloading...');
|
||||||
|
} else if (operation === 'u') {
|
||||||
|
modal.setAttribute('headline', 'Uploading...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUpdModal() {
|
||||||
|
const modal = document.getElementById('upd-modal');
|
||||||
|
modal.open = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForHandleReady() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
const checkReady = () => {
|
||||||
|
if (handleReady) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(checkReady, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkReady();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const socket = new WebSocket((window.location.protocol === 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/file/' + '{{.SessionID}}');
|
||||||
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
socket.onmessage = async function (event) {
|
||||||
|
try {
|
||||||
|
const arrayBuffer = event.data;
|
||||||
|
|
||||||
|
if (isFirstChunk) {
|
||||||
|
const identifier = new Uint8Array(arrayBuffer, 0, 4);
|
||||||
|
const fileIdentifier = new Uint8Array([0x4E, 0x5A, 0x54, 0x44]); // NZTD
|
||||||
|
const fileNameIdentifier = new Uint8Array([0x4E, 0x5A, 0x46, 0x4E]); // NZFN
|
||||||
|
const errorIdentifier = new Uint8Array([0x4E, 0x45, 0x52, 0x52]); // NERR
|
||||||
|
const completeIdentifier = new Uint8Array([0x4E, 0x5A, 0x55, 0x50]); // NZUP
|
||||||
|
|
||||||
|
if (arraysEqual(identifier, fileIdentifier)) {
|
||||||
|
worker = new Worker('/static/file.js');
|
||||||
|
worker.onmessage = async function (event) {
|
||||||
|
switch (event.data.type) {
|
||||||
|
case 'error':
|
||||||
|
console.error('Error from worker:', event.data.error);
|
||||||
|
break;
|
||||||
|
case 'progress':
|
||||||
|
handleReady = true;
|
||||||
|
break;
|
||||||
|
case 'result':
|
||||||
|
handleReady = false;
|
||||||
|
const url = URL.createObjectURL(event.data.blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = event.data.fileName;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
// Delete the file in OPFS
|
||||||
|
window.addEventListener('beforeunload', async () => {
|
||||||
|
await worker.postMessage({ operation: 3, arrayBuffer: null, fileName: event.data.fileName });
|
||||||
|
});
|
||||||
|
|
||||||
|
hideUpdModal();
|
||||||
|
resetUpdState();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await worker.postMessage({ operation: 1, arrayBuffer: arrayBuffer, fileName: fileName });
|
||||||
|
isFirstChunk = false;
|
||||||
|
} else if (arraysEqual(identifier, fileNameIdentifier)) {
|
||||||
|
// List files
|
||||||
|
const { items } = await parseFileList(arrayBuffer);
|
||||||
|
updateFileList(items);
|
||||||
|
return;
|
||||||
|
} else if (arraysEqual(identifier, errorIdentifier)) {
|
||||||
|
// Handle error
|
||||||
|
const errBytes = arrayBuffer.slice(4);
|
||||||
|
const errMsg = new TextDecoder('utf-8').decode(errBytes);
|
||||||
|
await handleError(errMsg);
|
||||||
|
return;
|
||||||
|
} else if (arraysEqual(identifier, completeIdentifier)) {
|
||||||
|
// Upload is completed
|
||||||
|
isUpCompleted = true;
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
console.log('Unknown identifier');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await waitForHandleReady();
|
||||||
|
await worker.postMessage({ operation: 2, arrayBuffer: arrayBuffer, fileName: fileName });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error processing received data:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onopen = function (event) {
|
||||||
|
listFile();
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onerror = function (event) {
|
||||||
|
console.error('WebSocket error:', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.onclose = function (event) {
|
||||||
|
console.log('WebSocket connection closed:', event);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('refresh').addEventListener('click', listFile);
|
||||||
|
|
||||||
|
document.getElementById('copy').addEventListener('click', async () => {
|
||||||
|
await copyTextToClipboard(currentPath);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('goto').addEventListener('click', function () {
|
||||||
|
const dialog = document.getElementById('goto-dialog');
|
||||||
|
const textField = document.getElementById('goto-text');
|
||||||
|
const goButton = document.getElementById('goto-go');
|
||||||
|
const closeButton = document.getElementById('goto-close');
|
||||||
|
|
||||||
|
dialog.open = true;
|
||||||
|
|
||||||
|
// Ensure the path ends with a separator
|
||||||
|
const updateText = function (event) {
|
||||||
|
let text = event.target.value;
|
||||||
|
if (!text.endsWith('/')) {
|
||||||
|
text += '/';
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGoClick = function () {
|
||||||
|
let text = updateText({ target: textField });
|
||||||
|
currentPath = text;
|
||||||
|
listFile();
|
||||||
|
dialog.open = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
textField.removeEventListener('change', updateText);
|
||||||
|
textField.addEventListener('change', updateText);
|
||||||
|
|
||||||
|
goButton.removeEventListener('click', handleGoClick);
|
||||||
|
goButton.addEventListener('click', handleGoClick);
|
||||||
|
|
||||||
|
closeButton.addEventListener("click", () => dialog.open = false);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('upload').addEventListener('click', async function () {
|
||||||
|
const fileInput = document.createElement('input');
|
||||||
|
fileInput.type = 'file';
|
||||||
|
fileInput.style.display = 'none';
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', async function (event) {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
await uploadFile(file);
|
||||||
|
isUpCompleted = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.appendChild(fileInput);
|
||||||
|
fileInput.click();
|
||||||
|
document.body.removeChild(fileInput);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
+2
-2
@@ -16,8 +16,8 @@
|
|||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>{{tr "Name"}}</th>
|
<th>{{tr "Name"}}</th>
|
||||||
<th>Agent ID</th>
|
<th>Agent ID</th>
|
||||||
<th>内网服务</th>
|
<th>{{tr "LocalService"}}</th>
|
||||||
<th>绑定域名</th>
|
<th>{{tr "BindHostname"}}</th>
|
||||||
<th>{{tr "Administration"}}</th>
|
<th>{{tr "Administration"}}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
+58
-4
@@ -23,14 +23,47 @@
|
|||||||
body {
|
body {
|
||||||
background-color: black;
|
background-color: black;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#file-list-iframe {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 30%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
background-color: white;
|
||||||
|
display: none;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
#folder-button {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
right: 20px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 25px;
|
||||||
|
font-size: 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<body onresize="onResize()">
|
<body onresize="onResize()">
|
||||||
<div id="terminal-container"></div>
|
<div id="terminal-container"></div>
|
||||||
|
<iframe id="file-list-iframe" src=""></iframe>
|
||||||
|
<button id="folder-button">📁</button>
|
||||||
|
|
||||||
<script src="https://unpkg.com/[email protected]/lib/xterm.js"></script>
|
<script src="https://unpkg.com/[email protected]/lib/xterm.js"></script>
|
||||||
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-fit.js"></script>
|
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-fit.js"></script>
|
||||||
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-web-links.js"></script>
|
<script src="https://unpkg.com/@xterm/[email protected]/lib/addon-web-links.js"></script>
|
||||||
<script src="https://unpkg.com/trzsz@1.1.5/lib/trzsz.js"></script>
|
<script src="https://unpkg.com/@xterm/[email protected]1.0/lib/addon-attach.js"></script>
|
||||||
<script>
|
<script>
|
||||||
let sendResizing = false;
|
let sendResizing = false;
|
||||||
|
|
||||||
@@ -74,10 +107,10 @@
|
|||||||
cursorBlink: true,
|
cursorBlink: true,
|
||||||
});
|
});
|
||||||
const socket = new WebSocket((window.location.protocol == 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/terminal/' + '{{.SessionID}}');
|
const socket = new WebSocket((window.location.protocol == 'https:' ? 'wss' : 'ws') + '://' + window.location.host + '/terminal/' + '{{.SessionID}}');
|
||||||
socket.binaryType = 'arraybuffer';
|
|
||||||
|
|
||||||
const trzszAddon = new TrzszAddon(socket);
|
const attachAddon = new AttachAddon.AttachAddon(socket);
|
||||||
term.loadAddon(trzszAddon);
|
term.loadAddon(attachAddon);
|
||||||
|
|
||||||
const fitAddon = new FitAddon.FitAddon();
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
@@ -95,6 +128,27 @@
|
|||||||
socket.onerror = () => {
|
socket.onerror = () => {
|
||||||
alert('{{tr "TerminalConnectionFailed"}}')
|
alert('{{tr "TerminalConnectionFailed"}}')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 处理文件夹按钮点击事件
|
||||||
|
const folderButton = document.getElementById('folder-button');
|
||||||
|
const fileListIframe = document.getElementById('file-list-iframe');
|
||||||
|
let fileListVisible = false;
|
||||||
|
|
||||||
|
folderButton.addEventListener('click', () => {
|
||||||
|
if (!fileListVisible) {
|
||||||
|
// 显示文件列表
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
id: "{{.ServerID}}"
|
||||||
|
}).toString();
|
||||||
|
fileListIframe.src = `/file?${params}`;
|
||||||
|
fileListIframe.style.display = 'block';
|
||||||
|
fileListVisible = true;
|
||||||
|
} else {
|
||||||
|
// 隐藏文件列表
|
||||||
|
fileListIframe.style.display = 'none';
|
||||||
|
fileListVisible = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
+16
-15
@@ -261,22 +261,23 @@
|
|||||||
const x = readableBytes(bs)
|
const x = readableBytes(bs)
|
||||||
return x != "NaN undefined" ? x : '0B'
|
return x != "NaN undefined" ? x : '0B'
|
||||||
},
|
},
|
||||||
getCoreAndGHz(str){
|
getCoreAndGHz(arr) {
|
||||||
if((str || []).hasOwnProperty(0) === false){
|
if ((arr || []).length === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
str = str[0];
|
let totalCores = 0;
|
||||||
let GHz = str.match(/(\d|\.)+GHz/g);
|
arr.forEach(str => {
|
||||||
let Core = str.match(/(\d|\.)+ Physical/g);
|
let coreMatch = str.match(/(\d+(\.\d+)?) Physical/g);
|
||||||
GHz = GHz!==null?GHz.hasOwnProperty(0)===false?'':GHz[0]:''
|
let coreCount = 0;
|
||||||
Core = Core!==null?Core.hasOwnProperty(0)===false?'?':Core[0]:'?'
|
if (coreMatch) {
|
||||||
if(Core === '?'){
|
coreCount = parseFloat(coreMatch[0]);
|
||||||
let Core = str.match(/(\d|\.)+ Virtual/g);
|
} else {
|
||||||
Core = Core!==null?Core.hasOwnProperty(0)===false?'?':Core[0]:'?'
|
let coreMatch = str.match(/(\d+(\.\d+)?) Virtual/g);
|
||||||
return Core.replace('Virtual','Core')
|
coreCount = coreMatch ? parseFloat(coreMatch[0]) : 0;
|
||||||
}
|
}
|
||||||
return Core.replace('Physical','Core');
|
totalCores += coreCount;
|
||||||
|
});
|
||||||
|
return `${totalCores} Cores`;
|
||||||
},
|
},
|
||||||
getByteToGB(bs){
|
getByteToGB(bs){
|
||||||
return (bs/1024/1024/1024).toFixed(2) + 'GB'
|
return (bs/1024/1024/1024).toFixed(2) + 'GB'
|
||||||
|
|||||||
+15
-13
@@ -474,21 +474,23 @@
|
|||||||
const sizes = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"];
|
const sizes = ["B", "K", "M", "G", "T", "P", "E", "Z", "Y"];
|
||||||
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + sizes[i];
|
return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + sizes[i];
|
||||||
},
|
},
|
||||||
getCoreAndGHz(str){
|
getCoreAndGHz(arr) {
|
||||||
if ((str || []).hasOwnProperty(0) === false) {
|
if ((arr || []).length === 0) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
str = str[0];
|
let totalCores = 0;
|
||||||
let GHz = str.match(/(\d|\.)+GHz/g);
|
arr.forEach(str => {
|
||||||
let Core = str.match(/(\d|\.)+ Physical/g);
|
let coreMatch = str.match(/(\d+(\.\d+)?) Physical/g);
|
||||||
GHz = GHz !== null ? GHz.hasOwnProperty(0) === false ? '' : GHz[0] : ''
|
let coreCount = 0;
|
||||||
Core = Core !== null ? Core.hasOwnProperty(0) === false ? '?' : Core[0] : '?'
|
if (coreMatch) {
|
||||||
if (Core === '?') {
|
coreCount = parseFloat(coreMatch[0]);
|
||||||
let Core = str.match(/(\d|\.)+ Virtual/g);
|
} else {
|
||||||
Core = Core !== null ? Core.hasOwnProperty(0) === false ? '?' : Core[0] : '?'
|
let coreMatch = str.match(/(\d+(\.\d+)?) Virtual/g);
|
||||||
return Core.replace('Virtual', 'Core')
|
coreCount = coreMatch ? parseFloat(coreMatch[0]) : 0;
|
||||||
}
|
}
|
||||||
return Core.replace('Physical', 'Core');
|
totalCores += coreCount;
|
||||||
|
});
|
||||||
|
return `${totalCores} Cores`;
|
||||||
},
|
},
|
||||||
getK2Gb(bs){
|
getK2Gb(bs){
|
||||||
bs = bs / 1024 / 1024 / 1024;
|
bs = bs / 1024 / 1024 / 1024;
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ LOOP:
|
|||||||
endCh := make(chan struct{})
|
endCh := make(chan struct{})
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
_, innerErr := io.Copy(stream.userIo, stream.agentIo)
|
_, innerErr := io.CopyBuffer(stream.userIo, stream.agentIo, make([]byte, 1048576))
|
||||||
if innerErr != nil {
|
if innerErr != nil {
|
||||||
err = innerErr
|
err = innerErr
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@ LOOP:
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
go func() {
|
go func() {
|
||||||
_, innerErr := io.Copy(stream.agentIo, stream.userIo)
|
_, innerErr := io.CopyBuffer(stream.agentIo, stream.userIo, make([]byte, 1048576))
|
||||||
if innerErr != nil {
|
if innerErr != nil {
|
||||||
err = innerErr
|
err = innerErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@ func (s *NezhaHandler) ReportSystemInfo(c context.Context, r *pb.Host) (*pb.Rece
|
|||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 虽然会在启动时panic, 可以断言不会走这个分支, 但是考虑到动态加载配置或者其它情况, 这里输出一下方便检查奇奇怪怪的BUG
|
// 虽然会在启动时panic, 可以断言不会走这个分支, 但是考虑到动态加载配置或者其它情况, 这里输出一下方便检查奇奇怪怪的BUG
|
||||||
log.Printf("NEZHA>> 未找到对应的DDNS配置(%s), 或者是provider填写不正确, 请前往config.yml检查你的设置\n", singleton.ServerList[clientID].DDNSProfile)
|
log.Printf("NEZHA>> 未找到对应的DDNS配置(%s), 或者是provider填写不正确, 请前往config.yml检查你的设置", singleton.ServerList[clientID].DDNSProfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+39
-53
@@ -3,86 +3,72 @@ package singleton
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"slices"
|
||||||
|
|
||||||
ddns2 "github.com/naiba/nezha/pkg/ddns"
|
ddns2 "github.com/naiba/nezha/pkg/ddns"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RetryableUpdateDomain(provider ddns2.Provider, config *ddns2.DomainConfig, maxRetries int) bool {
|
const (
|
||||||
if nil == config {
|
ProviderWebHook = "webhook"
|
||||||
return false
|
ProviderCloudflare = "cloudflare"
|
||||||
|
ProviderTencentCloud = "tencentcloud"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProviderFunc func(*ddns2.DomainConfig) ddns2.Provider
|
||||||
|
|
||||||
|
func RetryableUpdateDomain(provider ddns2.Provider, domainConfig *ddns2.DomainConfig, maxRetries int) {
|
||||||
|
if domainConfig == nil {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
for retries := 0; retries < maxRetries; retries++ {
|
for retries := 0; retries < maxRetries; retries++ {
|
||||||
log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)\n", config.FullDomain, retries+1, maxRetries)
|
log.Printf("NEZHA>> 正在尝试更新域名(%s)DDNS(%d/%d)", domainConfig.FullDomain, retries+1, maxRetries)
|
||||||
if provider.UpdateDomain(config) {
|
if err := provider.UpdateDomain(domainConfig); err != nil {
|
||||||
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功\n", config.FullDomain)
|
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败: %v", domainConfig.FullDomain, err)
|
||||||
return true
|
} else {
|
||||||
|
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS成功", domainConfig.FullDomain)
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.Printf("NEZHA>> 尝试更新域名(%s)DDNS失败\n", config.FullDomain)
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Deprecated
|
||||||
func GetDDNSProviderFromString(provider string) (ddns2.Provider, error) {
|
func GetDDNSProviderFromString(provider string) (ddns2.Provider, error) {
|
||||||
switch provider {
|
switch provider {
|
||||||
case "webhook":
|
case ProviderWebHook:
|
||||||
return &ddns2.ProviderWebHook{
|
return ddns2.NewProviderWebHook(Conf.DDNS.WebhookURL, Conf.DDNS.WebhookMethod, Conf.DDNS.WebhookRequestBody, Conf.DDNS.WebhookHeaders), nil
|
||||||
URL: Conf.DDNS.WebhookURL,
|
case ProviderCloudflare:
|
||||||
RequestMethod: Conf.DDNS.WebhookMethod,
|
return ddns2.NewProviderCloudflare(Conf.DDNS.AccessSecret), nil
|
||||||
RequestBody: Conf.DDNS.WebhookRequestBody,
|
case ProviderTencentCloud:
|
||||||
RequestHeader: Conf.DDNS.WebhookHeaders,
|
return ddns2.NewProviderTencentCloud(Conf.DDNS.AccessID, Conf.DDNS.AccessSecret), nil
|
||||||
}, nil
|
default:
|
||||||
case "dummy":
|
return new(ddns2.ProviderDummy), fmt.Errorf("无法找到配置的DDNS提供者 %s", provider)
|
||||||
return &ddns2.ProviderDummy{}, nil
|
|
||||||
case "cloudflare":
|
|
||||||
return &ddns2.ProviderCloudflare{
|
|
||||||
Secret: Conf.DDNS.AccessSecret,
|
|
||||||
}, nil
|
|
||||||
case "tencentcloud":
|
|
||||||
return &ddns2.ProviderTencentCloud{
|
|
||||||
SecretID: Conf.DDNS.AccessID,
|
|
||||||
SecretKey: Conf.DDNS.AccessSecret,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
return &ddns2.ProviderDummy{}, fmt.Errorf("无法找到配置的DDNS提供者%s", Conf.DDNS.Provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetDDNSProviderFromProfile(profileName string) (ddns2.Provider, error) {
|
func GetDDNSProviderFromProfile(profileName string) (ddns2.Provider, error) {
|
||||||
profile, ok := Conf.DDNS.Profiles[profileName]
|
profile, ok := Conf.DDNS.Profiles[profileName]
|
||||||
if !ok {
|
if !ok {
|
||||||
return &ddns2.ProviderDummy{}, fmt.Errorf("未找到配置项 %s", profileName)
|
return new(ddns2.ProviderDummy), fmt.Errorf("未找到配置项 %s", profileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
switch profile.Provider {
|
switch profile.Provider {
|
||||||
case "webhook":
|
case ProviderWebHook:
|
||||||
return &ddns2.ProviderWebHook{
|
return ddns2.NewProviderWebHook(profile.WebhookURL, profile.WebhookMethod, profile.WebhookRequestBody, profile.WebhookHeaders), nil
|
||||||
URL: profile.WebhookURL,
|
case ProviderCloudflare:
|
||||||
RequestMethod: profile.WebhookMethod,
|
return ddns2.NewProviderCloudflare(profile.AccessSecret), nil
|
||||||
RequestBody: profile.WebhookRequestBody,
|
case ProviderTencentCloud:
|
||||||
RequestHeader: profile.WebhookHeaders,
|
return ddns2.NewProviderTencentCloud(profile.AccessID, profile.AccessSecret), nil
|
||||||
}, nil
|
default:
|
||||||
case "dummy":
|
return new(ddns2.ProviderDummy), fmt.Errorf("无法找到配置的DDNS提供者 %s", profile.Provider)
|
||||||
return &ddns2.ProviderDummy{}, nil
|
|
||||||
case "cloudflare":
|
|
||||||
return &ddns2.ProviderCloudflare{
|
|
||||||
Secret: profile.AccessSecret,
|
|
||||||
}, nil
|
|
||||||
case "tencentcloud":
|
|
||||||
return &ddns2.ProviderTencentCloud{
|
|
||||||
SecretID: profile.AccessID,
|
|
||||||
SecretKey: profile.AccessSecret,
|
|
||||||
}, nil
|
|
||||||
}
|
}
|
||||||
return &ddns2.ProviderDummy{}, fmt.Errorf("无法找到配置的DDNS提供者%s", profile.Provider)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func ValidateDDNSProvidersFromProfiles() error {
|
func ValidateDDNSProvidersFromProfiles() error {
|
||||||
validProviders := map[string]bool{"webhook": true, "dummy": true, "cloudflare": true, "tencentcloud": true}
|
validProviders := []string{ProviderWebHook, ProviderCloudflare, ProviderTencentCloud}
|
||||||
providers := make(map[string]string)
|
for _, profile := range Conf.DDNS.Profiles {
|
||||||
for profileName, profile := range Conf.DDNS.Profiles {
|
if ok := slices.Contains(validProviders, profile.Provider); !ok {
|
||||||
if _, ok := validProviders[profile.Provider]; !ok {
|
|
||||||
return fmt.Errorf("无法找到配置的DDNS提供者%s", profile.Provider)
|
return fmt.Errorf("无法找到配置的DDNS提供者%s", profile.Provider)
|
||||||
}
|
}
|
||||||
providers[profileName] = profile.Provider
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user