├── .editorconfig
├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.yml
│ ├── config.yml
│ └── feature_request.yml
├── dependabot.yml
├── pull_request_template.md
└── workflows
│ ├── dockerhub-description.yml
│ ├── release.yml
│ ├── stale.yml
│ └── test.yml
├── .gitignore
├── .goreleaser.yml
├── .vscode
└── launch.json
├── Dockerfile
├── LICENSE
├── Makefile
├── README.md
├── README_EN.md
├── config
├── config.go
├── domains.go
├── domains_test.go
├── netInterface.go
├── netInterface_test.go
├── user.go
├── webhook.go
└── webhook_test.go
├── ddns-web.png
├── dns
├── alidns.go
├── baidu.go
├── callback.go
├── cloudflare.go
├── dnspod.go
├── dynadot.go
├── dynv6.go
├── godaddy.go
├── huawei.go
├── index.go
├── namecheap.go
├── namesilo.go
├── porkbun.go
├── spaceship.go
├── tencent_cloud.go
├── traffic_route.go
└── vercel.go
├── favicon.ico
├── go.mod
├── go.sum
├── main.go
├── static
├── bootstrap.min.css
├── common.css
├── constant.js
├── i18n.js
├── theme-button.css
├── theme.js
├── tooltips.js
└── utils.js
├── util
├── aliyun_signer.go
├── aliyun_signer_util.go
├── andriod_time.go
├── baidu_signer.go
├── bcrypt.go
├── copy_url_params.go
├── docker_util.go
├── escape.go
├── http_client_util.go
├── http_util.go
├── huawei_signer.go
├── ip_cache.go
├── messages.go
├── net.go
├── net_resolver.go
├── net_resolver_test.go
├── net_test.go
├── ordinal.go
├── ordinal_test.go
├── semver
│ ├── version.go
│ └── version_test.go
├── string.go
├── string_test.go
├── tencent_cloud_signer.go
├── termux.go
├── termux_test.go
├── token.go
├── traffic_route_signer.go
├── update
│ ├── apply.go
│ ├── apply_test.go
│ ├── arch.go
│ ├── arm.go
│ ├── decompress.go
│ ├── decompress_test.go
│ ├── detect.go
│ ├── errors.go
│ ├── latest.go
│ ├── package.go
│ ├── release.go
│ └── update.go
├── user.go
└── wait_internet.go
└── web
├── auth.go
├── login.go
├── login.html
├── logout.go
├── logs.go
├── return_json.go
├── save.go
├── webhookTest.go
├── writing.go
└── writing.html
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.go]
12 | indent_style = tab
13 | indent_size = 2
14 |
15 | [Dockerfile]
16 | indent_style = tab
17 | indent_size = 4
18 |
19 | [Makefile]
20 | indent_style = tab
21 | indent_size = 4
22 |
23 | [.travis.yml]
24 | indent_style = space
25 | indent_size = 2
26 |
27 | [*.json]
28 | indent_style = space
29 | indent_size = 2
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.yml:
--------------------------------------------------------------------------------
1 | name: Bug
2 | description: Report a bug in ddns-go
3 | labels: ['bug']
4 |
5 | body:
6 | - type: textarea
7 | attributes:
8 | label: Description
9 | description: A clear and concise description of what the bug is
10 | validations:
11 | required: true
12 |
13 | - type: dropdown
14 | attributes:
15 | label: DNS Provider
16 | description: The DNS provider you are using
17 | multiple: true
18 | options:
19 | - 阿里云
20 | - 腾讯云
21 | - DnsPod
22 | - Cloudflare
23 | - 华为云
24 | - Callback
25 | - 百度云
26 | - Porkbun
27 | - GoDaddy
28 | - Namecheap
29 | - NameSilo
30 | - Vercel
31 | - Dynadot
32 | - Others
33 |
34 | - type: dropdown
35 | attributes:
36 | label: Did you search for similar issues before submitting this one?
37 | options:
38 | - No, I didn't
39 | - Yes, I did, but I didn't find anything useful
40 | validations:
41 | required: true
42 |
43 | - type: dropdown
44 | attributes:
45 | label: Operating System
46 | description: The operating system you are running ddns-go on
47 | options:
48 | - Linux
49 | - Windows
50 | - macOS (Darwin)
51 | - FreeBSD
52 | validations:
53 | required: true
54 |
55 | - type: dropdown
56 | attributes:
57 | label: Architecture
58 | description: The architecture you are running ddns-go on
59 | options:
60 | - i386
61 | - x86_64
62 | - armv5
63 | - armv6
64 | - armv7
65 | - arm64
66 | - mips
67 | - mipsle
68 | - mips64
69 | - mips64le
70 | validations:
71 | required: true
72 |
73 | - type: input
74 | attributes:
75 | label: Version
76 | description: The version of ddns-go you are using
77 | placeholder: v0.0.1
78 | validations:
79 | required: true
80 |
81 | - type: dropdown
82 | attributes:
83 | label: How are you running ddns-go?
84 | options:
85 | - Docker
86 | - Service
87 | - Other
88 | validations:
89 | required: true
90 |
91 | - type: textarea
92 | attributes:
93 | label: Any other information
94 | description: |
95 | Please provide the steps to reproduce the bug.
96 | Or any other screenshots or logs that might help us understand the issue better.
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/config.yml:
--------------------------------------------------------------------------------
1 | blank_issues_enabled: false
2 | contact_links:
3 | - name: None of the above?
4 | url: https://github.com/jeessy2/ddns-go/discussions
5 | about: If you have any other questions, please visit our Discussions page
6 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.yml:
--------------------------------------------------------------------------------
1 | name: Feature Request
2 | description: Feature request for ddns-go
3 | labels: ['enhancement']
4 |
5 | body:
6 | - type: textarea
7 | attributes:
8 | label: Description
9 | description: A clear and concise description of what the feature is
10 | validations:
11 | required: true
12 |
13 | - type: textarea
14 | attributes:
15 | label: Problem
16 | description: Describe the problem you are facing
17 |
18 | - type: textarea
19 | attributes:
20 | label: Other Description
21 | description: Any other information you would like to provide
22 |
23 | - type: checkboxes
24 | attributes:
25 | label: Checklist
26 | description: Please check the following before submitting your feature request
27 | options:
28 | - label: I am using the latest version and have confirmed that the feature is not yet implemented in the latest version
29 | required: true
30 | - label: I have searched for similar feature requests before submitting this one
31 | required: true
32 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | # To get started with Dependabot version updates, you'll need to specify which
2 | # package ecosystems to update and where the package manifests are located.
3 | # Please see the documentation for all configuration options:
4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5 |
6 | version: 2
7 | updates:
8 | - package-ecosystem: "gomod" # Golang
9 | directory: "/" # Location of package manifests
10 | schedule:
11 | interval: "daily"
12 |
13 | - package-ecosystem: "github-actions" # GitHub Actions
14 | directory: "/"
15 | schedule:
16 | interval: "daily"
17 |
--------------------------------------------------------------------------------
/.github/pull_request_template.md:
--------------------------------------------------------------------------------
1 | # What does this PR do?
2 |
3 | # Motivation
4 |
5 | # Additional Notes
6 |
--------------------------------------------------------------------------------
/.github/workflows/dockerhub-description.yml:
--------------------------------------------------------------------------------
1 | name: Update Docker Hub Description
2 | on:
3 | push:
4 | branches:
5 | - master
6 | paths:
7 | - README.md
8 | - .github/workflows/dockerhub-description.yml
9 |
10 | jobs:
11 | dockerHubDescription:
12 | runs-on: ubuntu-latest
13 | if: github.repository == 'jeessy2/ddns-go'
14 | steps:
15 | - name: Checkout
16 | uses: actions/checkout@v4
17 |
18 | - name: Docker Hub Description
19 | uses: peter-evans/dockerhub-description@v4
20 | with:
21 | username: ${{ secrets.DOCKER_USERNAME }}
22 | password: ${{ secrets.DOCKER_PASSWORD }}
23 | repository: ${{ secrets.DOCKER_USERNAME }}/ddns-go
24 | short-description: ${{ github.event.repository.description }}
25 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: release
2 |
3 | on:
4 | push:
5 | # Sequence of patterns matched against refs/tags
6 | tags:
7 | - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
8 |
9 | permissions:
10 | contents: write
11 | packages: write
12 |
13 | jobs:
14 | goreleaser:
15 | name: Build
16 | runs-on: ubuntu-latest
17 | steps:
18 | - name: Checkout
19 | uses: actions/checkout@v4
20 | with:
21 | fetch-depth: 0
22 |
23 | - name: Set up Go
24 | uses: actions/setup-go@v5
25 | with:
26 | go-version-file: 'go.mod'
27 |
28 | - name: Set up QEMU
29 | uses: docker/setup-qemu-action@v3
30 |
31 | - name: Set up Docker Buildx
32 | uses: docker/setup-buildx-action@v3
33 |
34 | - name: Login to Docker Hub
35 | uses: docker/login-action@v3
36 | with:
37 | username: ${{ secrets.DOCKER_USERNAME }}
38 | password: ${{ secrets.DOCKER_PASSWORD }}
39 |
40 | - name: Login to GitHub Container Registry
41 | uses: docker/login-action@v3
42 | with:
43 | registry: ghcr.io
44 | username: ${{ github.actor }}
45 | password: ${{ secrets.GITHUB_TOKEN }}
46 |
47 | - name: Run GoReleaser
48 | uses: goreleaser/goreleaser-action@v6
49 | if: startsWith(github.ref, 'refs/tags/')
50 | with:
51 | distribution: goreleaser
52 | version: latest
53 | args: release --clean
54 | env:
55 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
56 | DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
57 |
--------------------------------------------------------------------------------
/.github/workflows/stale.yml:
--------------------------------------------------------------------------------
1 | name: Close stale issues and PRs
2 |
3 | on:
4 | schedule:
5 | - cron: "30 1 * * *"
6 |
7 | jobs:
8 | stale:
9 | permissions:
10 | issues: write # for actions/stale to close stale issues
11 | pull-requests: write # for actions/stale to close stale PRs
12 | runs-on: ubuntu-latest
13 | steps:
14 | - name: Stale
15 | uses: actions/stale@v9
16 | with:
17 | stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove Stale label or comment or this will be closed in 5 days.'
18 | stale-pr-message: 'This PR is stale because it has been open 30 days with no activity. Remove Stale label or comment or this will be closed in 5 days.'
19 | close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.'
20 | close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'
21 | exempt-issue-labels: 'bug,help wanted,question,documentation,keep'
22 | exempt-pr-labels: 'bug,help wanted,question,documentation,keep'
23 | days-before-stale: 30
24 | days-before-close: 5
25 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: test
2 |
3 | on:
4 | push:
5 | pull_request:
6 |
7 | jobs:
8 | test:
9 | runs-on: ubuntu-latest
10 | strategy:
11 | matrix:
12 | goarch: [amd64, arm64]
13 | steps:
14 | - name: Checkout
15 | uses: actions/checkout@v4
16 |
17 | - name: Set up Go
18 | uses: actions/setup-go@v5
19 | with:
20 | go-version-file: 'go.mod'
21 |
22 | - name: Test
23 | run: |
24 | # Run tests only when GOARCH is amd64, otherwize run builds only.
25 | if [ "${{ matrix.goarch }}" = "amd64" ]; then
26 | make build test
27 | else
28 | GOARCH=${{ matrix.goarch }} make build
29 | fi
30 | - name: Upload artifact
31 | uses: actions/upload-artifact@v4
32 | with:
33 | name: ddns-go_${{ matrix.goarch }}
34 | path: ddns-go
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Compiled Object files, Static and Dynamic libs (Shared Objects)
2 | *.o
3 | *.a
4 | *.so
5 | /ddns-go
6 | __*
7 |
8 | # Folders
9 | _obj
10 | _test
11 | .vagrant
12 | releases
13 | tmp
14 | /.idea/
15 | vendor/
16 | /dist
17 |
18 | # Architecture specific extensions/prefixes
19 | trace.out
20 | *.out
21 | .DS_Store
22 | _testmain.go
23 |
24 | *.exe
25 | *.test
26 | *.prof
27 | profile.cov
28 | coverage.html
29 | /go.sum
30 |
31 | # Emacs backup files
32 | *~
33 | .*~
34 |
--------------------------------------------------------------------------------
/.goreleaser.yml:
--------------------------------------------------------------------------------
1 | # This is an example goreleaser.yaml file with some sane defaults.
2 | # Make sure to check the documentation at http://goreleaser.com
3 |
4 | version: 2
5 |
6 | before:
7 | hooks:
8 | # You may remove this if you don't use go modules.
9 | - go mod download
10 | # you may remove this if you don't need go generate
11 | - go generate ./...
12 | builds:
13 | - env:
14 | - CGO_ENABLED=0
15 | flags:
16 | - -trimpath
17 | goos:
18 | - android
19 | - linux
20 | - windows
21 | - darwin
22 | - freebsd
23 | goarch:
24 | - '386'
25 | - amd64
26 | - arm
27 | - arm64
28 | - mips
29 | - mipsle
30 | - mips64
31 | - mips64le
32 | goarm:
33 | - '5'
34 | - '6'
35 | - '7'
36 | gomips:
37 | - hardfloat
38 | - softfloat
39 | ignore:
40 | # we only need the arm64 build on android
41 | - goos: android
42 | goarch: arm
43 | - goos: android
44 | goarch: '386'
45 | - goos: android
46 | goarch: amd64
47 | ldflags:
48 | - -s -w -X main.version={{.Tag}} -X main.buildTime={{.Date}}
49 | hooks:
50 | post:
51 | - sh -c 'test -d zoneinfo || cp -r /usr/share/zoneinfo .'
52 |
53 | archives:
54 | # use zip for windows archives
55 | - format_overrides:
56 | - goos: windows
57 | format: zip
58 | # this name template makes the OS and Arch compatible with the results of uname.
59 | name_template: >-
60 | {{ .ProjectName }}_
61 | {{- .Version }}_
62 | {{- .Os }}_
63 | {{- if eq .Arch "amd64" }}x86_64
64 | {{- else if eq .Arch "386" }}i386
65 | {{- else }}{{ .Arch }}{{ end }}
66 | {{- if .Mips }}_{{ .Mips }}{{ end }}
67 | {{- if .Arm }}v{{ .Arm }}{{ end }}
68 |
69 | checksum:
70 | name_template: 'checksums.txt'
71 | snapshot:
72 | version_template: "{{ incpatch .Version }}-devel"
73 | changelog:
74 | sort: asc
75 | filters:
76 | exclude:
77 | - '^docs:'
78 | - '^test:'
79 |
80 | dockers:
81 | - image_templates:
82 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64"
83 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64"
84 | use: buildx
85 | extra_files:
86 | - zoneinfo
87 | build_flag_templates:
88 | - "--platform=linux/amd64"
89 | - "--label=org.opencontainers.image.created={{.Date}}"
90 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
91 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
92 | - "--label=org.opencontainers.image.version={{.Version}}"
93 |
94 | - image_templates:
95 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64"
96 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64"
97 | use: buildx
98 | extra_files:
99 | - zoneinfo
100 | build_flag_templates:
101 | - "--platform=linux/arm64"
102 | - "--label=org.opencontainers.image.created={{.Date}}"
103 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
104 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
105 | - "--label=org.opencontainers.image.version={{.Version}}"
106 | goarch: arm64
107 |
108 | - image_templates:
109 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7"
110 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7"
111 | use: buildx
112 | extra_files:
113 | - zoneinfo
114 | build_flag_templates:
115 | - "--platform=linux/arm/v7"
116 | - "--label=org.opencontainers.image.created={{.Date}}"
117 | - "--label=org.opencontainers.image.title={{.ProjectName}}"
118 | - "--label=org.opencontainers.image.revision={{.FullCommit}}"
119 | - "--label=org.opencontainers.image.version={{.Version}}"
120 | goarch: arm
121 | goarm: 7
122 |
123 | docker_manifests:
124 | - name_template: "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}"
125 | image_templates:
126 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64"
127 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64"
128 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7"
129 |
130 | - name_template: "{{ .Env.DOCKER_USERNAME }}/ddns-go:latest"
131 | image_templates:
132 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64"
133 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64"
134 | - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7"
135 |
136 | - name_template: "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}"
137 | image_templates:
138 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64"
139 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64"
140 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7"
141 |
142 | - name_template: "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:latest"
143 | image_templates:
144 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64"
145 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64"
146 | - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7"
147 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Launch",
9 | "type": "go",
10 | "request": "launch",
11 | "mode": "auto",
12 | "program": "${workspaceFolder}/main.go",
13 | "env": {},
14 | "args": []
15 | }
16 | ]
17 | }
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine
2 | LABEL name=ddns-go
3 | LABEL url=https://github.com/jeessy2/ddns-go
4 | RUN apk add --no-cache curl grep
5 |
6 | WORKDIR /app
7 | COPY ddns-go /app/
8 | COPY zoneinfo /usr/share/zoneinfo
9 | ENV TZ=Asia/Shanghai
10 | EXPOSE 9876
11 | ENTRYPOINT ["/app/ddns-go"]
12 | CMD ["-l", ":9876", "-f", "300"]
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 jeessy
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: build clean test test-race
2 |
3 | # 如果找不到 tag 则使用 HEAD commit
4 | VERSION=$(shell git describe --tags `git rev-list --tags --max-count=1` 2>/dev/null || git rev-parse --short HEAD)
5 | BUILD_TIME=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
6 | BIN=ddns-go
7 | DIR_SRC=.
8 | DOCKER_ENV=DOCKER_BUILDKIT=1
9 | DOCKER=$(DOCKER_ENV) docker
10 |
11 | GO_ENV=CGO_ENABLED=0
12 | GO_FLAGS=-ldflags="-X main.version=$(VERSION) -X 'main.buildTime=$(BUILD_TIME)' -extldflags -static -s -w" -trimpath
13 | GO=$(GO_ENV) $(shell which go)
14 | GOROOT=$(shell `which go` env GOROOT)
15 | GOPATH=$(shell `which go` env GOPATH)
16 |
17 | build: $(DIR_SRC)/main.go
18 | @$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)
19 |
20 | build_docker_image:
21 | @$(DOCKER) build -f ./Dockerfile -t ddns-go:$(VERSION) .
22 |
23 | test:
24 | @$(GO) test ./...
25 |
26 | test-race:
27 | @$(GO) test -race ./...
28 |
29 | # clean all build result
30 | clean:
31 | @$(GO) clean ./...
32 | @rm -f $(BIN)
33 | @rm -rf ./dist/*
--------------------------------------------------------------------------------
/config/domains.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "net/url"
5 | "strings"
6 |
7 | "github.com/jeessy2/ddns-go/v6/util"
8 | "golang.org/x/net/idna"
9 | "golang.org/x/net/publicsuffix"
10 | )
11 |
12 | // Domains Ipv4/Ipv6 domains
13 | type Domains struct {
14 | Ipv4Addr string
15 | Ipv4Cache *util.IpCache
16 | Ipv4Domains []*Domain
17 | Ipv6Addr string
18 | Ipv6Cache *util.IpCache
19 | Ipv6Domains []*Domain
20 | }
21 |
22 | // Domain 域名实体
23 | type Domain struct {
24 | // DomainName 根域名
25 | DomainName string
26 | // SubDomain 子域名
27 | SubDomain string
28 | CustomParams string
29 | UpdateStatus updateStatusType // 更新状态
30 | }
31 |
32 | // nontransitionalLookup implements the nontransitional processing as specified in
33 | // Unicode Technical Standard 46 with almost all checkings off to maximize user freedom.
34 | //
35 | // Copied from: https://github.com/cloudflare/cloudflare-go/blob/v0.97.0/dns.go#L95
36 | var nontransitionalLookup = idna.New(
37 | idna.MapForLookup(),
38 | idna.StrictDomainName(false),
39 | idna.ValidateLabels(false),
40 | )
41 |
42 | func (d Domain) String() string {
43 | if d.SubDomain != "" {
44 | return d.SubDomain + "." + d.DomainName
45 | }
46 | return d.DomainName
47 | }
48 |
49 | // GetFullDomain 获得全部的,子域名
50 | func (d Domain) GetFullDomain() string {
51 | if d.SubDomain != "" {
52 | return d.SubDomain + "." + d.DomainName
53 | }
54 | return "@" + "." + d.DomainName
55 | }
56 |
57 | // GetSubDomain 获得子域名,为空返回@
58 | // 阿里云/腾讯云/dnspod/GoDaddy/namecheap 需要
59 | func (d Domain) GetSubDomain() string {
60 | if d.SubDomain != "" {
61 | return d.SubDomain
62 | }
63 | return "@"
64 | }
65 |
66 | // GetCustomParams not be nil
67 | func (d Domain) GetCustomParams() url.Values {
68 | if d.CustomParams != "" {
69 | q, err := url.ParseQuery(d.CustomParams)
70 | if err == nil {
71 | return q
72 | }
73 | }
74 | return url.Values{}
75 | }
76 |
77 | // ToASCII converts [Domain] to its ASCII form,
78 | // using non-transitional process specified in UTS 46.
79 | //
80 | // Note: conversion errors are silently discarded and partial conversion
81 | // results are used.
82 | func (d Domain) ToASCII() string {
83 | name, _ := nontransitionalLookup.ToASCII(d.String())
84 | return name
85 | }
86 |
87 | // GetNewIp 接口/网卡/命令获得 ip 并校验用户输入的域名
88 | func (domains *Domains) GetNewIp(dnsConf *DnsConfig) {
89 | domains.Ipv4Domains = checkParseDomains(dnsConf.Ipv4.Domains)
90 | domains.Ipv6Domains = checkParseDomains(dnsConf.Ipv6.Domains)
91 |
92 | // IPv4
93 | if dnsConf.Ipv4.Enable && len(domains.Ipv4Domains) > 0 {
94 | ipv4Addr := dnsConf.GetIpv4Addr()
95 | if ipv4Addr != "" {
96 | domains.Ipv4Addr = ipv4Addr
97 | domains.Ipv4Cache.TimesFailedIP = 0
98 | } else {
99 | // 启用IPv4 & 未获取到IP & 填写了域名 & 失败刚好3次,防止偶尔的网络连接失败,并且只发一次
100 | domains.Ipv4Cache.TimesFailedIP++
101 | if domains.Ipv4Cache.TimesFailedIP == 3 {
102 | domains.Ipv4Domains[0].UpdateStatus = UpdatedFailed
103 | }
104 | util.Log("未能获取IPv4地址, 将不会更新")
105 | }
106 | }
107 |
108 | // IPv6
109 | if dnsConf.Ipv6.Enable && len(domains.Ipv6Domains) > 0 {
110 | ipv6Addr := dnsConf.GetIpv6Addr()
111 | if ipv6Addr != "" {
112 | domains.Ipv6Addr = ipv6Addr
113 | domains.Ipv6Cache.TimesFailedIP = 0
114 | } else {
115 | // 启用IPv6 & 未获取到IP & 填写了域名 & 失败刚好3次,防止偶尔的网络连接失败,并且只发一次
116 | domains.Ipv6Cache.TimesFailedIP++
117 | if domains.Ipv6Cache.TimesFailedIP == 3 {
118 | domains.Ipv6Domains[0].UpdateStatus = UpdatedFailed
119 | }
120 | util.Log("未能获取IPv6地址, 将不会更新")
121 | }
122 | }
123 |
124 | }
125 |
126 | // checkParseDomains 校验并解析用户输入的域名
127 | func checkParseDomains(domainArr []string) (domains []*Domain) {
128 | for _, domainStr := range domainArr {
129 | domainStr = strings.TrimSpace(domainStr)
130 | if domainStr == "" {
131 | continue
132 | }
133 |
134 | domain := &Domain{}
135 |
136 | // qp(queryParts) 从域名中提取自定义参数,如 baidu.com?q=1 => [baidu.com, q=1]
137 | qp := strings.Split(domainStr, "?")
138 | domainStr = qp[0]
139 |
140 | // dp(domainParts) 将域名(qp[0])分割为子域名与根域名,如 www:example.cn.eu.org => [www, example.cn.eu.org]
141 | dp := strings.Split(domainStr, ":")
142 |
143 | switch len(dp) {
144 | case 1: // 不使用冒号分割,自动识别域名
145 | domainName, err := publicsuffix.EffectiveTLDPlusOne(domainStr)
146 | if err != nil {
147 | util.Log("域名: %s 不正确", domainStr)
148 | util.Log("异常信息: %s", err)
149 | continue
150 | }
151 | domain.DomainName = domainName
152 |
153 | domainLen := len(domainStr) - len(domainName) - 1
154 | if domainLen > 0 {
155 | domain.SubDomain = domainStr[:domainLen]
156 | }
157 | case 2: // 使用冒号分隔,为 子域名:根域名 格式
158 | sp := strings.Split(dp[1], ".")
159 | if len(sp) <= 1 {
160 | util.Log("域名: %s 不正确", domainStr)
161 | continue
162 | }
163 | domain.DomainName = dp[1]
164 | domain.SubDomain = dp[0]
165 | default:
166 | util.Log("域名: %s 不正确", domainStr)
167 | continue
168 | }
169 |
170 | // 参数条件
171 | if len(qp) == 2 {
172 | u, err := url.Parse("https://baidu.com?" + qp[1])
173 | if err != nil {
174 | util.Log("域名: %s 解析失败", domainStr)
175 | continue
176 | }
177 | domain.CustomParams = u.Query().Encode()
178 | }
179 | domains = append(domains, domain)
180 | }
181 | return
182 | }
183 |
184 | // GetNewIpResult 获得GetNewIp结果
185 | func (domains *Domains) GetNewIpResult(recordType string) (ipAddr string, retDomains []*Domain) {
186 | if recordType == "AAAA" {
187 | if domains.Ipv6Cache.Check(domains.Ipv6Addr) {
188 | return domains.Ipv6Addr, domains.Ipv6Domains
189 | } else {
190 | util.Log("IPv6未改变, 将等待 %d 次后与DNS服务商进行比对", domains.Ipv6Cache.Times)
191 | return "", domains.Ipv6Domains
192 | }
193 | }
194 | // IPv4
195 | if domains.Ipv4Cache.Check(domains.Ipv4Addr) {
196 | return domains.Ipv4Addr, domains.Ipv4Domains
197 | } else {
198 | util.Log("IPv4未改变, 将等待 %d 次后与DNS服务商进行比对", domains.Ipv4Cache.Times)
199 | return "", domains.Ipv4Domains
200 | }
201 | }
202 |
--------------------------------------------------------------------------------
/config/domains_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import "testing"
4 |
5 | // TestToASCII test converts the name of [Domain] to its ASCII form.
6 | //
7 | // Copied from: https://github.com/cloudflare/cloudflare-go/blob/v0.97.0/dns_test.go#L15
8 | func TestToASCII(t *testing.T) {
9 | tests := map[string]struct {
10 | domain string
11 | expected string
12 | }{
13 | "empty": {
14 | "", "",
15 | },
16 | "unicode get encoded": {
17 | "😺.com", "xn--138h.com",
18 | },
19 | "unicode gets mapped and encoded": {
20 | "ÖBB.at", "xn--bb-eka.at",
21 | },
22 | "punycode stays punycode": {
23 | "xn--138h.com", "xn--138h.com",
24 | },
25 | "hyphens are not checked": {
26 | "s3--s4.com", "s3--s4.com",
27 | },
28 | "STD3 rules are not enforced": {
29 | "℀.com", "a/c.com",
30 | },
31 | "bidi check is disabled": {
32 | "englishﻋﺮﺑﻲ.com", "xn--english-gqjzfwd1j.com",
33 | },
34 | "invalid joiners are allowed": {
35 | "a\u200cb.com", "xn--ab-j1t.com",
36 | },
37 | "partial results are used despite errors": {
38 | "xn--:D.xn--.😺.com", "xn--:d..xn--138h.com",
39 | },
40 | }
41 |
42 | for name, tt := range tests {
43 | t.Run(name, func(t *testing.T) {
44 | d := &Domain{DomainName: tt.domain}
45 | actual := d.ToASCII()
46 | if actual != tt.expected {
47 | t.Errorf("ToASCII() = %v, want %v", actual, tt.expected)
48 | }
49 | })
50 | }
51 | }
52 |
53 | // TestParseDomainArr 测试 parseDomainArr
54 | func TestParseDomainArr(t *testing.T) {
55 | domains := []string{"mydomain.com", "test.mydomain.com", "test2.test.mydomain.com", "mydomain.com.mydomain.com", "mydomain.com.cn",
56 | "test.mydomain.com.cn", "test:mydomain.com.cn",
57 | "test.mydomain.com?Line=oversea&RecordId=123", "test.mydomain.com.cn?Line=oversea&RecordId=123",
58 | "test2:test.mydomain.com?Line=oversea&RecordId=123"}
59 | result := []Domain{
60 | {DomainName: "mydomain.com", SubDomain: ""},
61 | {DomainName: "mydomain.com", SubDomain: "test"},
62 | {DomainName: "mydomain.com", SubDomain: "test2.test"},
63 | {DomainName: "mydomain.com", SubDomain: "mydomain.com"},
64 | {DomainName: "mydomain.com.cn", SubDomain: ""},
65 | {DomainName: "mydomain.com.cn", SubDomain: "test"},
66 | {DomainName: "mydomain.com.cn", SubDomain: "test"},
67 | {DomainName: "mydomain.com", SubDomain: "test", CustomParams: "Line=oversea&RecordId=123"},
68 | {DomainName: "mydomain.com.cn", SubDomain: "test", CustomParams: "Line=oversea&RecordId=123"},
69 | {DomainName: "test.mydomain.com", SubDomain: "test2", CustomParams: "Line=oversea&RecordId=123"},
70 | }
71 |
72 | parsedDomains := checkParseDomains(domains)
73 | for i := 0; i < len(parsedDomains); i++ {
74 | if parsedDomains[i].DomainName != result[i].DomainName ||
75 | parsedDomains[i].SubDomain != result[i].SubDomain ||
76 | parsedDomains[i].CustomParams != result[i].CustomParams {
77 | t.Errorf("解析 %s 失败:\n期待 DomainName:%s,得到 DomainName:%s\n期待 SubDomain:%s,得到 SubDomain:%s\n期待 CustomParams:%s,得到 CustomParams:%s",
78 | parsedDomains[i].String(),
79 | result[i].DomainName, parsedDomains[i].DomainName,
80 | result[i].SubDomain, parsedDomains[i].SubDomain,
81 | result[i].CustomParams, parsedDomains[i].CustomParams)
82 | }
83 | }
84 |
85 | }
86 |
--------------------------------------------------------------------------------
/config/netInterface.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "fmt"
5 | "net"
6 | )
7 |
8 | // NetInterface 本机网络
9 | type NetInterface struct {
10 | Name string
11 | Address []string
12 | }
13 |
14 | // GetNetInterface 获得网卡地址
15 | // 返回ipv4, ipv6地址
16 | func GetNetInterface() (ipv4NetInterfaces []NetInterface, ipv6NetInterfaces []NetInterface, err error) {
17 | allNetInterfaces, err := net.Interfaces()
18 | if err != nil {
19 | fmt.Println("net.Interfaces failed, err:", err.Error())
20 | return ipv4NetInterfaces, ipv6NetInterfaces, err
21 | }
22 |
23 | // https://en.wikipedia.org/wiki/IPv6_address#General_allocation
24 | _, ipv6Unicast, _ := net.ParseCIDR("2000::/3")
25 |
26 | for i := 0; i < len(allNetInterfaces); i++ {
27 | if (allNetInterfaces[i].Flags & net.FlagUp) != 0 {
28 | addrs, _ := allNetInterfaces[i].Addrs()
29 | ipv4 := []string{}
30 | ipv6 := []string{}
31 |
32 | for _, address := range addrs {
33 | if ipnet, ok := address.(*net.IPNet); ok && ipnet.IP.IsGlobalUnicast() {
34 | _, bits := ipnet.Mask.Size()
35 | // 需匹配全局单播地址
36 | if bits == 128 && ipv6Unicast.Contains(ipnet.IP) {
37 | ipv6 = append(ipv6, ipnet.IP.String())
38 | }
39 | if bits == 32 {
40 | ipv4 = append(ipv4, ipnet.IP.String())
41 | }
42 | }
43 | }
44 |
45 | if len(ipv4) > 0 {
46 | ipv4NetInterfaces = append(
47 | ipv4NetInterfaces,
48 | NetInterface{
49 | Name: allNetInterfaces[i].Name,
50 | Address: ipv4,
51 | },
52 | )
53 | }
54 |
55 | if len(ipv6) > 0 {
56 | ipv6NetInterfaces = append(
57 | ipv6NetInterfaces,
58 | NetInterface{
59 | Name: allNetInterfaces[i].Name,
60 | Address: ipv6,
61 | },
62 | )
63 | }
64 |
65 | }
66 | }
67 |
68 | return ipv4NetInterfaces, ipv6NetInterfaces, nil
69 | }
70 |
--------------------------------------------------------------------------------
/config/netInterface_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "testing"
5 | )
6 |
7 | func TestGetNetInterface(t *testing.T) {
8 | ipv4NetInterfaces, ipv6NetInterfaces, err := GetNetInterface()
9 | if err != nil {
10 | t.Error(err)
11 | }
12 | t.Log(ipv4NetInterfaces, ipv6NetInterfaces)
13 | }
14 |
--------------------------------------------------------------------------------
/config/user.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | // User 登录用户
4 | type User struct {
5 | Username string
6 | Password string
7 | }
8 |
--------------------------------------------------------------------------------
/config/webhook.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | "strings"
9 |
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | )
12 |
13 | // Webhook Webhook
14 | type Webhook struct {
15 | WebhookURL string
16 | WebhookRequestBody string
17 | WebhookHeaders string
18 | }
19 |
20 | // updateStatusType 更新状态
21 | type updateStatusType string
22 |
23 | const (
24 | // UpdatedNothing 未改变
25 | UpdatedNothing updateStatusType = "未改变"
26 | // UpdatedFailed 更新失败
27 | UpdatedFailed = "失败"
28 | // UpdatedSuccess 更新成功
29 | UpdatedSuccess = "成功"
30 | )
31 |
32 | // 更新失败次数
33 | var updatedFailedTimes = 0
34 |
35 | // hasJSONPrefix returns true if the string starts with a JSON open brace.
36 | func hasJSONPrefix(s string) bool {
37 | return strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[")
38 | }
39 |
40 | // ExecWebhook 添加或更新IPv4/IPv6记录, 返回是否有更新失败的
41 | func ExecWebhook(domains *Domains, conf *Config) (v4Status updateStatusType, v6Status updateStatusType) {
42 | v4Status = getDomainsStatus(domains.Ipv4Domains)
43 | v6Status = getDomainsStatus(domains.Ipv6Domains)
44 |
45 | if conf.WebhookURL != "" && (v4Status != UpdatedNothing || v6Status != UpdatedNothing) {
46 | // 第3次失败才触发一次webhook
47 | if v4Status == UpdatedFailed || v6Status == UpdatedFailed {
48 | updatedFailedTimes++
49 | if updatedFailedTimes != 3 {
50 | util.Log("将不会触发Webhook, 仅在第 3 次失败时触发一次Webhook, 当前失败次数:%d", updatedFailedTimes)
51 | return
52 | }
53 | } else {
54 | updatedFailedTimes = 0
55 | }
56 |
57 | // 成功和失败都要触发webhook
58 | method := "GET"
59 | postPara := ""
60 | contentType := "application/x-www-form-urlencoded"
61 | if conf.WebhookRequestBody != "" {
62 | method = "POST"
63 | postPara = replacePara(domains, conf.WebhookRequestBody, v4Status, v6Status)
64 | if json.Valid([]byte(postPara)) {
65 | contentType = "application/json"
66 | } else if hasJSONPrefix(postPara) {
67 | // 如果 RequestBody 的 JSON 无效但前缀为 JSON,提示无效
68 | util.Log("Webhook中的 RequestBody JSON 无效")
69 | }
70 | }
71 | requestURL := replacePara(domains, conf.WebhookURL, v4Status, v6Status)
72 | u, err := url.Parse(requestURL)
73 | if err != nil {
74 | util.Log("Webhook配置中的URL不正确")
75 | return
76 | }
77 | req, err := http.NewRequest(method, fmt.Sprintf("%s://%s%s?%s", u.Scheme, u.Host, u.EscapedPath(), u.Query().Encode()), strings.NewReader(postPara))
78 | if err != nil {
79 | util.Log("Webhook调用失败! 异常信息:%s", err)
80 | return
81 | }
82 |
83 | headers := extractHeaders(conf.WebhookHeaders)
84 | for key, value := range headers {
85 | req.Header.Add(key, value)
86 | }
87 | req.Header.Add("content-type", contentType)
88 |
89 | clt := util.CreateHTTPClient()
90 | resp, err := clt.Do(req)
91 | body, err := util.GetHTTPResponseOrg(resp, err)
92 | if err == nil {
93 | util.Log("Webhook调用成功! 返回数据:%s", string(body))
94 | } else {
95 | util.Log("Webhook调用失败! 异常信息:%s", err)
96 | }
97 | }
98 | return
99 | }
100 |
101 | // getDomainsStatus 获取域名状态
102 | func getDomainsStatus(domains []*Domain) updateStatusType {
103 | successNum := 0
104 | for _, v46 := range domains {
105 | switch v46.UpdateStatus {
106 | case UpdatedFailed:
107 | // 一个失败,全部失败
108 | return UpdatedFailed
109 | case UpdatedSuccess:
110 | successNum++
111 | }
112 | }
113 |
114 | if successNum > 0 {
115 | // 迭代完成后一个成功,就成功
116 | return UpdatedSuccess
117 | }
118 | return UpdatedNothing
119 | }
120 |
121 | // replacePara 替换参数
122 | func replacePara(domains *Domains, orgPara string, ipv4Result updateStatusType, ipv6Result updateStatusType) string {
123 | return strings.NewReplacer(
124 | "#{ipv4Addr}", domains.Ipv4Addr,
125 | "#{ipv4Result}", util.LogStr(string(ipv4Result)), // i18n
126 | "#{ipv4Domains}", getDomainsStr(domains.Ipv4Domains),
127 | "#{ipv6Addr}", domains.Ipv6Addr,
128 | "#{ipv6Result}", util.LogStr(string(ipv6Result)), // i18n
129 | "#{ipv6Domains}", getDomainsStr(domains.Ipv6Domains),
130 | ).Replace(orgPara)
131 | }
132 |
133 | // getDomainsStr 用逗号分割域名
134 | func getDomainsStr(domains []*Domain) string {
135 | str := ""
136 | for i, v46 := range domains {
137 | str += v46.String()
138 | if i != len(domains)-1 {
139 | str += ","
140 | }
141 | }
142 |
143 | return str
144 | }
145 |
146 | // extractHeaders converts s into a map of headers.
147 | //
148 | // See also: https://github.com/appleboy/gorush/blob/v1.17.0/notify/feedback.go#L15
149 | func extractHeaders(s string) map[string]string {
150 | lines := util.SplitLines(s)
151 | headers := make(map[string]string, len(lines))
152 |
153 | for _, line := range lines {
154 | line = strings.TrimSpace(line)
155 | if line == "" {
156 | continue
157 | }
158 |
159 | parts := strings.Split(line, ":")
160 | if len(parts) != 2 {
161 | util.Log("Webhook Header不正确: %s", line)
162 | continue
163 | }
164 |
165 | k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])
166 | headers[k] = v
167 | }
168 |
169 | return headers
170 | }
171 |
--------------------------------------------------------------------------------
/config/webhook_test.go:
--------------------------------------------------------------------------------
1 | package config
2 |
3 | import (
4 | "reflect"
5 | "testing"
6 | )
7 |
8 | // TestExtractHeaders 测试 parseHeaderArr
9 | func TestExtractHeaders(t *testing.T) {
10 | input := `
11 | a: foo
12 | b: bar`
13 | expected := map[string]string{
14 | "a": "foo",
15 | "b": "bar",
16 | }
17 |
18 | parsedHeaders := extractHeaders(input)
19 | if !reflect.DeepEqual(parsedHeaders, expected) {
20 | t.Errorf("Expected %v, got %v", expected, parsedHeaders)
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/ddns-web.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeessy2/ddns-go/3756c607c9ed9657270c2ab26f232dfd00cf9503/ddns-web.png
--------------------------------------------------------------------------------
/dns/alidns.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "net/http"
6 | "net/url"
7 |
8 | "github.com/jeessy2/ddns-go/v6/config"
9 | "github.com/jeessy2/ddns-go/v6/util"
10 | )
11 |
12 | const (
13 | alidnsEndpoint string = "https://alidns.aliyuncs.com/"
14 | )
15 |
16 | // https://help.aliyun.com/document_detail/29776.html?spm=a2c4g.11186623.6.672.715a45caji9dMA
17 | // Alidns Alidns
18 | type Alidns struct {
19 | DNS config.DNS
20 | Domains config.Domains
21 | TTL string
22 | }
23 |
24 | // AlidnsRecord record
25 | type AlidnsRecord struct {
26 | DomainName string
27 | RecordID string
28 | Value string
29 | }
30 |
31 | // AlidnsSubDomainRecords 记录
32 | type AlidnsSubDomainRecords struct {
33 | TotalCount int
34 | DomainRecords struct {
35 | Record []AlidnsRecord
36 | }
37 | }
38 |
39 | // AlidnsResp 修改/添加返回结果
40 | type AlidnsResp struct {
41 | RecordID string
42 | RequestID string
43 | }
44 |
45 | // Init 初始化
46 | func (ali *Alidns) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
47 | ali.Domains.Ipv4Cache = ipv4cache
48 | ali.Domains.Ipv6Cache = ipv6cache
49 | ali.DNS = dnsConf.DNS
50 | ali.Domains.GetNewIp(dnsConf)
51 | if dnsConf.TTL == "" {
52 | // 默认600s
53 | ali.TTL = "600"
54 | } else {
55 | ali.TTL = dnsConf.TTL
56 | }
57 | }
58 |
59 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
60 | func (ali *Alidns) AddUpdateDomainRecords() config.Domains {
61 | ali.addUpdateDomainRecords("A")
62 | ali.addUpdateDomainRecords("AAAA")
63 | return ali.Domains
64 | }
65 |
66 | func (ali *Alidns) addUpdateDomainRecords(recordType string) {
67 | ipAddr, domains := ali.Domains.GetNewIpResult(recordType)
68 |
69 | if ipAddr == "" {
70 | return
71 | }
72 |
73 | for _, domain := range domains {
74 | var records AlidnsSubDomainRecords
75 | // 获取当前域名信息
76 | params := domain.GetCustomParams()
77 | params.Set("Action", "DescribeSubDomainRecords")
78 | params.Set("DomainName", domain.DomainName)
79 | params.Set("SubDomain", domain.GetFullDomain())
80 | params.Set("Type", recordType)
81 | err := ali.request(params, &records)
82 |
83 | if err != nil {
84 | util.Log("查询域名信息发生异常! %s", err)
85 | domain.UpdateStatus = config.UpdatedFailed
86 | return
87 | }
88 |
89 | if records.TotalCount > 0 {
90 | // 默认第一个
91 | recordSelected := records.DomainRecords.Record[0]
92 | if params.Has("RecordId") {
93 | for i := 0; i < len(records.DomainRecords.Record); i++ {
94 | if records.DomainRecords.Record[i].RecordID == params.Get("RecordId") {
95 | recordSelected = records.DomainRecords.Record[i]
96 | }
97 | }
98 | }
99 | // 存在,更新
100 | ali.modify(recordSelected, domain, recordType, ipAddr)
101 | } else {
102 | // 不存在,创建
103 | ali.create(domain, recordType, ipAddr)
104 | }
105 |
106 | }
107 | }
108 |
109 | // 创建
110 | func (ali *Alidns) create(domain *config.Domain, recordType string, ipAddr string) {
111 | params := domain.GetCustomParams()
112 | params.Set("Action", "AddDomainRecord")
113 | params.Set("DomainName", domain.DomainName)
114 | params.Set("RR", domain.GetSubDomain())
115 | params.Set("Type", recordType)
116 | params.Set("Value", ipAddr)
117 | params.Set("TTL", ali.TTL)
118 |
119 | var result AlidnsResp
120 | err := ali.request(params, &result)
121 |
122 | if err != nil {
123 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err)
124 | domain.UpdateStatus = config.UpdatedFailed
125 | return
126 | }
127 |
128 | if result.RecordID != "" {
129 | util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr)
130 | domain.UpdateStatus = config.UpdatedSuccess
131 | } else {
132 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, "返回RecordId为空")
133 | domain.UpdateStatus = config.UpdatedFailed
134 | }
135 | }
136 |
137 | // 修改
138 | func (ali *Alidns) modify(recordSelected AlidnsRecord, domain *config.Domain, recordType string, ipAddr string) {
139 |
140 | // 相同不修改
141 | if recordSelected.Value == ipAddr {
142 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
143 | return
144 | }
145 |
146 | params := domain.GetCustomParams()
147 | params.Set("Action", "UpdateDomainRecord")
148 | params.Set("RR", domain.GetSubDomain())
149 | params.Set("RecordId", recordSelected.RecordID)
150 | params.Set("Type", recordType)
151 | params.Set("Value", ipAddr)
152 | params.Set("TTL", ali.TTL)
153 |
154 | var result AlidnsResp
155 | err := ali.request(params, &result)
156 |
157 | if err != nil {
158 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
159 | domain.UpdateStatus = config.UpdatedFailed
160 | return
161 | }
162 |
163 | if result.RecordID != "" {
164 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
165 | domain.UpdateStatus = config.UpdatedSuccess
166 | } else {
167 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, "返回RecordId为空")
168 | domain.UpdateStatus = config.UpdatedFailed
169 | }
170 | }
171 |
172 | // request 统一请求接口
173 | func (ali *Alidns) request(params url.Values, result interface{}) (err error) {
174 |
175 | util.AliyunSigner(ali.DNS.ID, ali.DNS.Secret, ¶ms)
176 |
177 | req, err := http.NewRequest(
178 | "GET",
179 | alidnsEndpoint,
180 | bytes.NewBuffer(nil),
181 | )
182 | req.URL.RawQuery = params.Encode()
183 |
184 | if err != nil {
185 | return
186 | }
187 |
188 | client := util.CreateHTTPClient()
189 | resp, err := client.Do(req)
190 | err = util.GetHTTPResponse(resp, err, result)
191 |
192 | return
193 | }
194 |
--------------------------------------------------------------------------------
/dns/baidu.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "net/http"
7 | "strconv"
8 |
9 | "github.com/jeessy2/ddns-go/v6/config"
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | )
12 |
13 | // https://cloud.baidu.com/doc/BCD/s/4jwvymhs7
14 |
15 | const (
16 | baiduEndpoint = "https://bcd.baidubce.com"
17 | )
18 |
19 | type BaiduCloud struct {
20 | DNS config.DNS
21 | Domains config.Domains
22 | TTL int
23 | }
24 |
25 | // BaiduRecord 单条解析记录
26 | type BaiduRecord struct {
27 | RecordId uint `json:"recordId"`
28 | Domain string `json:"domain"`
29 | View string `json:"view"`
30 | Rdtype string `json:"rdtype"`
31 | TTL int `json:"ttl"`
32 | Rdata string `json:"rdata"`
33 | ZoneName string `json:"zoneName"`
34 | Status string `json:"status"`
35 | }
36 |
37 | // BaiduRecordsResp 获取解析列表拿到的结果
38 | type BaiduRecordsResp struct {
39 | TotalCount int `json:"totalCount"`
40 | Result []BaiduRecord `json:"result"`
41 | }
42 |
43 | // BaiduListRequest 获取解析列表请求的body json
44 | type BaiduListRequest struct {
45 | Domain string `json:"domain"`
46 | PageNum int `json:"pageNum"`
47 | PageSize int `json:"pageSize"`
48 | }
49 |
50 | // BaiduModifyRequest 修改解析请求的body json
51 | type BaiduModifyRequest struct {
52 | RecordId uint `json:"recordId"`
53 | Domain string `json:"domain"`
54 | View string `json:"view"`
55 | RdType string `json:"rdType"`
56 | TTL int `json:"ttl"`
57 | Rdata string `json:"rdata"`
58 | ZoneName string `json:"zoneName"`
59 | }
60 |
61 | // BaiduCreateRequest 创建新解析请求的body json
62 | type BaiduCreateRequest struct {
63 | Domain string `json:"domain"`
64 | RdType string `json:"rdType"`
65 | TTL int `json:"ttl"`
66 | Rdata string `json:"rdata"`
67 | ZoneName string `json:"zoneName"`
68 | }
69 |
70 | func (baidu *BaiduCloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
71 | baidu.Domains.Ipv4Cache = ipv4cache
72 | baidu.Domains.Ipv6Cache = ipv6cache
73 | baidu.DNS = dnsConf.DNS
74 | baidu.Domains.GetNewIp(dnsConf)
75 | if dnsConf.TTL == "" {
76 | // 默认300s
77 | baidu.TTL = 300
78 | } else {
79 | ttl, err := strconv.Atoi(dnsConf.TTL)
80 | if err != nil {
81 | baidu.TTL = 300
82 | } else {
83 | baidu.TTL = ttl
84 | }
85 | }
86 | }
87 |
88 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
89 | func (baidu *BaiduCloud) AddUpdateDomainRecords() config.Domains {
90 | baidu.addUpdateDomainRecords("A")
91 | baidu.addUpdateDomainRecords("AAAA")
92 | return baidu.Domains
93 | }
94 |
95 | func (baidu *BaiduCloud) addUpdateDomainRecords(recordType string) {
96 | ipAddr, domains := baidu.Domains.GetNewIpResult(recordType)
97 | if ipAddr == "" {
98 | return
99 | }
100 |
101 | for _, domain := range domains {
102 | var records BaiduRecordsResp
103 |
104 | requestBody := BaiduListRequest{
105 | Domain: domain.DomainName,
106 | PageNum: 1,
107 | PageSize: 1000,
108 | }
109 |
110 | err := baidu.request("POST", baiduEndpoint+"/v1/domain/resolve/list", requestBody, &records)
111 | if err != nil {
112 | util.Log("查询域名信息发生异常! %s", err)
113 | domain.UpdateStatus = config.UpdatedFailed
114 | return
115 | }
116 |
117 | find := false
118 | for _, record := range records.Result {
119 | if record.Domain == domain.GetSubDomain() {
120 | //存在就去更新
121 | baidu.modify(record, domain, recordType, ipAddr)
122 | find = true
123 | break
124 | }
125 | }
126 | if !find {
127 | //没找到,去创建
128 | baidu.create(domain, recordType, ipAddr)
129 | }
130 | }
131 | }
132 |
133 | // create 创建新的解析
134 | func (baidu *BaiduCloud) create(domain *config.Domain, recordType string, ipAddr string) {
135 | var baiduCreateRequest = BaiduCreateRequest{
136 | Domain: domain.GetSubDomain(), //处理一下@
137 | RdType: recordType,
138 | TTL: baidu.TTL,
139 | Rdata: ipAddr,
140 | ZoneName: domain.DomainName,
141 | }
142 | var result BaiduRecordsResp
143 |
144 | err := baidu.request("POST", baiduEndpoint+"/v1/domain/resolve/add", baiduCreateRequest, &result)
145 | if err == nil {
146 | util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr)
147 | domain.UpdateStatus = config.UpdatedSuccess
148 | } else {
149 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err)
150 | domain.UpdateStatus = config.UpdatedFailed
151 | }
152 | }
153 |
154 | // modify 更新解析
155 | func (baidu *BaiduCloud) modify(record BaiduRecord, domain *config.Domain, rdType string, ipAddr string) {
156 | //没有变化直接跳过
157 | if record.Rdata == ipAddr {
158 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
159 | return
160 | }
161 | var baiduModifyRequest = BaiduModifyRequest{
162 | RecordId: record.RecordId,
163 | Domain: record.Domain,
164 | View: record.View,
165 | RdType: rdType,
166 | TTL: record.TTL,
167 | Rdata: ipAddr,
168 | ZoneName: record.ZoneName,
169 | }
170 | var result BaiduRecordsResp
171 |
172 | err := baidu.request("POST", baiduEndpoint+"/v1/domain/resolve/edit", baiduModifyRequest, &result)
173 | if err == nil {
174 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
175 | domain.UpdateStatus = config.UpdatedSuccess
176 | } else {
177 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
178 | domain.UpdateStatus = config.UpdatedFailed
179 | }
180 | }
181 |
182 | // request 统一请求接口
183 | func (baidu *BaiduCloud) request(method string, url string, data interface{}, result interface{}) (err error) {
184 | jsonStr := make([]byte, 0)
185 | if data != nil {
186 | jsonStr, _ = json.Marshal(data)
187 | }
188 |
189 | req, err := http.NewRequest(
190 | method,
191 | url,
192 | bytes.NewBuffer(jsonStr),
193 | )
194 |
195 | if err != nil {
196 | return
197 | }
198 |
199 | util.BaiduSigner(baidu.DNS.ID, baidu.DNS.Secret, req)
200 |
201 | client := util.CreateHTTPClient()
202 | resp, err := client.Do(req)
203 | err = util.GetHTTPResponse(resp, err, result)
204 |
205 | return
206 | }
207 |
--------------------------------------------------------------------------------
/dns/callback.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | "strings"
9 |
10 | "github.com/jeessy2/ddns-go/v6/config"
11 | "github.com/jeessy2/ddns-go/v6/util"
12 | )
13 |
14 | type Callback struct {
15 | DNS config.DNS
16 | Domains config.Domains
17 | TTL string
18 | lastIpv4 string
19 | lastIpv6 string
20 | }
21 |
22 | // Init 初始化
23 | func (cb *Callback) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
24 | cb.Domains.Ipv4Cache = ipv4cache
25 | cb.Domains.Ipv6Cache = ipv6cache
26 | cb.lastIpv4 = ipv4cache.Addr
27 | cb.lastIpv6 = ipv6cache.Addr
28 |
29 | cb.DNS = dnsConf.DNS
30 | cb.Domains.GetNewIp(dnsConf)
31 | if dnsConf.TTL == "" {
32 | // 默认600
33 | cb.TTL = "600"
34 | } else {
35 | cb.TTL = dnsConf.TTL
36 | }
37 | }
38 |
39 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
40 | func (cb *Callback) AddUpdateDomainRecords() config.Domains {
41 | cb.addUpdateDomainRecords("A")
42 | cb.addUpdateDomainRecords("AAAA")
43 | return cb.Domains
44 | }
45 |
46 | func (cb *Callback) addUpdateDomainRecords(recordType string) {
47 | ipAddr, domains := cb.Domains.GetNewIpResult(recordType)
48 |
49 | if ipAddr == "" {
50 | return
51 | }
52 |
53 | // 防止多次发送Webhook通知
54 | if recordType == "A" {
55 | if cb.lastIpv4 == ipAddr {
56 | util.Log("你的IPv4未变化, 未触发 %s 请求", "Callback")
57 | return
58 | }
59 | } else {
60 | if cb.lastIpv6 == ipAddr {
61 | util.Log("你的IPv6未变化, 未触发 %s 请求", "Callback")
62 | return
63 | }
64 | }
65 |
66 | for _, domain := range domains {
67 | method := "GET"
68 | postPara := ""
69 | contentType := "application/x-www-form-urlencoded"
70 | if cb.DNS.Secret != "" {
71 | method = "POST"
72 | postPara = replacePara(cb.DNS.Secret, ipAddr, domain, recordType, cb.TTL)
73 | if json.Valid([]byte(postPara)) {
74 | contentType = "application/json"
75 | }
76 | }
77 | requestURL := replacePara(cb.DNS.ID, ipAddr, domain, recordType, cb.TTL)
78 | u, err := url.Parse(requestURL)
79 | if err != nil {
80 | util.Log("Callback的URL不正确")
81 | return
82 | }
83 | req, err := http.NewRequest(method, u.String(), strings.NewReader(postPara))
84 | if err != nil {
85 | util.Log("异常信息: %s", err)
86 | domain.UpdateStatus = config.UpdatedFailed
87 | return
88 | }
89 | req.Header.Add("content-type", contentType)
90 |
91 | clt := util.CreateHTTPClient()
92 | resp, err := clt.Do(req)
93 | body, err := util.GetHTTPResponseOrg(resp, err)
94 | if err == nil {
95 | util.Log("Callback调用成功, 域名: %s, IP: %s, 返回数据: %s", domain, ipAddr, string(body))
96 | domain.UpdateStatus = config.UpdatedSuccess
97 | } else {
98 | util.Log("Callback调用失败, 异常信息: %s", err)
99 | domain.UpdateStatus = config.UpdatedFailed
100 | }
101 | }
102 | }
103 |
104 | // replacePara 替换参数
105 | func replacePara(orgPara, ipAddr string, domain *config.Domain, recordType string, ttl string) string {
106 | // params 使用 map 以便添加更多参数
107 | params := map[string]string{
108 | "ip": ipAddr,
109 | "domain": domain.String(),
110 | "recordType": recordType,
111 | "ttl": ttl,
112 | }
113 |
114 | // 也替换域名的自定义参数
115 | for k, v := range domain.GetCustomParams() {
116 | if len(v) == 1 {
117 | params[k] = v[0]
118 | }
119 | }
120 |
121 | // 将 map 转换为 [NewReplacer] 所需的参数
122 | // map 中的每个元素占用 2 个位置(kv),因此需要预留 2 倍的空间
123 | oldnew := make([]string, 0, len(params)*2)
124 | for k, v := range params {
125 | k = fmt.Sprintf("#{%s}", k)
126 | oldnew = append(oldnew, k, v)
127 | }
128 |
129 | return strings.NewReplacer(oldnew...).Replace(orgPara)
130 | }
131 |
--------------------------------------------------------------------------------
/dns/cloudflare.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "net/url"
9 | "strconv"
10 | "strings"
11 |
12 | "github.com/jeessy2/ddns-go/v6/config"
13 | "github.com/jeessy2/ddns-go/v6/util"
14 | )
15 |
16 | const zonesAPI = "https://api.cloudflare.com/client/v4/zones"
17 |
18 | // Cloudflare Cloudflare实现
19 | type Cloudflare struct {
20 | DNS config.DNS
21 | Domains config.Domains
22 | TTL int
23 | }
24 |
25 | // CloudflareZonesResp cloudflare zones返回结果
26 | type CloudflareZonesResp struct {
27 | CloudflareStatus
28 | Result []struct {
29 | ID string
30 | Name string
31 | Status string
32 | Paused bool
33 | }
34 | }
35 |
36 | // CloudflareRecordsResp records
37 | type CloudflareRecordsResp struct {
38 | CloudflareStatus
39 | Result []CloudflareRecord
40 | }
41 |
42 | // CloudflareRecord 记录实体
43 | type CloudflareRecord struct {
44 | ID string `json:"id"`
45 | Name string `json:"name"`
46 | Type string `json:"type"`
47 | Content string `json:"content"`
48 | Proxied bool `json:"proxied"`
49 | TTL int `json:"ttl"`
50 | Comment string `json:"comment"`
51 | }
52 |
53 | // CloudflareStatus 公共状态
54 | type CloudflareStatus struct {
55 | Success bool
56 | Messages []string
57 | }
58 |
59 | // Init 初始化
60 | func (cf *Cloudflare) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
61 | cf.Domains.Ipv4Cache = ipv4cache
62 | cf.Domains.Ipv6Cache = ipv6cache
63 | cf.DNS = dnsConf.DNS
64 | cf.Domains.GetNewIp(dnsConf)
65 | if dnsConf.TTL == "" {
66 | // 默认1 auto ttl
67 | cf.TTL = 1
68 | } else {
69 | ttl, err := strconv.Atoi(dnsConf.TTL)
70 | if err != nil {
71 | cf.TTL = 1
72 | } else {
73 | cf.TTL = ttl
74 | }
75 | }
76 | }
77 |
78 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
79 | func (cf *Cloudflare) AddUpdateDomainRecords() config.Domains {
80 | cf.addUpdateDomainRecords("A")
81 | cf.addUpdateDomainRecords("AAAA")
82 | return cf.Domains
83 | }
84 |
85 | func (cf *Cloudflare) addUpdateDomainRecords(recordType string) {
86 | ipAddr, domains := cf.Domains.GetNewIpResult(recordType)
87 |
88 | if ipAddr == "" {
89 | return
90 | }
91 |
92 | for _, domain := range domains {
93 | // get zone
94 | result, err := cf.getZones(domain)
95 |
96 | if err != nil {
97 | util.Log("查询域名信息发生异常! %s", err)
98 | domain.UpdateStatus = config.UpdatedFailed
99 | return
100 | }
101 |
102 | if len(result.Result) == 0 {
103 | util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName)
104 | domain.UpdateStatus = config.UpdatedFailed
105 | return
106 | }
107 |
108 | params := url.Values{}
109 | params.Set("type", recordType)
110 | // The name of DNS records in Cloudflare API expects Punycode.
111 | //
112 | // See: cloudflare/cloudflare-go#690
113 | params.Set("name", domain.ToASCII())
114 | params.Set("per_page", "50")
115 | // Add a comment only if it exists
116 | if c := domain.GetCustomParams().Get("comment"); c != "" {
117 | params.Set("comment", c)
118 | }
119 |
120 | zoneID := result.Result[0].ID
121 |
122 | var records CloudflareRecordsResp
123 | // getDomains 最多更新前50条
124 | err = cf.request(
125 | "GET",
126 | fmt.Sprintf(zonesAPI+"/%s/dns_records?%s", zoneID, params.Encode()),
127 | nil,
128 | &records,
129 | )
130 |
131 | if err != nil {
132 | util.Log("查询域名信息发生异常! %s", err)
133 | domain.UpdateStatus = config.UpdatedFailed
134 | return
135 | }
136 |
137 | if !records.Success {
138 | util.Log("查询域名信息发生异常! %s", strings.Join(records.Messages, ", "))
139 | domain.UpdateStatus = config.UpdatedFailed
140 | return
141 | }
142 |
143 | if len(records.Result) > 0 {
144 | // 更新
145 | cf.modify(records, zoneID, domain, ipAddr)
146 | } else {
147 | // 新增
148 | cf.create(zoneID, domain, recordType, ipAddr)
149 | }
150 | }
151 | }
152 |
153 | // 创建
154 | func (cf *Cloudflare) create(zoneID string, domain *config.Domain, recordType string, ipAddr string) {
155 | record := &CloudflareRecord{
156 | Type: recordType,
157 | Name: domain.ToASCII(),
158 | Content: ipAddr,
159 | Proxied: false,
160 | TTL: cf.TTL,
161 | Comment: domain.GetCustomParams().Get("comment"),
162 | }
163 | record.Proxied = domain.GetCustomParams().Get("proxied") == "true"
164 | var status CloudflareStatus
165 | err := cf.request(
166 | "POST",
167 | fmt.Sprintf(zonesAPI+"/%s/dns_records", zoneID),
168 | record,
169 | &status,
170 | )
171 |
172 | if err != nil {
173 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err)
174 | domain.UpdateStatus = config.UpdatedFailed
175 | return
176 | }
177 |
178 | if status.Success {
179 | util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr)
180 | domain.UpdateStatus = config.UpdatedSuccess
181 | } else {
182 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, strings.Join(status.Messages, ", "))
183 | domain.UpdateStatus = config.UpdatedFailed
184 | }
185 | }
186 |
187 | // 修改
188 | func (cf *Cloudflare) modify(result CloudflareRecordsResp, zoneID string, domain *config.Domain, ipAddr string) {
189 | for _, record := range result.Result {
190 | // 相同不修改
191 | if record.Content == ipAddr {
192 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
193 | continue
194 | }
195 | var status CloudflareStatus
196 | record.Content = ipAddr
197 | record.TTL = cf.TTL
198 | // 存在参数才修改proxied
199 | if domain.GetCustomParams().Has("proxied") {
200 | record.Proxied = domain.GetCustomParams().Get("proxied") == "true"
201 | }
202 | err := cf.request(
203 | "PUT",
204 | fmt.Sprintf(zonesAPI+"/%s/dns_records/%s", zoneID, record.ID),
205 | record,
206 | &status,
207 | )
208 |
209 | if err != nil {
210 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
211 | domain.UpdateStatus = config.UpdatedFailed
212 | return
213 | }
214 |
215 | if status.Success {
216 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
217 | domain.UpdateStatus = config.UpdatedSuccess
218 | } else {
219 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, strings.Join(status.Messages, ", "))
220 | domain.UpdateStatus = config.UpdatedFailed
221 | }
222 | }
223 | }
224 |
225 | // 获得域名记录列表
226 | func (cf *Cloudflare) getZones(domain *config.Domain) (result CloudflareZonesResp, err error) {
227 | params := url.Values{}
228 | params.Set("name", domain.DomainName)
229 | params.Set("status", "active")
230 | params.Set("per_page", "50")
231 |
232 | err = cf.request(
233 | "GET",
234 | fmt.Sprintf(zonesAPI+"?%s", params.Encode()),
235 | nil,
236 | &result,
237 | )
238 |
239 | return
240 | }
241 |
242 | // request 统一请求接口
243 | func (cf *Cloudflare) request(method string, url string, data interface{}, result interface{}) (err error) {
244 | jsonStr := make([]byte, 0)
245 | if data != nil {
246 | jsonStr, _ = json.Marshal(data)
247 | }
248 | req, err := http.NewRequest(
249 | method,
250 | url,
251 | bytes.NewBuffer(jsonStr),
252 | )
253 | if err != nil {
254 | return
255 | }
256 | req.Header.Set("Authorization", "Bearer "+cf.DNS.Secret)
257 | req.Header.Set("Content-Type", "application/json")
258 |
259 | client := util.CreateHTTPClient()
260 | resp, err := client.Do(req)
261 | err = util.GetHTTPResponse(resp, err, result)
262 |
263 | return
264 | }
265 |
--------------------------------------------------------------------------------
/dns/dnspod.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "net/url"
5 |
6 | "github.com/jeessy2/ddns-go/v6/config"
7 | "github.com/jeessy2/ddns-go/v6/util"
8 | )
9 |
10 | const (
11 | recordListAPI string = "https://dnsapi.cn/Record.List"
12 | recordModifyURL string = "https://dnsapi.cn/Record.Modify"
13 | recordCreateAPI string = "https://dnsapi.cn/Record.Create"
14 | )
15 |
16 | // https://cloud.tencent.com/document/api/302/8516
17 | // Dnspod 腾讯云dns实现
18 | type Dnspod struct {
19 | DNS config.DNS
20 | Domains config.Domains
21 | TTL string
22 | }
23 |
24 | // DnspodRecord DnspodRecord
25 | type DnspodRecord struct {
26 | ID string
27 | Name string
28 | Type string
29 | Value string
30 | Enabled string
31 | }
32 |
33 | // DnspodRecordListResp recordListAPI结果
34 | type DnspodRecordListResp struct {
35 | DnspodStatus
36 | Records []DnspodRecord
37 | }
38 |
39 | // DnspodStatus DnspodStatus
40 | type DnspodStatus struct {
41 | Status struct {
42 | Code string
43 | Message string
44 | }
45 | }
46 |
47 | // Init 初始化
48 | func (dnspod *Dnspod) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
49 | dnspod.Domains.Ipv4Cache = ipv4cache
50 | dnspod.Domains.Ipv6Cache = ipv6cache
51 | dnspod.DNS = dnsConf.DNS
52 | dnspod.Domains.GetNewIp(dnsConf)
53 | if dnsConf.TTL == "" {
54 | // 默认600s
55 | dnspod.TTL = "600"
56 | } else {
57 | dnspod.TTL = dnsConf.TTL
58 | }
59 | }
60 |
61 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
62 | func (dnspod *Dnspod) AddUpdateDomainRecords() config.Domains {
63 | dnspod.addUpdateDomainRecords("A")
64 | dnspod.addUpdateDomainRecords("AAAA")
65 | return dnspod.Domains
66 | }
67 |
68 | func (dnspod *Dnspod) addUpdateDomainRecords(recordType string) {
69 | ipAddr, domains := dnspod.Domains.GetNewIpResult(recordType)
70 |
71 | if ipAddr == "" {
72 | return
73 | }
74 |
75 | for _, domain := range domains {
76 | result, err := dnspod.getRecordList(domain, recordType)
77 | if err != nil {
78 | util.Log("查询域名信息发生异常! %s", err)
79 | domain.UpdateStatus = config.UpdatedFailed
80 | return
81 | }
82 |
83 | if len(result.Records) > 0 {
84 | // 默认第一个
85 | recordSelected := result.Records[0]
86 | params := domain.GetCustomParams()
87 | if params.Has("record_id") {
88 | for i := 0; i < len(result.Records); i++ {
89 | if result.Records[i].ID == params.Get("record_id") {
90 | recordSelected = result.Records[i]
91 | }
92 | }
93 | }
94 | // 更新
95 | dnspod.modify(recordSelected, domain, recordType, ipAddr)
96 | } else {
97 | // 新增
98 | dnspod.create(domain, recordType, ipAddr)
99 | }
100 | }
101 | }
102 |
103 | // 创建
104 | func (dnspod *Dnspod) create(domain *config.Domain, recordType string, ipAddr string) {
105 | params := domain.GetCustomParams()
106 | params.Set("login_token", dnspod.DNS.ID+","+dnspod.DNS.Secret)
107 | params.Set("domain", domain.DomainName)
108 | params.Set("sub_domain", domain.GetSubDomain())
109 | params.Set("record_type", recordType)
110 | params.Set("value", ipAddr)
111 | params.Set("ttl", dnspod.TTL)
112 | params.Set("format", "json")
113 |
114 | if !params.Has("record_line") {
115 | params.Set("record_line", "默认")
116 | }
117 |
118 | status, err := dnspod.request(recordCreateAPI, params)
119 |
120 | if err != nil {
121 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err)
122 | domain.UpdateStatus = config.UpdatedFailed
123 | return
124 | }
125 |
126 | if status.Status.Code == "1" {
127 | util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr)
128 | domain.UpdateStatus = config.UpdatedSuccess
129 | } else {
130 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, status.Status.Message)
131 | domain.UpdateStatus = config.UpdatedFailed
132 | }
133 | }
134 |
135 | // 修改
136 | func (dnspod *Dnspod) modify(record DnspodRecord, domain *config.Domain, recordType string, ipAddr string) {
137 |
138 | // 相同不修改
139 | if record.Value == ipAddr {
140 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
141 | return
142 | }
143 |
144 | params := domain.GetCustomParams()
145 | params.Set("login_token", dnspod.DNS.ID+","+dnspod.DNS.Secret)
146 | params.Set("domain", domain.DomainName)
147 | params.Set("sub_domain", domain.GetSubDomain())
148 | params.Set("record_type", recordType)
149 | params.Set("value", ipAddr)
150 | params.Set("ttl", dnspod.TTL)
151 | params.Set("format", "json")
152 | params.Set("record_id", record.ID)
153 |
154 | if !params.Has("record_line") {
155 | params.Set("record_line", "默认")
156 | }
157 |
158 | status, err := dnspod.request(recordModifyURL, params)
159 |
160 | if err != nil {
161 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
162 | domain.UpdateStatus = config.UpdatedFailed
163 | return
164 | }
165 |
166 | if status.Status.Code == "1" {
167 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
168 | domain.UpdateStatus = config.UpdatedSuccess
169 | } else {
170 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, status.Status.Message)
171 | domain.UpdateStatus = config.UpdatedFailed
172 | }
173 | }
174 |
175 | // request sends a POST request to the given API with the given values.
176 | func (dnspod *Dnspod) request(apiAddr string, values url.Values) (status DnspodStatus, err error) {
177 | client := util.CreateHTTPClient()
178 | resp, err := client.PostForm(
179 | apiAddr,
180 | values,
181 | )
182 |
183 | err = util.GetHTTPResponse(resp, err, &status)
184 |
185 | return
186 | }
187 |
188 | // 获得域名记录列表
189 | func (dnspod *Dnspod) getRecordList(domain *config.Domain, typ string) (result DnspodRecordListResp, err error) {
190 |
191 | params := domain.GetCustomParams()
192 | params.Set("login_token", dnspod.DNS.ID+","+dnspod.DNS.Secret)
193 | params.Set("domain", domain.DomainName)
194 | params.Set("record_type", typ)
195 | params.Set("sub_domain", domain.GetSubDomain())
196 | params.Set("format", "json")
197 |
198 | client := util.CreateHTTPClient()
199 | resp, err := client.PostForm(
200 | recordListAPI,
201 | params,
202 | )
203 |
204 | err = util.GetHTTPResponse(resp, err, &result)
205 |
206 | return
207 | }
208 |
--------------------------------------------------------------------------------
/dns/dynadot.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "github.com/jeessy2/ddns-go/v6/config"
6 | "github.com/jeessy2/ddns-go/v6/util"
7 | "net/http"
8 | "net/url"
9 | "strconv"
10 | "strings"
11 | )
12 |
13 | // https://www.dynadot.com/set_ddns
14 | const (
15 | dynadotEndpoint string = "https://www.dynadot.com/set_ddns"
16 | )
17 |
18 | // Dynadot Dynadot
19 | type Dynadot struct {
20 | DNS config.DNS
21 | Domains config.Domains
22 | TTL string
23 | LastIpv4 string
24 | LastIpv6 string
25 | }
26 |
27 | // DynadotRecord record
28 | type DynadotRecord struct {
29 | DomainName string
30 | SubDomainNames []string
31 | CustomParams url.Values
32 | Domains []*config.Domain
33 | ContainRoot bool
34 | }
35 |
36 | // DynadotResp 修改/添加返回结果
37 | type DynadotResp struct {
38 | Status string `json:"status"`
39 | ErrorCode int `json:"error_code"`
40 | Content []string `json:"content"`
41 | }
42 |
43 | // Init 初始化
44 | func (dynadot *Dynadot) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
45 | dynadot.Domains.Ipv4Cache = ipv4cache
46 | dynadot.Domains.Ipv6Cache = ipv6cache
47 | dynadot.LastIpv4 = ipv4cache.Addr
48 | dynadot.LastIpv6 = ipv6cache.Addr
49 | dynadot.DNS = dnsConf.DNS
50 | dynadot.Domains.GetNewIp(dnsConf)
51 | if dnsConf.TTL == "" {
52 | // 默认600s
53 | dynadot.TTL = "600"
54 | } else {
55 | dynadot.TTL = dnsConf.TTL
56 | }
57 | }
58 |
59 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
60 | func (dynadot *Dynadot) AddUpdateDomainRecords() config.Domains {
61 | dynadot.addOrUpdateDomainRecords("A")
62 | dynadot.addOrUpdateDomainRecords("AAAA")
63 | return dynadot.Domains
64 | }
65 |
66 | // addOrUpdateDomainRecords 添加或更新记录
67 | func (dynadot *Dynadot) addOrUpdateDomainRecords(recordType string) {
68 | ipAddr, domains := dynadot.Domains.GetNewIpResult(recordType)
69 |
70 | if len(ipAddr) == 0 {
71 | return
72 | }
73 |
74 | // 防止多次发送Webhook通知
75 | if recordType == "A" {
76 | if dynadot.LastIpv4 == ipAddr {
77 | util.Log("你的IPv4未变化, 未触发 %s 请求", "dynadot")
78 | return
79 | }
80 | } else {
81 | if dynadot.LastIpv6 == ipAddr {
82 | util.Log("你的IPv6未变化, 未触发 %s 请求", "dynadot")
83 | return
84 | }
85 | }
86 |
87 | records := mergeDomains(domains)
88 | // dynadot 仅支持一个域名对应一个dynamic password
89 | if len(records) != 1 {
90 | util.Log("dynadot仅支持单域名配置,多个域名请添加更多配置")
91 | return
92 | }
93 | for _, record := range records {
94 | // 创建或更新
95 | dynadot.createOrModify(record, recordType, ipAddr)
96 | }
97 | }
98 |
99 | // 合并域名的子域名
100 | func mergeDomains(domains []*config.Domain) (records []*DynadotRecord) {
101 | records = make([]*DynadotRecord, 0)
102 | for _, domain := range domains {
103 | var record *DynadotRecord
104 | for _, r := range records {
105 | if r.DomainName == domain.DomainName {
106 | record = r
107 | params := domain.GetCustomParams()
108 | for key := range params {
109 | record.CustomParams.Add(key, params.Get(key))
110 | }
111 | record.Domains = append(record.Domains, domain)
112 | record.SubDomainNames = append(record.SubDomainNames, domain.GetSubDomain())
113 | break
114 | }
115 | }
116 | if record == nil {
117 | record = &DynadotRecord{
118 | DomainName: domain.DomainName,
119 | CustomParams: domain.GetCustomParams(),
120 | Domains: []*config.Domain{domain},
121 | SubDomainNames: []string{domain.GetSubDomain()},
122 | }
123 | records = append(records, record)
124 | }
125 | if len(domain.SubDomain) == 0 {
126 | // 包含根域名
127 | record.ContainRoot = true
128 | }
129 | }
130 | return records
131 | }
132 |
133 | // 创建或变更记录
134 | func (dynadot *Dynadot) createOrModify(record *DynadotRecord, recordType string, ipAddr string) {
135 | params := record.CustomParams
136 | params.Set("domain", record.DomainName)
137 | params.Set("subDomain", strings.Join(record.SubDomainNames, ","))
138 | params.Set("type", recordType)
139 | params.Set("ip", ipAddr)
140 | params.Set("pwd", dynadot.DNS.Secret)
141 | params.Set("ttl", dynadot.TTL)
142 | params.Set("containRoot", strconv.FormatBool(record.ContainRoot))
143 |
144 | var result DynadotResp
145 | err := dynadot.request(params, &result)
146 |
147 | domains := record.Domains
148 | for _, domain := range domains {
149 |
150 | if err != nil {
151 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
152 | domain.UpdateStatus = config.UpdatedFailed
153 | return
154 | }
155 |
156 | if result.ErrorCode != -1 {
157 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
158 | domain.UpdateStatus = config.UpdatedSuccess
159 | } else {
160 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, strings.Join(result.Content, ","))
161 | domain.UpdateStatus = config.UpdatedFailed
162 | }
163 | }
164 |
165 | }
166 |
167 | // request 统一请求接口
168 | func (dynadot *Dynadot) request(params url.Values, result interface{}) (err error) {
169 |
170 | req, err := http.NewRequest(
171 | "GET",
172 | dynadotEndpoint,
173 | bytes.NewBuffer(nil),
174 | )
175 | req.URL.RawQuery = params.Encode()
176 |
177 | if err != nil {
178 | return
179 | }
180 |
181 | client := util.CreateHTTPClient()
182 | resp, err := client.Do(req)
183 | err = util.GetHTTPResponse(resp, err, result)
184 |
185 | return
186 | }
187 |
--------------------------------------------------------------------------------
/dns/godaddy.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "strconv"
9 |
10 | "github.com/jeessy2/ddns-go/v6/config"
11 | "github.com/jeessy2/ddns-go/v6/util"
12 | )
13 |
14 | type godaddyRecord struct {
15 | Data string `json:"data"`
16 | Name string `json:"name"`
17 | TTL int `json:"ttl"`
18 | Type string `json:"type"`
19 | }
20 |
21 | type godaddyRecords []godaddyRecord
22 |
23 | type GoDaddyDNS struct {
24 | dns config.DNS
25 | domains config.Domains
26 | ttl int
27 | header http.Header
28 | client *http.Client
29 | lastIpv4 string
30 | lastIpv6 string
31 | }
32 |
33 | func (g *GoDaddyDNS) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
34 | g.domains.Ipv4Cache = ipv4cache
35 | g.domains.Ipv6Cache = ipv6cache
36 | g.lastIpv4 = ipv4cache.Addr
37 | g.lastIpv6 = ipv6cache.Addr
38 |
39 | g.dns = dnsConf.DNS
40 | g.domains.GetNewIp(dnsConf)
41 | g.ttl = 600
42 | if val, err := strconv.Atoi(dnsConf.TTL); err == nil {
43 | g.ttl = val
44 | }
45 | g.header = map[string][]string{
46 | "Authorization": {fmt.Sprintf("sso-key %s:%s", g.dns.ID, g.dns.Secret)},
47 | "Content-Type": {"application/json"},
48 | }
49 |
50 | g.client = util.CreateHTTPClient()
51 | }
52 |
53 | func (g *GoDaddyDNS) updateDomainRecord(recordType string, ipAddr string, domains []*config.Domain) {
54 | if ipAddr == "" {
55 | return
56 | }
57 |
58 | // 防止多次发送Webhook通知
59 | if recordType == "A" {
60 | if g.lastIpv4 == ipAddr {
61 | util.Log("你的IPv4未变化, 未触发 %s 请求", "godaddy")
62 | return
63 | }
64 | } else {
65 | if g.lastIpv6 == ipAddr {
66 | util.Log("你的IPv6未变化, 未触发 %s 请求", "godaddy")
67 | return
68 | }
69 | }
70 |
71 | for _, domain := range domains {
72 | err := g.sendReq(http.MethodPut, recordType, domain, &godaddyRecords{godaddyRecord{
73 | Data: ipAddr,
74 | Name: domain.GetSubDomain(),
75 | TTL: g.ttl,
76 | Type: recordType,
77 | }})
78 | if err == nil {
79 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
80 | domain.UpdateStatus = config.UpdatedSuccess
81 | } else {
82 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
83 | domain.UpdateStatus = config.UpdatedFailed
84 | }
85 | }
86 | }
87 |
88 | func (g *GoDaddyDNS) AddUpdateDomainRecords() config.Domains {
89 | if ipv4Addr, ipv4Domains := g.domains.GetNewIpResult("A"); ipv4Addr != "" {
90 | g.updateDomainRecord("A", ipv4Addr, ipv4Domains)
91 | }
92 | if ipv6Addr, ipv6Domains := g.domains.GetNewIpResult("AAAA"); ipv6Addr != "" {
93 | g.updateDomainRecord("AAAA", ipv6Addr, ipv6Domains)
94 | }
95 | return g.domains
96 | }
97 |
98 | func (g *GoDaddyDNS) sendReq(method string, rType string, domain *config.Domain, data *godaddyRecords) error {
99 |
100 | var body *bytes.Buffer
101 | if data != nil {
102 | if buffer, err := json.Marshal(data); err != nil {
103 | return err
104 | } else {
105 | body = bytes.NewBuffer(buffer)
106 | }
107 | }
108 | path := fmt.Sprintf("https://api.godaddy.com/v1/domains/%s/records/%s/%s",
109 | domain.DomainName, rType, domain.GetSubDomain())
110 |
111 | req, err := http.NewRequest(method, path, body)
112 | if err != nil {
113 | return err
114 | }
115 | req.Header = g.header
116 | resp, err := g.client.Do(req)
117 | _, err = util.GetHTTPResponseOrg(resp, err)
118 | return err
119 | }
120 |
--------------------------------------------------------------------------------
/dns/index.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "time"
5 |
6 | "github.com/jeessy2/ddns-go/v6/config"
7 | "github.com/jeessy2/ddns-go/v6/util"
8 | )
9 |
10 | // DNS interface
11 | type DNS interface {
12 | Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache)
13 | // 添加或更新IPv4/IPv6记录
14 | AddUpdateDomainRecords() (domains config.Domains)
15 | }
16 |
17 | var (
18 | Addresses = []string{
19 | alidnsEndpoint,
20 | baiduEndpoint,
21 | zonesAPI,
22 | recordListAPI,
23 | huaweicloudEndpoint,
24 | nameCheapEndpoint,
25 | nameSiloListRecordEndpoint,
26 | porkbunEndpoint,
27 | tencentCloudEndPoint,
28 | dynadotEndpoint,
29 | dynv6Endpoint,
30 | }
31 |
32 | Ipcache = [][2]util.IpCache{}
33 | )
34 |
35 | // RunTimer 定时运行
36 | func RunTimer(delay time.Duration) {
37 | for {
38 | RunOnce()
39 | time.Sleep(delay)
40 | }
41 | }
42 |
43 | // RunOnce RunOnce
44 | func RunOnce() {
45 | conf, err := config.GetConfigCached()
46 | if err != nil {
47 | return
48 | }
49 | if util.ForceCompareGlobal || len(Ipcache) != len(conf.DnsConf) {
50 | Ipcache = [][2]util.IpCache{}
51 | for range conf.DnsConf {
52 | Ipcache = append(Ipcache, [2]util.IpCache{{}, {}})
53 | }
54 | }
55 |
56 | for i, dc := range conf.DnsConf {
57 | var dnsSelected DNS
58 | switch dc.DNS.Name {
59 | case "alidns":
60 | dnsSelected = &Alidns{}
61 | case "tencentcloud":
62 | dnsSelected = &TencentCloud{}
63 | case "trafficroute":
64 | dnsSelected = &TrafficRoute{}
65 | case "dnspod":
66 | dnsSelected = &Dnspod{}
67 | case "cloudflare":
68 | dnsSelected = &Cloudflare{}
69 | case "huaweicloud":
70 | dnsSelected = &Huaweicloud{}
71 | case "callback":
72 | dnsSelected = &Callback{}
73 | case "baiducloud":
74 | dnsSelected = &BaiduCloud{}
75 | case "porkbun":
76 | dnsSelected = &Porkbun{}
77 | case "godaddy":
78 | dnsSelected = &GoDaddyDNS{}
79 | case "namecheap":
80 | dnsSelected = &NameCheap{}
81 | case "namesilo":
82 | dnsSelected = &NameSilo{}
83 | case "vercel":
84 | dnsSelected = &Vercel{}
85 | case "dynadot":
86 | dnsSelected = &Dynadot{}
87 | case "dynv6":
88 | dnsSelected = &Dynv6{}
89 | case "spaceship":
90 | dnsSelected = &Spaceship{}
91 | default:
92 | dnsSelected = &Alidns{}
93 | }
94 | dnsSelected.Init(&dc, &Ipcache[i][0], &Ipcache[i][1])
95 | domains := dnsSelected.AddUpdateDomainRecords()
96 | // webhook
97 | v4Status, v6Status := config.ExecWebhook(&domains, &conf)
98 | // 重置单个cache
99 | if v4Status == config.UpdatedFailed {
100 | Ipcache[i][0] = util.IpCache{}
101 | }
102 | if v6Status == config.UpdatedFailed {
103 | Ipcache[i][1] = util.IpCache{}
104 | }
105 | }
106 |
107 | util.ForceCompareGlobal = false
108 | }
109 |
--------------------------------------------------------------------------------
/dns/namecheap.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "io"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/jeessy2/ddns-go/v6/config"
9 | "github.com/jeessy2/ddns-go/v6/util"
10 | )
11 |
12 | const (
13 | nameCheapEndpoint string = "https://dynamicdns.park-your-domain.com/update?host=#{host}&domain=#{domain}&password=#{password}&ip=#{ip}"
14 | )
15 |
16 | // NameCheap Domain
17 | type NameCheap struct {
18 | DNS config.DNS
19 | Domains config.Domains
20 | lastIpv4 string
21 | lastIpv6 string
22 | }
23 |
24 | // NameCheap 修改域名解析结果
25 | type NameCheapResp struct {
26 | Status string
27 | Errors []string
28 | }
29 |
30 | // Init 初始化
31 | func (nc *NameCheap) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
32 | nc.Domains.Ipv4Cache = ipv4cache
33 | nc.Domains.Ipv6Cache = ipv6cache
34 | nc.lastIpv4 = ipv4cache.Addr
35 | nc.lastIpv6 = ipv6cache.Addr
36 |
37 | nc.DNS = dnsConf.DNS
38 | nc.Domains.GetNewIp(dnsConf)
39 | }
40 |
41 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
42 | func (nc *NameCheap) AddUpdateDomainRecords() config.Domains {
43 | nc.addUpdateDomainRecords("A")
44 | nc.addUpdateDomainRecords("AAAA")
45 | return nc.Domains
46 | }
47 |
48 | func (nc *NameCheap) addUpdateDomainRecords(recordType string) {
49 | ipAddr, domains := nc.Domains.GetNewIpResult(recordType)
50 |
51 | if ipAddr == "" {
52 | return
53 | }
54 |
55 | // 防止多次发送Webhook通知
56 | if recordType == "A" {
57 | if nc.lastIpv4 == ipAddr {
58 | util.Log("你的IPv4未变化, 未触发 %s 请求", "NameCheap")
59 | return
60 | }
61 | } else {
62 | // https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-http-request/
63 | util.Log("Namecheap 不支持更新 IPv6")
64 | return
65 | }
66 |
67 | for _, domain := range domains {
68 | nc.modify(domain, ipAddr)
69 | }
70 | }
71 |
72 | // 修改
73 | func (nc *NameCheap) modify(domain *config.Domain, ipAddr string) {
74 | var result NameCheapResp
75 | err := nc.request(&result, ipAddr, domain)
76 |
77 | if err != nil {
78 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
79 | domain.UpdateStatus = config.UpdatedFailed
80 | return
81 | }
82 |
83 | switch result.Status {
84 | case "Success":
85 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
86 | domain.UpdateStatus = config.UpdatedSuccess
87 | default:
88 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, result.Status)
89 | domain.UpdateStatus = config.UpdatedFailed
90 | }
91 | }
92 |
93 | // request 统一请求接口
94 | func (nc *NameCheap) request(result *NameCheapResp, ipAddr string, domain *config.Domain) (err error) {
95 | url := strings.NewReplacer(
96 | "#{host}", domain.GetSubDomain(),
97 | "#{domain}", domain.DomainName,
98 | "#{password}", nc.DNS.Secret,
99 | "#{ip}", ipAddr,
100 | ).Replace(nameCheapEndpoint)
101 |
102 | req, err := http.NewRequest(
103 | http.MethodGet,
104 | url,
105 | http.NoBody,
106 | )
107 |
108 | if err != nil {
109 | return
110 | }
111 |
112 | client := util.CreateHTTPClient()
113 | resp, err := client.Do(req)
114 | if err != nil {
115 | return
116 | }
117 |
118 | defer resp.Body.Close()
119 | data, err := io.ReadAll(resp.Body)
120 | if err != nil {
121 | return err
122 | }
123 |
124 | status := string(data)
125 |
126 | if strings.Contains(status, "0") {
127 | result.Status = "Success"
128 | } else {
129 | result.Status = status
130 | }
131 |
132 | return
133 | }
134 |
--------------------------------------------------------------------------------
/dns/namesilo.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "encoding/xml"
5 | "io"
6 | "net/http"
7 | "strings"
8 |
9 | "github.com/jeessy2/ddns-go/v6/config"
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | )
12 |
13 | const (
14 | nameSiloListRecordEndpoint = "https://www.namesilo.com/api/dnsListRecords?version=1&type=xml&key=#{password}&domain=#{domain}"
15 | nameSiloAddRecordEndpoint = "https://www.namesilo.com/api/dnsAddRecord?version=1&type=xml&key=#{password}&domain=#{domain}&rrhost=#{host}&rrtype=#{recordType}&rrvalue=#{ip}&rrttl=3600"
16 | nameSiloUpdateRecordEndpoint = "https://www.namesilo.com/api/dnsUpdateRecord?version=1&type=xml&key=#{password}&domain=#{domain}&rrhost=#{host}&rrid=#{recordID}&rrvalue=#{ip}&rrttl=3600"
17 | )
18 |
19 | // NameSilo Domain
20 | type NameSilo struct {
21 | DNS config.DNS
22 | Domains config.Domains
23 | lastIpv4 string
24 | lastIpv6 string
25 | }
26 |
27 | // NameSiloResp 修改域名解析结果
28 | type NameSiloResp struct {
29 | XMLName xml.Name `xml:"namesilo"`
30 | Request Request `xml:"request"`
31 | Reply ReplyResponse `xml:"reply"`
32 | }
33 |
34 | type ReplyResponse struct {
35 | Code int `xml:"code"`
36 | Detail string `xml:"detail"`
37 | RecordID string `xml:"record_id"`
38 | }
39 |
40 | type NameSiloDNSListRecordResp struct {
41 | XMLName xml.Name `xml:"namesilo"`
42 | Request Request `xml:"request"`
43 | Reply Reply `xml:"reply"`
44 | }
45 |
46 | type Request struct {
47 | Operation string `xml:"operation"`
48 | IP string `xml:"ip"`
49 | }
50 |
51 | type Reply struct {
52 | Code int `xml:"code"`
53 | Detail string `xml:"detail"`
54 | ResourceItems []ResourceRecord `xml:"resource_record"`
55 | }
56 |
57 | type ResourceRecord struct {
58 | RecordID string `xml:"record_id"`
59 | Type string `xml:"type"`
60 | Host string `xml:"host"`
61 | Value string `xml:"value"`
62 | TTL int `xml:"ttl"`
63 | Distance int `xml:"distance"`
64 | }
65 |
66 | // Init 初始化
67 | func (ns *NameSilo) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
68 | ns.Domains.Ipv4Cache = ipv4cache
69 | ns.Domains.Ipv6Cache = ipv6cache
70 | ns.lastIpv4 = ipv4cache.Addr
71 | ns.lastIpv6 = ipv6cache.Addr
72 |
73 | ns.DNS = dnsConf.DNS
74 | ns.Domains.GetNewIp(dnsConf)
75 | }
76 |
77 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
78 | func (ns *NameSilo) AddUpdateDomainRecords() config.Domains {
79 | ns.addUpdateDomainRecords("A")
80 | ns.addUpdateDomainRecords("AAAA")
81 | return ns.Domains
82 | }
83 |
84 | func (ns *NameSilo) addUpdateDomainRecords(recordType string) {
85 | ipAddr, domains := ns.Domains.GetNewIpResult(recordType)
86 |
87 | if ipAddr == "" {
88 | return
89 | }
90 |
91 | for _, domain := range domains {
92 | // 有可能有人填写@.example.com
93 | if domain.SubDomain == "@" {
94 | domain.SubDomain = ""
95 | }
96 | // 拿到DNS记录列表,从列表中去取对应域名的id,有id进行修改,没ID进行新增
97 | records, err := ns.listRecords(domain)
98 | if err != nil {
99 | util.Log("查询域名信息发生异常! %s", err)
100 | domain.UpdateStatus = config.UpdatedFailed
101 | return
102 | }
103 | items := records.Reply.ResourceItems
104 | record := findResourceRecord(items, recordType, domain.String())
105 | var isAdd bool
106 | var recordID string
107 | if record == nil {
108 | isAdd = true
109 | } else {
110 | recordID = record.RecordID
111 | if record.Value == ipAddr {
112 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
113 | continue
114 | }
115 | }
116 | ns.modify(domain, recordID, recordType, ipAddr, isAdd)
117 | }
118 | }
119 |
120 | // 修改
121 | func (ns *NameSilo) modify(domain *config.Domain, recordID, recordType, ipAddr string, isAdd bool) {
122 | var err error
123 | var result string
124 | var requestType string
125 | if isAdd {
126 | requestType = "新增"
127 | result, err = ns.request(ipAddr, domain, "", recordType, nameSiloAddRecordEndpoint)
128 | } else {
129 | requestType = "更新"
130 | result, err = ns.request(ipAddr, domain, recordID, "", nameSiloUpdateRecordEndpoint)
131 | }
132 | if err != nil {
133 | util.Log("异常信息: %s", err)
134 | domain.UpdateStatus = config.UpdatedFailed
135 | return
136 | }
137 | var resp NameSiloResp
138 | xml.Unmarshal([]byte(result), &resp)
139 | if resp.Reply.Code == 300 {
140 | util.Log(requestType+"域名解析 %s 成功! IP: %s\n", domain, ipAddr)
141 | domain.UpdateStatus = config.UpdatedSuccess
142 | } else {
143 | util.Log(requestType+"域名解析 %s 失败! 异常信息: %s", domain, resp.Reply.Detail)
144 | domain.UpdateStatus = config.UpdatedFailed
145 | }
146 | }
147 |
148 | func (ns *NameSilo) listRecords(domain *config.Domain) (*NameSiloDNSListRecordResp, error) {
149 | result, err := ns.request("", domain, "", "", nameSiloListRecordEndpoint)
150 | if err != nil {
151 | return nil, err
152 | }
153 |
154 | var resp NameSiloDNSListRecordResp
155 | if err = xml.Unmarshal([]byte(result), &resp); err != nil {
156 | return nil, err
157 | }
158 |
159 | return &resp, nil
160 | }
161 |
162 | // request 统一请求接口
163 | func (ns *NameSilo) request(ipAddr string, domain *config.Domain, recordID, recordType, url string) (result string, err error) {
164 | url = strings.NewReplacer(
165 | "#{host}", domain.SubDomain,
166 | "#{domain}", domain.DomainName,
167 | "#{password}", ns.DNS.Secret,
168 | "#{recordID}", recordID,
169 | "#{recordType}", recordType,
170 | "#{ip}", ipAddr,
171 | ).Replace(url)
172 | req, err := http.NewRequest(
173 | http.MethodGet,
174 | url,
175 | http.NoBody,
176 | )
177 |
178 | if err != nil {
179 | return
180 | }
181 |
182 | client := util.CreateHTTPClient()
183 | resp, err := client.Do(req)
184 | if err != nil {
185 | return
186 | }
187 |
188 | defer resp.Body.Close()
189 | data, err := io.ReadAll(resp.Body)
190 | result = string(data)
191 | return
192 | }
193 |
194 | func findResourceRecord(data []ResourceRecord, recordType, domain string) *ResourceRecord {
195 | for i := 0; i < len(data); i++ {
196 | if data[i].Host == domain && data[i].Type == recordType {
197 | return &data[i]
198 | }
199 | }
200 | return nil
201 | }
202 |
--------------------------------------------------------------------------------
/dns/porkbun.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 |
9 | "github.com/jeessy2/ddns-go/v6/config"
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | )
12 |
13 | const (
14 | porkbunEndpoint string = "https://api.porkbun.com/api/json/v3/dns"
15 | )
16 |
17 | type Porkbun struct {
18 | DNSConfig config.DNS
19 | Domains config.Domains
20 | TTL string
21 | }
22 | type PorkbunDomainRecord struct {
23 | Name *string `json:"name"` // subdomain
24 | Type *string `json:"type"` // record type, e.g. A AAAA CNAME
25 | Content *string `json:"content"` // value
26 | Ttl *string `json:"ttl"` // default 300
27 | }
28 |
29 | type PorkbunResponse struct {
30 | Status string `json:"status"`
31 | }
32 |
33 | type PorkbunDomainQueryResponse struct {
34 | *PorkbunResponse
35 | Records []PorkbunDomainRecord `json:"records"`
36 | }
37 |
38 | type PorkbunApiKey struct {
39 | AccessKey string `json:"apikey"`
40 | SecretKey string `json:"secretapikey"`
41 | }
42 |
43 | type PorkbunDomainCreateOrUpdateVO struct {
44 | *PorkbunApiKey
45 | *PorkbunDomainRecord
46 | }
47 |
48 | // Init 初始化
49 | func (pb *Porkbun) Init(conf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
50 | pb.Domains.Ipv4Cache = ipv4cache
51 | pb.Domains.Ipv6Cache = ipv6cache
52 | pb.DNSConfig = conf.DNS
53 | pb.Domains.GetNewIp(conf)
54 | if conf.TTL == "" {
55 | // 默认600s
56 | pb.TTL = "600"
57 | } else {
58 | pb.TTL = conf.TTL
59 | }
60 | }
61 |
62 | // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录
63 | func (pb *Porkbun) AddUpdateDomainRecords() config.Domains {
64 | pb.addUpdateDomainRecords("A")
65 | pb.addUpdateDomainRecords("AAAA")
66 | return pb.Domains
67 | }
68 |
69 | func (pb *Porkbun) addUpdateDomainRecords(recordType string) {
70 | ipAddr, domains := pb.Domains.GetNewIpResult(recordType)
71 |
72 | if ipAddr == "" {
73 | return
74 | }
75 |
76 | for _, domain := range domains {
77 | var record PorkbunDomainQueryResponse
78 | // 获取当前域名信息
79 | err := pb.request(
80 | porkbunEndpoint+fmt.Sprintf("/retrieveByNameType/%s/%s/%s", domain.DomainName, recordType, domain.SubDomain),
81 | &PorkbunApiKey{
82 | AccessKey: pb.DNSConfig.ID,
83 | SecretKey: pb.DNSConfig.Secret,
84 | },
85 | &record,
86 | )
87 |
88 | if err != nil {
89 | util.Log("查询域名信息发生异常! %s", err)
90 | domain.UpdateStatus = config.UpdatedFailed
91 | return
92 | }
93 | if record.Status == "SUCCESS" {
94 | if len(record.Records) > 0 {
95 | // 存在,更新
96 | pb.modify(&record, domain, recordType, ipAddr)
97 | } else {
98 | // 不存在,创建
99 | pb.create(domain, recordType, ipAddr)
100 | }
101 | } else {
102 | util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName)
103 | domain.UpdateStatus = config.UpdatedFailed
104 | }
105 | }
106 | }
107 |
108 | // 创建
109 | func (pb *Porkbun) create(domain *config.Domain, recordType string, ipAddr string) {
110 | var response PorkbunResponse
111 |
112 | err := pb.request(
113 | porkbunEndpoint+fmt.Sprintf("/create/%s", domain.DomainName),
114 | &PorkbunDomainCreateOrUpdateVO{
115 | PorkbunApiKey: &PorkbunApiKey{
116 | AccessKey: pb.DNSConfig.ID,
117 | SecretKey: pb.DNSConfig.Secret,
118 | },
119 | PorkbunDomainRecord: &PorkbunDomainRecord{
120 | Name: &domain.SubDomain,
121 | Type: &recordType,
122 | Content: &ipAddr,
123 | Ttl: &pb.TTL,
124 | },
125 | },
126 | &response,
127 | )
128 |
129 | if err != nil {
130 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err)
131 | domain.UpdateStatus = config.UpdatedFailed
132 | return
133 | }
134 |
135 | if response.Status == "SUCCESS" {
136 | util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr)
137 | domain.UpdateStatus = config.UpdatedSuccess
138 | } else {
139 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, response.Status)
140 | domain.UpdateStatus = config.UpdatedFailed
141 | }
142 | }
143 |
144 | // 修改
145 | func (pb *Porkbun) modify(record *PorkbunDomainQueryResponse, domain *config.Domain, recordType string, ipAddr string) {
146 |
147 | // 相同不修改
148 | if len(record.Records) > 0 && *record.Records[0].Content == ipAddr {
149 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
150 | return
151 | }
152 |
153 | var response PorkbunResponse
154 |
155 | err := pb.request(
156 | porkbunEndpoint+fmt.Sprintf("/editByNameType/%s/%s/%s", domain.DomainName, recordType, domain.SubDomain),
157 | &PorkbunDomainCreateOrUpdateVO{
158 | PorkbunApiKey: &PorkbunApiKey{
159 | AccessKey: pb.DNSConfig.ID,
160 | SecretKey: pb.DNSConfig.Secret,
161 | },
162 | PorkbunDomainRecord: &PorkbunDomainRecord{
163 | Content: &ipAddr,
164 | Ttl: &pb.TTL,
165 | },
166 | },
167 | &response,
168 | )
169 |
170 | if err != nil {
171 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
172 | domain.UpdateStatus = config.UpdatedFailed
173 | return
174 | }
175 |
176 | if response.Status == "SUCCESS" {
177 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
178 | domain.UpdateStatus = config.UpdatedSuccess
179 | } else {
180 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, response.Status)
181 | domain.UpdateStatus = config.UpdatedFailed
182 | }
183 | }
184 |
185 | // request 统一请求接口
186 | func (pb *Porkbun) request(url string, data interface{}, result interface{}) (err error) {
187 | jsonStr := make([]byte, 0)
188 | if data != nil {
189 | jsonStr, _ = json.Marshal(data)
190 | }
191 | req, err := http.NewRequest(
192 | "POST",
193 | url,
194 | bytes.NewBuffer(jsonStr),
195 | )
196 | if err != nil {
197 | return
198 | }
199 | req.Header.Set("Content-Type", "application/json")
200 |
201 | client := util.CreateHTTPClient()
202 | resp, err := client.Do(req)
203 | err = util.GetHTTPResponse(resp, err, result)
204 |
205 | return
206 | }
207 |
--------------------------------------------------------------------------------
/dns/spaceship.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "io"
8 | "net/http"
9 | "net/url"
10 | "strconv"
11 |
12 | "github.com/jeessy2/ddns-go/v6/config"
13 | "github.com/jeessy2/ddns-go/v6/util"
14 | )
15 |
16 | const spaceshipAPI = "https://spaceship.dev/api/v1/dns/records"
17 | const maxRecords = 500
18 |
19 | type Spaceship struct {
20 | domains config.Domains
21 | header http.Header
22 | ttl int
23 | }
24 |
25 | func (s *Spaceship) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
26 | s.domains.Ipv4Cache = ipv4cache
27 | s.domains.Ipv6Cache = ipv6cache
28 | s.domains.GetNewIp(dnsConf)
29 |
30 | s.ttl = 600
31 | if val, err := strconv.Atoi(dnsConf.TTL); err == nil {
32 | s.ttl = val
33 | }
34 | s.header = http.Header{
35 | "X-API-Key": {dnsConf.DNS.ID},
36 | "X-API-Secret": {dnsConf.DNS.Secret},
37 | "Content-Type": {"application/json"},
38 | }
39 | }
40 |
41 | func (s *Spaceship) AddUpdateDomainRecords() (domains config.Domains) {
42 | for _, recordType := range []string{"A", "AAAA"} {
43 | ip, domains := s.domains.GetNewIpResult(recordType)
44 | if ip == "" {
45 | continue
46 | }
47 | for _, domain := range domains {
48 | hasUpdated, err := s.updateRecord(recordType, ip, domain)
49 | if err != nil {
50 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
51 | domain.UpdateStatus = config.UpdatedFailed
52 | continue
53 | }
54 | if !hasUpdated {
55 | util.Log("你的IP %s 没有变化, 域名 %s", ip, domain)
56 | } else {
57 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ip)
58 | domain.UpdateStatus = config.UpdatedSuccess
59 | }
60 | }
61 | }
62 | return s.domains
63 | }
64 |
65 | func (s *Spaceship) request(domain *config.Domain, method string, query url.Values, payload []byte) (response []byte, err error) {
66 | url := fmt.Sprintf("%s/%s", spaceshipAPI, domain.DomainName)
67 | req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(payload)))
68 | if err != nil {
69 | return
70 | }
71 | req.Header = s.header
72 | req.URL.RawQuery = query.Encode()
73 |
74 | cli := util.CreateHTTPClient()
75 | resp, err := cli.Do(req)
76 | if err != nil {
77 | return
78 | }
79 |
80 | defer resp.Body.Close()
81 | response, err = io.ReadAll(resp.Body)
82 | if err != nil {
83 | return
84 | }
85 |
86 | type DataItem struct {
87 | Field string `json:"field"`
88 | Details string `json:"details"`
89 | }
90 |
91 | type ErrorResponse struct {
92 | Detail string `json:"detail"`
93 | Data *[]DataItem `json:"data,omitempty"`
94 | }
95 |
96 | if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
97 | var e ErrorResponse
98 | err = json.Unmarshal(response, &e)
99 | if err != nil {
100 | return
101 | }
102 | err = fmt.Errorf("request error: %s", e.Detail)
103 | return
104 | }
105 |
106 | return
107 | }
108 |
109 | func (s *Spaceship) createRecord(recordType string, ip string, domain *config.Domain) (err error) {
110 | type Item struct {
111 | Type string `json:"type"`
112 | Address string `json:"address"`
113 | Name string `json:"name"`
114 | TTL int `json:"ttl"`
115 | }
116 |
117 | type Payload struct {
118 | Force bool `json:"force"`
119 | Items []Item `json:"items"`
120 | }
121 |
122 | payload := Payload{
123 | Force: true,
124 | Items: []Item{
125 | {
126 | Type: recordType,
127 | Address: ip,
128 | Name: domain.SubDomain,
129 | TTL: s.ttl,
130 | },
131 | },
132 | }
133 | data, err := json.Marshal(payload)
134 | if err != nil {
135 | return
136 | }
137 | _, err = s.request(domain, "PUT", url.Values{}, data)
138 | return
139 | }
140 |
141 | func (s *Spaceship) getRecords(recordType string, domain *config.Domain) (ips []string, err error) {
142 | type Group struct {
143 | Type string `json:"type"`
144 | }
145 |
146 | type Item struct {
147 | Type string `json:"type"`
148 | Address string `json:"address"`
149 | Name string `json:"name"`
150 | TTL int `json:"ttl"`
151 | Group Group `json:"group"`
152 | }
153 |
154 | type Response struct {
155 | Items []Item `json:"items"`
156 | Total int `json:"total"`
157 | }
158 |
159 | resp, err := s.request(domain, "GET", url.Values{"take": {strconv.Itoa(maxRecords)}, "skip": {"0"}}, []byte{})
160 | if err != nil {
161 | return
162 | }
163 |
164 | var response Response
165 | err = json.Unmarshal(resp, &response)
166 | if err != nil {
167 | return
168 | }
169 |
170 | if response.Total > maxRecords {
171 | err = fmt.Errorf("could not fetch all %d records in a one request", response.Total)
172 | return
173 | }
174 |
175 | for _, item := range response.Items {
176 | if item.Type == recordType && item.Name == domain.SubDomain {
177 | ips = append(ips, item.Address)
178 | }
179 | }
180 | return
181 | }
182 |
183 | func (s *Spaceship) deleteRecords(recordType string, domain *config.Domain, ips []string) (err error) {
184 | if len(ips) == 0 {
185 | return
186 | }
187 |
188 | if len(ips) > maxRecords {
189 | err = fmt.Errorf("could not delete all %d records in a one request", len(ips))
190 | return
191 | }
192 |
193 | type Item struct {
194 | Type string `json:"type"`
195 | Address string `json:"address"`
196 | Name string `json:"name"`
197 | }
198 | var payload []Item
199 | for _, ip := range ips {
200 | payload = append(payload, Item{
201 | Type: recordType,
202 | Address: ip,
203 | Name: domain.SubDomain,
204 | })
205 | }
206 | data, err := json.Marshal(payload)
207 | if err != nil {
208 | return
209 | }
210 | _, err = s.request(domain, "DELETE", url.Values{}, data)
211 | return
212 | }
213 |
214 | func (s *Spaceship) updateRecord(recordType string, ip string, domain *config.Domain) (hasUpdated bool, err error) {
215 | ips, err := s.getRecords(recordType, domain)
216 | if err != nil {
217 | return
218 | }
219 | if len(ips) == 1 && ips[0] == ip {
220 | return
221 | }
222 | err = s.deleteRecords(recordType, domain, ips)
223 | if err != nil {
224 | return
225 | }
226 | err = s.createRecord(recordType, ip, domain)
227 | hasUpdated = true
228 | return
229 | }
230 |
--------------------------------------------------------------------------------
/dns/tencent_cloud.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "net/http"
7 | "strconv"
8 |
9 | "github.com/jeessy2/ddns-go/v6/config"
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | )
12 |
13 | const (
14 | tencentCloudEndPoint = "https://dnspod.tencentcloudapi.com"
15 | tencentCloudVersion = "2021-03-23"
16 | )
17 |
18 | // TencentCloud 腾讯云 DNSPod API 3.0 实现
19 | // https://cloud.tencent.com/document/api/1427/56193
20 | type TencentCloud struct {
21 | DNS config.DNS
22 | Domains config.Domains
23 | TTL int
24 | }
25 |
26 | // TencentCloudRecord 腾讯云记录
27 | type TencentCloudRecord struct {
28 | Domain string `json:"Domain"`
29 | // DescribeRecordList 不需要 SubDomain
30 | SubDomain string `json:"SubDomain,omitempty"`
31 | // CreateRecord/ModifyRecord 不需要 Subdomain
32 | Subdomain string `json:"Subdomain,omitempty"`
33 | RecordType string `json:"RecordType"`
34 | RecordLine string `json:"RecordLine"`
35 | // DescribeRecordList 不需要 Value
36 | Value string `json:"Value,omitempty"`
37 | // CreateRecord/DescribeRecordList 不需要 RecordId
38 | RecordId int `json:"RecordId,omitempty"`
39 | // DescribeRecordList 不需要 TTL
40 | TTL int `json:"TTL,omitempty"`
41 | }
42 |
43 | // TencentCloudRecordListsResp 获取域名的解析记录列表返回结果
44 | type TencentCloudRecordListsResp struct {
45 | TencentCloudStatus
46 | Response struct {
47 | RecordCountInfo struct {
48 | TotalCount int `json:"TotalCount"`
49 | } `json:"RecordCountInfo"`
50 |
51 | RecordList []TencentCloudRecord `json:"RecordList"`
52 | }
53 | }
54 |
55 | // TencentCloudStatus 腾讯云返回状态
56 | // https://cloud.tencent.com/document/product/1427/56192
57 | type TencentCloudStatus struct {
58 | Response struct {
59 | Error struct {
60 | Code string
61 | Message string
62 | }
63 | }
64 | }
65 |
66 | func (tc *TencentCloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
67 | tc.Domains.Ipv4Cache = ipv4cache
68 | tc.Domains.Ipv6Cache = ipv6cache
69 | tc.DNS = dnsConf.DNS
70 | tc.Domains.GetNewIp(dnsConf)
71 | if dnsConf.TTL == "" {
72 | // 默认 600s
73 | tc.TTL = 600
74 | } else {
75 | ttl, err := strconv.Atoi(dnsConf.TTL)
76 | if err != nil {
77 | tc.TTL = 600
78 | } else {
79 | tc.TTL = ttl
80 | }
81 | }
82 | }
83 |
84 | // AddUpdateDomainRecords 添加或更新 IPv4/IPv6 记录
85 | func (tc *TencentCloud) AddUpdateDomainRecords() config.Domains {
86 | tc.addUpdateDomainRecords("A")
87 | tc.addUpdateDomainRecords("AAAA")
88 | return tc.Domains
89 | }
90 |
91 | func (tc *TencentCloud) addUpdateDomainRecords(recordType string) {
92 | ipAddr, domains := tc.Domains.GetNewIpResult(recordType)
93 |
94 | if ipAddr == "" {
95 | return
96 | }
97 |
98 | for _, domain := range domains {
99 | result, err := tc.getRecordList(domain, recordType)
100 | if err != nil {
101 | util.Log("查询域名信息发生异常! %s", err)
102 | domain.UpdateStatus = config.UpdatedFailed
103 | return
104 | }
105 |
106 | if result.Response.RecordCountInfo.TotalCount > 0 {
107 | // 默认第一个
108 | recordSelected := result.Response.RecordList[0]
109 | params := domain.GetCustomParams()
110 | if params.Has("RecordId") {
111 | for i := 0; i < result.Response.RecordCountInfo.TotalCount; i++ {
112 | if strconv.Itoa(result.Response.RecordList[i].RecordId) == params.Get("RecordId") {
113 | recordSelected = result.Response.RecordList[i]
114 | }
115 | }
116 | }
117 |
118 | // 修改记录
119 | tc.modify(recordSelected, domain, recordType, ipAddr)
120 | } else {
121 | // 添加记录
122 | tc.create(domain, recordType, ipAddr)
123 | }
124 | }
125 | }
126 |
127 | // create 添加记录
128 | // CreateRecord https://cloud.tencent.com/document/api/1427/56180
129 | func (tc *TencentCloud) create(domain *config.Domain, recordType string, ipAddr string) {
130 | record := &TencentCloudRecord{
131 | Domain: domain.DomainName,
132 | SubDomain: domain.GetSubDomain(),
133 | RecordType: recordType,
134 | RecordLine: tc.getRecordLine(domain),
135 | Value: ipAddr,
136 | TTL: tc.TTL,
137 | }
138 |
139 | var status TencentCloudStatus
140 | err := tc.request(
141 | "CreateRecord",
142 | record,
143 | &status,
144 | )
145 |
146 | if err != nil {
147 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err)
148 | domain.UpdateStatus = config.UpdatedFailed
149 | return
150 | }
151 |
152 | if status.Response.Error.Code == "" {
153 | util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr)
154 | domain.UpdateStatus = config.UpdatedSuccess
155 | } else {
156 | util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, status.Response.Error.Message)
157 | domain.UpdateStatus = config.UpdatedFailed
158 | }
159 | }
160 |
161 | // modify 修改记录
162 | // ModifyRecord https://cloud.tencent.com/document/api/1427/56157
163 | func (tc *TencentCloud) modify(record TencentCloudRecord, domain *config.Domain, recordType string, ipAddr string) {
164 | // 相同不修改
165 | if record.Value == ipAddr {
166 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
167 | return
168 | }
169 | var status TencentCloudStatus
170 | record.Domain = domain.DomainName
171 | record.SubDomain = domain.GetSubDomain()
172 | record.RecordType = recordType
173 | record.RecordLine = tc.getRecordLine(domain)
174 | record.Value = ipAddr
175 | record.TTL = tc.TTL
176 | err := tc.request(
177 | "ModifyRecord",
178 | record,
179 | &status,
180 | )
181 |
182 | if err != nil {
183 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err)
184 | domain.UpdateStatus = config.UpdatedFailed
185 | return
186 | }
187 |
188 | if status.Response.Error.Code == "" {
189 | util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr)
190 | domain.UpdateStatus = config.UpdatedSuccess
191 | } else {
192 | util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, status.Response.Error.Message)
193 | domain.UpdateStatus = config.UpdatedFailed
194 | }
195 | }
196 |
197 | // getRecordList 获取域名的解析记录列表
198 | // DescribeRecordList https://cloud.tencent.com/document/api/1427/56166
199 | func (tc *TencentCloud) getRecordList(domain *config.Domain, recordType string) (result TencentCloudRecordListsResp, err error) {
200 | record := TencentCloudRecord{
201 | Domain: domain.DomainName,
202 | Subdomain: domain.GetSubDomain(),
203 | RecordType: recordType,
204 | RecordLine: tc.getRecordLine(domain),
205 | }
206 | err = tc.request(
207 | "DescribeRecordList",
208 | record,
209 | &result,
210 | )
211 |
212 | return
213 | }
214 |
215 | // getRecordLine 获取记录线路,为空返回默认
216 | func (tc *TencentCloud) getRecordLine(domain *config.Domain) string {
217 | if domain.GetCustomParams().Has("RecordLine") {
218 | return domain.GetCustomParams().Get("RecordLine")
219 | }
220 | return "默认"
221 | }
222 |
223 | // request 统一请求接口
224 | func (tc *TencentCloud) request(action string, data interface{}, result interface{}) (err error) {
225 | jsonStr := make([]byte, 0)
226 | if data != nil {
227 | jsonStr, _ = json.Marshal(data)
228 | }
229 | req, err := http.NewRequest(
230 | "POST",
231 | tencentCloudEndPoint,
232 | bytes.NewBuffer(jsonStr),
233 | )
234 | if err != nil {
235 | return
236 | }
237 |
238 | req.Header.Set("Content-Type", "application/json")
239 | req.Header.Set("X-TC-Version", tencentCloudVersion)
240 |
241 | util.TencentCloudSigner(tc.DNS.ID, tc.DNS.Secret, req, action, string(jsonStr))
242 |
243 | client := util.CreateHTTPClient()
244 | resp, err := client.Do(req)
245 | err = util.GetHTTPResponse(resp, err, result)
246 |
247 | return
248 | }
249 |
--------------------------------------------------------------------------------
/dns/vercel.go:
--------------------------------------------------------------------------------
1 | package dns
2 |
3 | import (
4 | "bytes"
5 | "encoding/json"
6 | "fmt"
7 | "net/http"
8 | "strconv"
9 | "strings"
10 |
11 | "github.com/jeessy2/ddns-go/v6/config"
12 | "github.com/jeessy2/ddns-go/v6/util"
13 | )
14 |
15 | type Vercel struct {
16 | DNS config.DNS
17 | Domains config.Domains
18 | TTL int
19 | }
20 |
21 | type ListExistingRecordsResponse struct {
22 | Records []Record `json:"records"`
23 | }
24 |
25 | type Record struct {
26 | ID string `json:"id"` // 记录ID
27 | Slug string `json:"slug"`
28 | Name string `json:"name"` // 记录名称
29 | Type string `json:"type"` // 记录类型
30 | Value string `json:"value"` // 记录值
31 | Creator string `json:"creator"`
32 | Created int64 `json:"created"`
33 | Updated int64 `json:"updated"`
34 | CreatedAt int64 `json:"createdAt"`
35 | UpdatedAt int64 `json:"updatedAt"`
36 | TTL int64 `json:"ttl"`
37 | Comment *string `json:"comment,omitempty"`
38 | }
39 |
40 | func (v *Vercel) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {
41 | v.Domains.Ipv4Cache = ipv4cache
42 | v.Domains.Ipv6Cache = ipv6cache
43 | v.DNS = dnsConf.DNS
44 | v.Domains.GetNewIp(dnsConf)
45 |
46 | // Must be greater than 60
47 | ttl, err := strconv.Atoi(dnsConf.TTL)
48 | if err != nil {
49 | ttl = 60
50 | }
51 | if ttl < 60 {
52 | ttl = 60
53 | }
54 | v.TTL = ttl
55 | }
56 |
57 | func (v *Vercel) AddUpdateDomainRecords() (domains config.Domains) {
58 | v.addUpdateDomainRecords("A")
59 | v.addUpdateDomainRecords("AAAA")
60 | return v.Domains
61 | }
62 |
63 | func (v *Vercel) addUpdateDomainRecords(recordType string) {
64 | ipAddr, domains := v.Domains.GetNewIpResult(recordType)
65 |
66 | if ipAddr == "" {
67 | return
68 | }
69 |
70 | ipAddr = strings.ToLower(ipAddr)
71 |
72 | var (
73 | records []Record
74 | err error
75 | )
76 | for _, domain := range domains {
77 | records, err = v.listExistingRecords(domain)
78 | if err != nil {
79 | util.Log("查询域名信息发生异常! %s", err)
80 | continue
81 | }
82 |
83 | var targetRecord *Record
84 | for _, record := range records {
85 | if record.Name == domain.SubDomain {
86 | targetRecord = &record
87 | break
88 | }
89 | }
90 |
91 | if targetRecord == nil {
92 | err = v.createRecord(domain, recordType, ipAddr)
93 | } else {
94 | if strings.ToLower(targetRecord.Value) == ipAddr {
95 | util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain)
96 | domain.UpdateStatus = config.UpdatedNothing
97 | continue
98 | } else {
99 | err = v.updateRecord(targetRecord, recordType, ipAddr)
100 | }
101 | }
102 |
103 | operation := "新增"
104 | if targetRecord != nil {
105 | operation = "更新"
106 | }
107 | if err == nil {
108 | util.Log(operation+"域名解析 %s 成功! IP: %s", domain, ipAddr)
109 | domain.UpdateStatus = config.UpdatedSuccess
110 | } else {
111 | util.Log(operation+"域名解析 %s 失败! 异常信息: %s", domain, err)
112 | domain.UpdateStatus = config.UpdatedFailed
113 | }
114 | }
115 | }
116 |
117 | func (v *Vercel) listExistingRecords(domain *config.Domain) (records []Record, err error) {
118 | var result ListExistingRecordsResponse
119 | err = v.request(http.MethodGet, "https://api.vercel.com/v4/domains/"+domain.DomainName+"/records", nil, &result)
120 | if err != nil {
121 | return
122 | }
123 | records = result.Records
124 | return
125 | }
126 |
127 | func (v *Vercel) createRecord(domain *config.Domain, recordType string, recordValue string) (err error) {
128 | err = v.request(http.MethodPost, "https://api.vercel.com/v2/domains/"+domain.DomainName+"/records", map[string]interface{}{
129 | "name": domain.SubDomain,
130 | "type": recordType,
131 | "value": recordValue,
132 | "ttl": v.TTL,
133 | "comment": "Created by ddns-go",
134 | }, nil)
135 | return
136 | }
137 |
138 | func (v *Vercel) updateRecord(record *Record, recordType string, recordValue string) (err error) {
139 | err = v.request(http.MethodPatch, "https://api.vercel.com/v1/domains/records/"+record.ID, map[string]interface{}{
140 | "type": recordType,
141 | "value": recordValue,
142 | "ttl": v.TTL,
143 | }, nil)
144 | return
145 | }
146 |
147 | func (v *Vercel) request(method, api string, data, result interface{}) (err error) {
148 | var payload []byte
149 | if data != nil {
150 | payload, _ = json.Marshal(data)
151 | }
152 |
153 | req, err := http.NewRequest(
154 | method,
155 | api,
156 | bytes.NewBuffer(payload),
157 | )
158 | if err != nil {
159 | return
160 | }
161 | req.Header.Set("Authorization", "Bearer "+v.DNS.Secret)
162 | req.Header.Set("Content-Type", "application/json")
163 |
164 | client := util.CreateHTTPClient()
165 | resp, err := client.Do(req)
166 | if err != nil {
167 | return err
168 | }
169 | if resp.StatusCode != 200 {
170 | return fmt.Errorf("Vercel API returned status code %d", resp.StatusCode)
171 | }
172 | if result != nil {
173 | err = util.GetHTTPResponse(resp, err, result)
174 | }
175 | return
176 | }
177 |
--------------------------------------------------------------------------------
/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jeessy2/ddns-go/3756c607c9ed9657270c2ab26f232dfd00cf9503/favicon.ico
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jeessy2/ddns-go/v6
2 |
3 | go 1.23.6
4 |
5 | require (
6 | github.com/kardianos/service v1.2.2
7 | github.com/wagslane/go-password-validator v0.3.0
8 | golang.org/x/crypto v0.38.0
9 | golang.org/x/net v0.40.0
10 | gopkg.in/yaml.v3 v3.0.1
11 | )
12 |
13 | require (
14 | golang.org/x/sys v0.33.0 // indirect
15 | golang.org/x/text v0.25.0
16 | )
17 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60=
2 | github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
3 | github.com/wagslane/go-password-validator v0.3.0 h1:vfxOPzGHkz5S146HDpavl0cw1DSVP061Ry2PX0/ON6I=
4 | github.com/wagslane/go-password-validator v0.3.0/go.mod h1:TI1XJ6T5fRdRnHqHt14pvy1tNVnrwe7m3/f1f2fDphQ=
5 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
6 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
7 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
8 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
9 | golang.org/x/sys v0.0.0-20201015000850-e3ed0017c211/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
10 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
11 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
12 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
13 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
16 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
17 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
18 |
--------------------------------------------------------------------------------
/static/common.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --bg-color: #f2f3f8;
3 | --text-color: black;
4 | }
5 |
6 | [data-theme="dark"] {
7 | --bg-color: #22272e;
8 | --text-color: #adbac7;
9 | }
10 |
11 | body {
12 | background-color: var(--bg-color) !important;
13 | }
14 |
15 | #mask {
16 | background-color: #00000088;
17 | height: 100%;
18 | width: 100%;
19 | position: absolute;
20 | z-index: 1;
21 | }
22 |
23 | [data-theme='dark'] .form-control {
24 | background-color: #1c2128 !important;
25 | border-color: #444c56 !important;
26 | color: var(--text-color) !important;
27 | }
28 |
29 | [data-theme='dark'] .row {
30 | background-color: var(--bg-color);
31 | color: var(--text-color);
32 | }
33 |
34 | .portlet {
35 | display: -webkit-box;
36 | display: flex;
37 | -webkit-box-flex: 1;
38 | flex-grow: 1;
39 | -webkit-box-orient: vertical;
40 | -webkit-box-direction: normal;
41 | flex-direction: column;
42 | box-shadow: 0px 0px 13px 3px rgba(82, 63, 105, 0.05);
43 | background-color: #ffffff;
44 | margin-bottom: 20px;
45 | border-radius: 4px;
46 | }
47 |
48 | [data-theme='dark'] .portlet {
49 | background-color: #32353b;
50 | color: #adbac7;
51 | border: 2px solid #444c56;
52 | border-radius: 5px;
53 | box-shadow: unset;
54 | }
55 |
56 | .portlet .portlet__head {
57 | display: flex;
58 | -webkit-box-align: stretch;
59 | -webkit-box-pack: justify;
60 | justify-content: space-between;
61 | position: relative;
62 | padding: 0 20px;
63 | margin: 0;
64 | border-bottom: 1px solid #ebedf2;
65 | min-height: 60px;
66 | border-top-left-radius: 4px;
67 | border-top-right-radius: 4px;
68 | align-items: center;
69 | font-size: 1.2rem;
70 | font-weight: 540;
71 | color: #48465b;
72 | }
73 |
74 | [data-theme='dark'] .portlet .portlet__head {
75 | border-bottom: 1px solid #444c56;
76 | background-color: #2d333b !important;
77 | color: #adbac7;
78 | }
79 |
80 | .portlet .portlet__body {
81 | display: -webkit-box;
82 | display: -ms-flexbox;
83 | -webkit-box-orient: vertical;
84 | -webkit-box-direction: normal;
85 | -ms-flex-direction: column;
86 | flex-direction: column;
87 | padding: 20px;
88 | border-radius: 4px;
89 | }
90 |
91 | [data-theme='dark'] .portlet__body {
92 | background-color: #22272e !important;
93 | }
94 |
95 | .navbar {
96 | color: #adbac7;
97 | position: fixed !important;
98 | width: 100vw;
99 | z-index: 2;
100 | height: 3.5rem;
101 | }
102 |
103 | main {
104 | position: relative;
105 | padding-top: 3.5rem;
106 | overflow: hidden;
107 | }
108 |
109 | [data-theme='dark'] .navbar {
110 | background-image: linear-gradient(#2d333b, #22272e) !important;
111 | }
112 |
113 |
114 | [data-theme='dark'] .form {
115 | background-color: #1c2128 !important;
116 | color: #adbac7 !important;
117 | border-radius: 5px !important;
118 | border-color: #444c56 !important;
119 | }
120 |
121 | [data-theme='dark'] .form-group {
122 | background-color: transparent !important;
123 | }
124 |
125 | #logsBtn {
126 | position: relative;
127 | margin-left: auto;
128 | margin-right: 25px;
129 | }
130 |
131 | .unread:after {
132 | content: '';
133 | position: absolute;
134 | top: 0;
135 | right: 0;
136 | width: 8px;
137 | height: 8px;
138 | border-radius: 50%;
139 | background-color: #ff0000;
140 | }
141 |
142 | .theme-button {
143 | background-color: transparent;
144 | cursor: pointer;
145 | font-size: 15px;
146 | margin-right: 25px;
147 | }
148 |
149 | .theme-button:hover {
150 | box-shadow: 0px 0px 15px #0d0d0dab;
151 | }
152 |
153 | .theme-button:active {
154 | transform: scale(0.98);
155 | }
156 |
157 | #logs {
158 | max-height: 50vh !important;
159 | height: 600px !important;
160 | margin-bottom: 10px;
161 | overflow-y: auto;
162 | font-size: 13px !important;
163 | background-color: #f6f6f6;
164 | }
165 |
166 | .logs-panel {
167 | z-index: 2;
168 | background-color: rgb(255, 255, 255);
169 | color: var(--text-color);
170 | border-radius: 10px;
171 | padding: 15px !important;
172 | padding-bottom: 10px !important;
173 | border: 1px solid #cbcbcb;
174 | box-shadow: 0px 0px 13px 3px rgba(52, 52, 52, 0.226);
175 | }
176 |
177 | [data-theme='dark'] .logs-panel {
178 | background-color: #22272e;
179 | color: #adbac7;
180 | border: 1px solid #444c56;
181 | box-shadow: unset;
182 | }
183 |
184 | .col-md-6.logs-panel {
185 | position: fixed;
186 | left: 0;
187 | }
188 |
189 | #msg-container {
190 | pointer-events: none;
191 | z-index: 3;
192 | position: fixed;
193 | width: 100vw;
194 | padding: 0 5vw;
195 | display: flex;
196 | flex-direction: column;
197 | align-items: center;
198 | top: 0;
199 | }
200 |
201 | #msg-container .msg {
202 | pointer-events: all;
203 | padding: 9px 12px;
204 | text-align: center;
205 | line-height: 1.5714285714285714;
206 | border-radius: 8px;
207 | box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 3px 6px -4px rgba(0, 0, 0, 0.12), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
208 | font-size: 14px;
209 | background-color: #ffffff;
210 | margin: 8px 0;
211 | color: var(--text-color);
212 | transition: all 0.2s ease-in;
213 | }
214 |
215 | [data-theme='dark'] #msg-container .msg {
216 | background-color: #1f1f1f;
217 | }
218 |
219 | #msg-container .msg-fade {
220 | opacity: 0;
221 | transform: translateY(-1rem) scale(0.6);
222 | transition: all 0.2s ease-in-out;
223 | }
224 |
225 | #msg-container .msg-icon {
226 | margin-right: 8px;
227 | line-height: 0;
228 | text-align: center;
229 | font-size: 16px;
230 | }
231 |
232 | .badge {
233 | margin-right: 20px; /* 给版本号添加右侧间距 */
234 | }
235 | .button-container{
236 | padding:0 !important;
237 | }
238 |
239 | .action-button {
240 | flex:none;
241 | padding:4px 6px;
242 | font-size: 14px;
243 | color: white;
244 | border: 1px solid white;
245 | border-radius: 8px;
246 | background-color: transparent;
247 | text-align: center;
248 | text-decoration: none;
249 | }
250 |
251 | .action-button:hover,
252 | .action-button:visited,
253 | .action-button:active,
254 | .action-button:focus {
255 | color: white;
256 | border-color: white;
257 | background-color: transparent;
258 | text-decoration: none;
259 | outline: none;
260 | }
261 |
262 | .tooltip[x-placement^="top"] .arrow, .tooltip[x-placement^="bottom"] .arrow {
263 | left: 50%;
264 | }
265 |
266 | .tooltip[x-placement^="left"] .arrow, .tooltip[x-placement^="right"] .arrow {
267 | top: 50%;
268 | }
269 |
270 | .tooltip[x-placement^="top"] .arrow::before, .tooltip[x-placement^="bottom"] .arrow::before {
271 | transform: translateX(-50%);
272 | }
273 |
274 | .tooltip[x-placement^="left"] .arrow::before, .tooltip[x-placement^="right"] .arrow::before {
275 | transform: translateY(-50%);
276 | }
--------------------------------------------------------------------------------
/static/theme-button.css:
--------------------------------------------------------------------------------
1 | /* From https://css.gg */
2 |
3 | .gg-dark-mode {
4 | box-sizing: border-box;
5 | position: relative;
6 | display: block;
7 | transform: scale(var(--ggs, 1));
8 | border: 2px solid;
9 | border-radius: 100px;
10 | width: 20px;
11 | height: 20px
12 | }
13 |
14 | .gg-dark-mode::after,
15 | .gg-dark-mode::before {
16 | content: "";
17 | box-sizing: border-box;
18 | position: absolute;
19 | display: block
20 | }
21 |
22 | .gg-dark-mode::before {
23 | border: 5px solid;
24 | border-top-left-radius: 100px;
25 | border-bottom-left-radius: 100px;
26 | border-right: 0;
27 | width: 9px;
28 | height: 18px;
29 | top: -1px;
30 | left: -1px
31 | }
32 |
33 | .gg-dark-mode::after {
34 | border: 4px solid;
35 | border-top-right-radius: 100px;
36 | border-bottom-right-radius: 100px;
37 | border-left: 0;
38 | width: 4px;
39 | height: 8px;
40 | right: 4px;
41 | top: 4px
42 | }
43 |
--------------------------------------------------------------------------------
/static/theme.js:
--------------------------------------------------------------------------------
1 | function toggleTheme(write = false) {
2 | const docEle = document.documentElement;
3 | if (docEle.getAttribute("data-theme") === "dark") {
4 | docEle.removeAttribute("data-theme");
5 | write && localStorage.setItem("theme", "light");
6 | } else {
7 | docEle.setAttribute("data-theme", "dark");
8 | write && localStorage.setItem("theme", "dark");
9 | }
10 | }
11 |
12 | const theme = localStorage.getItem("theme") ??
13 | (window.matchMedia("(prefers-color-scheme: dark)").matches
14 | ? "dark"
15 | : "light");
16 |
17 | if (theme === "dark") {
18 | toggleTheme();
19 | }
20 |
21 | // 主题切换
22 | document.getElementById("themeButton").addEventListener('click', () => toggleTheme(true));
23 |
--------------------------------------------------------------------------------
/static/tooltips.js:
--------------------------------------------------------------------------------
1 | class Tooltip {
2 | constructor(element, triggers) {
3 | this.$element = element;
4 | this.$tooltip = null;
5 | this.originalTitle = '';
6 | this._bindEvents(triggers);
7 | }
8 |
9 | _createTooltipElement(options) {
10 | const title = options.title || this.$element.dataset.title || this.originalTitle;
11 | if (!title) {
12 | return;
13 | }
14 | const useHtml = options.hasOwnProperty('html') ? options.html : this.$element.dataset.html === 'true';
15 | let placement = options.placement || this.$element.dataset.placement || 'auto';
16 | if (placement === 'auto') {
17 | const rect = this.$element.getBoundingClientRect();
18 | const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
19 | const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
20 | const space = {
21 | top: rect.top,
22 | bottom: viewportHeight - rect.bottom,
23 | left: rect.left,
24 | right: viewportWidth - rect.right
25 | };
26 | placement = Object.keys(space).reduce((a, b) => space[a] > space[b] ? a : b);
27 | }
28 | this.$tooltip = html2Element(`
29 |
37 | `)
38 | if (useHtml) {
39 | this.$tooltip.querySelector('.tooltip-inner').innerHTML = title
40 | } else {
41 | this.$tooltip.querySelector('.tooltip-inner').textContent = title
42 | }
43 | }
44 |
45 | _updatePosition() {
46 | const elRect = this.$element.getBoundingClientRect()
47 | const bodyRect = document.body.getBoundingClientRect()
48 | const tooltipRect = this.$tooltip.getBoundingClientRect()
49 | const placement = this.$tooltip.getAttribute('x-placement')
50 |
51 | let left, top;
52 |
53 | switch(placement) {
54 | case 'top':
55 | left = elRect.left + (elRect.width - tooltipRect.width) / 2
56 | top = elRect.top - tooltipRect.height - 8
57 | break
58 | case 'bottom':
59 | left = elRect.left + (elRect.width - tooltipRect.width) / 2
60 | top = elRect.bottom + 8
61 | break
62 | case 'left':
63 | left = elRect.left - tooltipRect.width - 8
64 | top = elRect.top + (elRect.height - tooltipRect.height) / 2
65 | break
66 | case 'right':
67 | left = elRect.right + 8
68 | top = elRect.top + (elRect.height - tooltipRect.height) / 2
69 | break
70 | }
71 |
72 | // 考虑滚动条的影响
73 | left = left - bodyRect.left
74 | top = top - bodyRect.top
75 |
76 | this.$tooltip.style.left = `${left}px`
77 | this.$tooltip.style.top = `${top}px`
78 | }
79 |
80 | async show(options = {}) {
81 | if (this.$tooltip) {
82 | this.$tooltip.remove();
83 | }
84 | if (this.$element.title) {
85 | this.originalTitle = this.$element.title;
86 | this.$element.title = '';
87 | }
88 | this._createTooltipElement(options);
89 | if (!this.$tooltip) {
90 | return;
91 | }
92 | document.body.appendChild(this.$tooltip);
93 | await delay(0);
94 | if (!this.$tooltip) {
95 | return;
96 | }
97 | this._updatePosition();
98 | this.$tooltip.classList.add('show');
99 | }
100 |
101 | async hide() {
102 | if (this.originalTitle && !this.$element.title) {
103 | this.$element.title = this.originalTitle;
104 | }
105 | if (!this.$tooltip) {
106 | return;
107 | }
108 | this.$tooltip.classList.remove('show');
109 | await delay(200);
110 | if (!this.$tooltip) {
111 | return;
112 | }
113 | this.$tooltip.remove();
114 | this.$tooltip = null;
115 | }
116 |
117 | _bindEvents(triggers) {
118 | let state = 0;
119 | const _enter = () => {
120 | state += 1;
121 | this.show();
122 | };
123 | const _leave = () => {
124 | state -= 1;
125 | if (state <= 0) {
126 | this.hide();
127 | }
128 | };
129 | if (!triggers) {
130 | triggers = (this.$element.dataset.trigger || 'hover focus').split(' ');
131 | }
132 | triggers.forEach(trigger => {
133 | switch(trigger) {
134 | case 'hover':
135 | this.$element.addEventListener('mouseenter', _enter);
136 | this.$element.addEventListener('mouseleave', _leave);
137 | break;
138 | case 'focus':
139 | this.$element.addEventListener('focusin', _enter);
140 | this.$element.addEventListener('focusout', _leave);
141 | break;
142 | case 'click':
143 | this.$element.addEventListener('click', () => {
144 | if (this.$tooltip) {
145 | this.hide();
146 | } else {
147 | this.show();
148 | }
149 | });
150 | break;
151 | case 'manual':
152 | break;
153 | default:
154 | console.warn(`Unknown trigger: ${trigger}`);
155 | }
156 | });
157 | }
158 | }
159 |
160 | // 初始化所有带data-tooltip属性的元素
161 | const initTooltips = () => {
162 | window.tooltips = {};
163 | document.querySelectorAll('[data-toggle="tooltip"]').forEach(element => {
164 | let key = element.dataset.tooltipKey || element.id;
165 | if (!key) {
166 | key = crypto.randomUUID();
167 | element.dataset.tooltipKey = key;
168 | }
169 | window.tooltips[key] = new Tooltip(element);
170 | });
171 | };
172 |
173 | // 页面加载完成后初始化
174 | document.addEventListener('DOMContentLoaded', initTooltips);
--------------------------------------------------------------------------------
/static/utils.js:
--------------------------------------------------------------------------------
1 | const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
2 |
3 | const html2Element = (htmlString) => {
4 | const doc = new DOMParser().parseFromString(htmlString, 'text/html')
5 | return doc.body.firstElementChild
6 | }
7 |
8 | // 在页面顶部显示一行消息,并在若干秒后自动消失
9 | const showMessage = async (msgObj) => {
10 | // 填充默认值
11 | msgObj = Object.assign({
12 | type: 'info',
13 | content: '',
14 | html: false,
15 | duration: 3000
16 | }, msgObj)
17 | // 当前是否有消息容器
18 | let $container = document.getElementById('msg-container')
19 | if (!$container) {
20 | // 创建消息容器
21 | $container = html2Element('')
22 | document.body.appendChild($container)
23 | }
24 | // 创建消息元素
25 | const $msg = html2Element('')
26 | // 创建两个span,用于显示消息的图标和内容
27 | const $content = html2Element('')
28 |
29 | // 填充内容,根据html属性决定使用text还是html
30 | if (msgObj.html) {
31 | $content.innerHTML = msgObj.content
32 | } else {
33 | $content.textContent = msgObj.content
34 | }
35 | // 根据消息类型设置图标
36 | $msg.innerHTML = `${SVG_CODE[msgObj.type]}`
37 | $msg.appendChild($content)
38 | $container.appendChild($msg)
39 | // 确保动画生效
40 | await delay(0)
41 | $msg.classList.remove('msg-fade')
42 | // 等待动画结束
43 | await delay(200)
44 | // 销毁函数
45 | const destroy = async () => {
46 | // 增加消失动画
47 | $msg.classList.add('msg-fade')
48 | // 动画结束后移除元素
49 | await delay(200)
50 | $msg.remove()
51 | // 如果容器中没有消息了,移除容器
52 | if (!$container.children.length) {
53 | $container.remove()
54 | }
55 | }
56 | // 如果duration为0,则不自动消失
57 | if (msgObj.duration === 0) {
58 | return destroy
59 | }
60 | // 自动消失计时器
61 | let timer = setTimeout(destroy, msgObj.duration)
62 | // 注册鼠标事件,鼠标移入时取消自动消失
63 | $msg.addEventListener('mouseenter', () => {
64 | clearTimeout(timer)
65 | })
66 | // 鼠标移出时重新计时
67 | $msg.addEventListener('mouseleave', () => {
68 | timer = setTimeout(destroy, msgObj.duration)
69 | })
70 | return destroy
71 | }
72 |
73 | const request = {
74 | baseURL: './',
75 | parse: async function(resp) {
76 | const text = await resp.text()
77 | try {
78 | return JSON.parse(text)
79 | } catch (e) {
80 | return text
81 | }
82 | },
83 | stringify: function(dict) {
84 | const result = []
85 | for (let key in dict) {
86 | if (!dict.hasOwnProperty(key)) {
87 | continue
88 | }
89 | // 所有空值将被删除
90 | if (String(dict[key])) {
91 | result.push(`${key}=${encodeURIComponent(dict[key])}`)
92 | }
93 | }
94 | return result.join('&')
95 | },
96 | get: async function(path, data, parseFunc) {
97 | const response = await fetch(`${this.baseURL}${path}?${this.stringify(data)}`)
98 | if (response.redirected) {
99 | window.location.href = response.url
100 | }
101 | return await (parseFunc||this.parse)(response)
102 | },
103 | post: async function(path, data, parseFunc) {
104 | if (typeof data === 'object') {
105 | data = JSON.stringify(data)
106 | }
107 | const response = await fetch(`${this.baseURL}${path}`, {
108 | method: 'POST',
109 | body: data
110 | })
111 | if (response.redirected) {
112 | window.location.href = response.url
113 | }
114 | return await (parseFunc||this.parse)(response)
115 | }
116 | }
--------------------------------------------------------------------------------
/util/aliyun_signer.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/md5"
6 | "crypto/sha1"
7 | "crypto/sha256"
8 | "encoding/base64"
9 | "fmt"
10 | "hash"
11 | "io"
12 | "net/url"
13 | )
14 |
15 | // https://github.com/rosbit/aliyun-sign/blob/master/aliyun-sign.go
16 |
17 | var (
18 | signMethodMap = map[string]func() hash.Hash{
19 | "HMAC-SHA1": sha1.New,
20 | "HMAC-SHA256": sha256.New,
21 | "HMAC-MD5": md5.New,
22 | }
23 | )
24 |
25 | func HmacSign(signMethod string, httpMethod string, appKeySecret string, vals url.Values) (signature []byte) {
26 | key := []byte(appKeySecret + "&")
27 |
28 | var h hash.Hash
29 | if method, ok := signMethodMap[signMethod]; ok {
30 | h = hmac.New(method, key)
31 | } else {
32 | h = hmac.New(sha1.New, key)
33 | }
34 | makeDataToSign(h, httpMethod, vals)
35 | return h.Sum(nil)
36 | }
37 |
38 | func HmacSignToB64(signMethod string, httpMethod string, appKeySecret string, vals url.Values) (signature string) {
39 | return base64.StdEncoding.EncodeToString(HmacSign(signMethod, httpMethod, appKeySecret, vals))
40 | }
41 |
42 | type strToEnc struct {
43 | s string
44 | e bool
45 | }
46 |
47 | func makeDataToSign(w io.Writer, httpMethod string, vals url.Values) {
48 | in := make(chan *strToEnc)
49 | go func() {
50 | in <- &strToEnc{s: httpMethod}
51 | in <- &strToEnc{s: "&"}
52 | in <- &strToEnc{s: "/", e: true}
53 | in <- &strToEnc{s: "&"}
54 | in <- &strToEnc{s: vals.Encode(), e: true}
55 | close(in)
56 | }()
57 |
58 | specialUrlEncode(in, w)
59 | }
60 |
61 | var (
62 | encTilde = "%7E" // '~' -> "%7E"
63 | encBlank = []byte("%20") // ' ' -> "%20"
64 | tilde = []byte("~")
65 | )
66 |
67 | func specialUrlEncode(in <-chan *strToEnc, w io.Writer) {
68 | for s := range in {
69 | if !s.e {
70 | io.WriteString(w, s.s)
71 | continue
72 | }
73 |
74 | l := len(s.s)
75 | for i := 0; i < l; {
76 | ch := s.s[i]
77 |
78 | switch ch {
79 | case '%':
80 | if encTilde == s.s[i:i+3] {
81 | w.Write(tilde)
82 | i += 3
83 | continue
84 | }
85 | fallthrough
86 | case '*', '/', '&', '=':
87 | fmt.Fprintf(w, "%%%02X", ch)
88 | case '+':
89 | w.Write(encBlank)
90 | default:
91 | fmt.Fprintf(w, "%c", ch)
92 | }
93 |
94 | i += 1
95 | }
96 | }
97 | }
98 |
--------------------------------------------------------------------------------
/util/aliyun_signer_util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "net/url"
5 | "strconv"
6 | "time"
7 | )
8 |
9 | // AliyunSigner AliyunSigner
10 | func AliyunSigner(accessKeyID, accessSecret string, params *url.Values) {
11 | // 公共参数
12 | params.Set("SignatureMethod", "HMAC-SHA1")
13 | params.Set("SignatureNonce", strconv.FormatInt(time.Now().UnixNano(), 10))
14 | params.Set("AccessKeyId", accessKeyID)
15 | params.Set("SignatureVersion", "1.0")
16 | params.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z"))
17 | params.Set("Format", "JSON")
18 | params.Set("Version", "2015-01-09")
19 | params.Set("Signature", HmacSignToB64("HMAC-SHA1", "GET", accessSecret, *params))
20 | }
21 |
--------------------------------------------------------------------------------
/util/andriod_time.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "os/exec"
5 | "strings"
6 | "time"
7 | )
8 |
9 | func FixTimezone() {
10 | out, err := exec.Command("/system/bin/getprop", "persist.sys.timezone").Output()
11 | if err != nil {
12 | return
13 | }
14 | timeZone, err := time.LoadLocation(strings.TrimSpace(string(out)))
15 | if err != nil {
16 | return
17 | }
18 | time.Local = timeZone
19 | }
20 |
--------------------------------------------------------------------------------
/util/baidu_signer.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "fmt"
8 | "net/http"
9 | "strings"
10 | "time"
11 | )
12 |
13 | // https://cloud.baidu.com/doc/Reference/s/Njwvz1wot
14 |
15 | const (
16 | BaiduDateFormat = "2006-01-02T15:04:05Z"
17 | expirationPeriod = "1800"
18 | )
19 |
20 | func HmacSha256Hex(secret, message string) string {
21 | key := []byte(secret)
22 |
23 | h := hmac.New(sha256.New, key)
24 | h.Write([]byte(message))
25 | sha := hex.EncodeToString(h.Sum(nil))
26 | return sha
27 | }
28 |
29 | func BaiduCanonicalURI(r *http.Request) string {
30 | patterns := strings.Split(r.URL.Path, "/")
31 | var uri []string
32 | for _, v := range patterns {
33 | uri = append(uri, escape(v))
34 | }
35 | urlpath := strings.Join(uri, "/")
36 | if len(urlpath) == 0 || urlpath[len(urlpath)-1] != '/' {
37 | urlpath = urlpath + "/"
38 | }
39 | return urlpath[0 : len(urlpath)-1]
40 | }
41 |
42 | // BaiduSigner set Authorization header
43 | func BaiduSigner(accessKeyID, accessSecret string, r *http.Request) {
44 | //format: bce-auth-v1/{accessKeyId}/{timestamp}/{expirationPeriodInSeconds}
45 | authStringPrefix := "bce-auth-v1/" + accessKeyID + "/" + time.Now().UTC().Format(BaiduDateFormat) + "/" + expirationPeriod
46 | baiduCanonicalURL := BaiduCanonicalURI(r)
47 |
48 | //format: HTTP Method + "\n" + CanonicalURI + "\n" + CanonicalQueryString + "\n" + CanonicalHeaders
49 | //由于仅仅调用三个POST接口且不会更改,这里CanonicalQueryString和CanonicalHeaders直接写死
50 | CanonicalReq := fmt.Sprintf("%s\n%s\n%s\n%s", r.Method, baiduCanonicalURL, "", "host:bcd.baidubce.com")
51 |
52 | signingKey := HmacSha256Hex(accessSecret, authStringPrefix)
53 | signature := HmacSha256Hex(signingKey, CanonicalReq)
54 |
55 | //format: authStringPrefix/{signedHeaders}/{signature}
56 | authString := authStringPrefix + "/host/" + signature
57 | r.Header.Set(HeaderAuthorization, authString)
58 | }
59 |
--------------------------------------------------------------------------------
/util/bcrypt.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "golang.org/x/crypto/bcrypt"
5 | )
6 |
7 | // HashPassword 密码哈希
8 | func HashPassword(password string) (string, error) {
9 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
10 | if err != nil {
11 | return "", err
12 | }
13 | return string(hashedPassword), nil
14 | }
15 |
16 | // PasswordOK 检查密码
17 | func PasswordOK(hashedPassword, password string) bool {
18 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
19 | return err == nil
20 | }
21 |
22 | // IsHashedPassword 是否是哈希密码
23 | func IsHashedPassword(password string) bool {
24 | _, err := bcrypt.Cost([]byte(password))
25 | return err == nil
26 | }
27 |
--------------------------------------------------------------------------------
/util/copy_url_params.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "net/url"
4 |
5 | func CopyUrlParams(src url.Values, dest url.Values, keys []string) {
6 | if keys == nil || len(keys) == 0 {
7 | for key := range src {
8 | dest.Set(key, src.Get(key))
9 | }
10 | } else {
11 | for _, key := range keys {
12 | val := src.Get(key)
13 | if val != "" {
14 | dest.Set(key, val)
15 | }
16 | }
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/util/docker_util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "os"
4 |
5 | // DockerEnvFile Docker容器中包含的文件
6 | const DockerEnvFile string = "/.dockerenv"
7 |
8 | // IsRunInDocker 是否在docker中运行
9 | func IsRunInDocker() bool {
10 | _, err := os.Stat(DockerEnvFile)
11 | return err == nil
12 | }
13 |
--------------------------------------------------------------------------------
/util/escape.go:
--------------------------------------------------------------------------------
1 | // based on https://github.com/golang/go/blob/master/src/net/url/url.go
2 | // Copyright 2009 The Go Authors. All rights reserved.
3 | // Use of this source code is governed by a BSD-style
4 | // license that can be found in the LICENSE file.
5 |
6 | package util
7 |
8 | func shouldEscape(c byte) bool {
9 | if 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '_' || c == '-' || c == '~' || c == '.' {
10 | return false
11 | }
12 | return true
13 | }
14 | func escape(s string) string {
15 | hexCount := 0
16 | for i := 0; i < len(s); i++ {
17 | c := s[i]
18 | if shouldEscape(c) {
19 | hexCount++
20 | }
21 | }
22 |
23 | if hexCount == 0 {
24 | return s
25 | }
26 |
27 | t := make([]byte, len(s)+2*hexCount)
28 | j := 0
29 | for i := 0; i < len(s); i++ {
30 | switch c := s[i]; {
31 | case shouldEscape(c):
32 | t[j] = '%'
33 | t[j+1] = "0123456789ABCDEF"[c>>4]
34 | t[j+2] = "0123456789ABCDEF"[c&15]
35 | j += 3
36 | default:
37 | t[j] = s[i]
38 | j++
39 | }
40 | }
41 | return string(t)
42 | }
43 |
--------------------------------------------------------------------------------
/util/http_client_util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "context"
5 | "crypto/tls"
6 | "net"
7 | "net/http"
8 | "time"
9 | )
10 |
11 | var dialer = &net.Dialer{
12 | Timeout: 30 * time.Second,
13 | KeepAlive: 30 * time.Second,
14 | }
15 |
16 | var defaultTransport = &http.Transport{
17 | // from http.DefaultTransport
18 | Proxy: http.ProxyFromEnvironment,
19 | DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
20 | return dialer.DialContext(ctx, network, address)
21 | },
22 | ForceAttemptHTTP2: true,
23 | MaxIdleConns: 100,
24 | IdleConnTimeout: 90 * time.Second,
25 | TLSHandshakeTimeout: 10 * time.Second,
26 | ExpectContinueTimeout: 1 * time.Second,
27 | }
28 |
29 | // CreateHTTPClient Create Default HTTP Client
30 | func CreateHTTPClient() *http.Client {
31 | return &http.Client{
32 | Timeout: 30 * time.Second,
33 | Transport: defaultTransport,
34 | }
35 | }
36 |
37 | var noProxyTcp4Transport = &http.Transport{
38 | // no proxy
39 | // DisableKeepAlives
40 | DisableKeepAlives: true,
41 | // tcp4
42 | DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
43 | return dialer.DialContext(ctx, "tcp4", address)
44 | },
45 | // from http.DefaultTransport
46 | ForceAttemptHTTP2: true,
47 | MaxIdleConns: 100,
48 | IdleConnTimeout: 90 * time.Second,
49 | TLSHandshakeTimeout: 10 * time.Second,
50 | ExpectContinueTimeout: 1 * time.Second,
51 | }
52 |
53 | var noProxyTcp6Transport = &http.Transport{
54 | // no proxy
55 | // DisableKeepAlives
56 | DisableKeepAlives: true,
57 | // tcp6
58 | DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
59 | return dialer.DialContext(ctx, "tcp6", address)
60 | },
61 | // from http.DefaultTransport
62 | ForceAttemptHTTP2: true,
63 | MaxIdleConns: 100,
64 | IdleConnTimeout: 90 * time.Second,
65 | TLSHandshakeTimeout: 10 * time.Second,
66 | ExpectContinueTimeout: 1 * time.Second,
67 | }
68 |
69 | // CreateNoProxyHTTPClient Create NoProxy HTTP Client
70 | func CreateNoProxyHTTPClient(network string) *http.Client {
71 | if network == "tcp6" {
72 | return &http.Client{
73 | Timeout: 30 * time.Second,
74 | Transport: noProxyTcp6Transport,
75 | }
76 | }
77 |
78 | return &http.Client{
79 | Timeout: 30 * time.Second,
80 | Transport: noProxyTcp4Transport,
81 | }
82 | }
83 |
84 | // SetInsecureSkipVerify 将所有 http.Transport 的 InsecureSkipVerify 设置为 true
85 | func SetInsecureSkipVerify() {
86 | transports := []*http.Transport{defaultTransport, noProxyTcp4Transport, noProxyTcp6Transport}
87 |
88 | for _, transport := range transports {
89 | transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/util/http_util.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "io"
7 | "net/http"
8 | )
9 |
10 | // GetHTTPResponse 处理HTTP结果,返回序列化的json
11 | func GetHTTPResponse(resp *http.Response, err error, result interface{}) error {
12 | body, err := GetHTTPResponseOrg(resp, err)
13 |
14 | if err == nil {
15 | // log.Println(string(body))
16 | if len(body) != 0 {
17 | err = json.Unmarshal(body, &result)
18 | }
19 | }
20 |
21 | return err
22 |
23 | }
24 |
25 | // GetHTTPResponseOrg 处理HTTP结果,返回byte
26 | func GetHTTPResponseOrg(resp *http.Response, err error) ([]byte, error) {
27 | if err != nil {
28 | return nil, err
29 | }
30 |
31 | defer resp.Body.Close()
32 | lr := io.LimitReader(resp.Body, 1024000)
33 | body, err := io.ReadAll(lr)
34 |
35 | if err != nil {
36 | return nil, err
37 | }
38 |
39 | // 300及以上状态码都算异常
40 | if resp.StatusCode >= 300 {
41 | err = fmt.Errorf(LogStr("返回内容: %s ,返回状态码: %d", string(body), resp.StatusCode))
42 | }
43 |
44 | return body, err
45 | }
46 |
--------------------------------------------------------------------------------
/util/huawei_signer.go:
--------------------------------------------------------------------------------
1 | // HWS API Gateway Signature
2 | // based on https://github.com/datastream/aws/blob/master/signv4.go
3 | // Copyright (c) 2014, Xianjie
4 |
5 | package util
6 |
7 | import (
8 | "bytes"
9 | "crypto/hmac"
10 | "crypto/sha256"
11 | "fmt"
12 | "io"
13 | "net/http"
14 | "sort"
15 | "strings"
16 | "time"
17 | )
18 |
19 | const (
20 | BasicDateFormat = "20060102T150405Z"
21 | Algorithm = "SDK-HMAC-SHA256"
22 | HeaderXDate = "X-Sdk-Date"
23 | HeaderHost = "host"
24 | HeaderAuthorization = "Authorization"
25 | HeaderContentSha256 = "X-Sdk-Content-Sha256"
26 | )
27 |
28 | func hmacsha256(key []byte, data string) ([]byte, error) {
29 | h := hmac.New(sha256.New, []byte(key))
30 | if _, err := h.Write([]byte(data)); err != nil {
31 | return nil, err
32 | }
33 | return h.Sum(nil), nil
34 | }
35 |
36 | // Build a CanonicalRequest from a regular request string
37 | //
38 | // CanonicalRequest =
39 | //
40 | // HTTPRequestMethod + '\n' +
41 | // CanonicalURI + '\n' +
42 | // CanonicalQueryString + '\n' +
43 | // CanonicalHeaders + '\n' +
44 | // SignedHeaders + '\n' +
45 | // HexEncode(Hash(RequestPayload))
46 | func CanonicalRequest(r *http.Request, signedHeaders []string) (string, error) {
47 | var hexencode string
48 | var err error
49 | if hex := r.Header.Get(HeaderContentSha256); hex != "" {
50 | hexencode = hex
51 | } else {
52 | data, err := RequestPayload(r)
53 | if err != nil {
54 | return "", err
55 | }
56 | hexencode, err = HexEncodeSHA256Hash(data)
57 | if err != nil {
58 | return "", err
59 | }
60 | }
61 | return fmt.Sprintf("%s\n%s\n%s\n%s\n%s\n%s", r.Method, CanonicalURI(r), CanonicalQueryString(r), CanonicalHeaders(r, signedHeaders), strings.Join(signedHeaders, ";"), hexencode), err
62 | }
63 |
64 | // CanonicalURI returns request uri
65 | func CanonicalURI(r *http.Request) string {
66 | patterns := strings.Split(r.URL.Path, "/")
67 | var uri []string
68 | for _, v := range patterns {
69 | uri = append(uri, escape(v))
70 | }
71 | urlpath := strings.Join(uri, "/")
72 | if len(urlpath) == 0 || urlpath[len(urlpath)-1] != '/' {
73 | urlpath = urlpath + "/"
74 | }
75 | return urlpath
76 | }
77 |
78 | // CanonicalQueryString
79 | func CanonicalQueryString(r *http.Request) string {
80 | var keys []string
81 | query := r.URL.Query()
82 | for key := range query {
83 | keys = append(keys, key)
84 | }
85 | sort.Strings(keys)
86 | var a []string
87 | for _, key := range keys {
88 | k := escape(key)
89 | sort.Strings(query[key])
90 | for _, v := range query[key] {
91 | kv := fmt.Sprintf("%s=%s", k, escape(v))
92 | a = append(a, kv)
93 | }
94 | }
95 | queryStr := strings.Join(a, "&")
96 | r.URL.RawQuery = queryStr
97 | return queryStr
98 | }
99 |
100 | // CanonicalHeaders
101 | func CanonicalHeaders(r *http.Request, signerHeaders []string) string {
102 | var a []string
103 | header := make(map[string][]string)
104 | for k, v := range r.Header {
105 | header[strings.ToLower(k)] = v
106 | }
107 | for _, key := range signerHeaders {
108 | value := header[key]
109 | if strings.EqualFold(key, HeaderHost) {
110 | value = []string{r.Host}
111 | }
112 | sort.Strings(value)
113 | for _, v := range value {
114 | a = append(a, key+":"+strings.TrimSpace(v))
115 | }
116 | }
117 | return fmt.Sprintf("%s\n", strings.Join(a, "\n"))
118 | }
119 |
120 | // SignedHeaders
121 | func SignedHeaders(r *http.Request) []string {
122 | var a []string
123 | for key := range r.Header {
124 | a = append(a, strings.ToLower(key))
125 | }
126 | sort.Strings(a)
127 | return a
128 | }
129 |
130 | // RequestPayload
131 | func RequestPayload(r *http.Request) ([]byte, error) {
132 | if r.Body == nil {
133 | return []byte(""), nil
134 | }
135 | b, err := io.ReadAll(r.Body)
136 | if err != nil {
137 | return []byte(""), err
138 | }
139 | r.Body = io.NopCloser(bytes.NewBuffer(b))
140 | return b, err
141 | }
142 |
143 | // Create a "String to Sign".
144 | func StringToSign(canonicalRequest string, t time.Time) (string, error) {
145 | hash := sha256.New()
146 | _, err := hash.Write([]byte(canonicalRequest))
147 | if err != nil {
148 | return "", err
149 | }
150 | return fmt.Sprintf("%s\n%s\n%x",
151 | Algorithm, t.UTC().Format(BasicDateFormat), hash.Sum(nil)), nil
152 | }
153 |
154 | // Create the HWS Signature.
155 | func SignStringToSign(stringToSign string, signingKey []byte) (string, error) {
156 | hm, err := hmacsha256(signingKey, stringToSign)
157 | return fmt.Sprintf("%x", hm), err
158 | }
159 |
160 | // HexEncodeSHA256Hash returns hexcode of sha256
161 | func HexEncodeSHA256Hash(body []byte) (string, error) {
162 | hash := sha256.New()
163 | if body == nil {
164 | body = []byte("")
165 | }
166 | _, err := hash.Write(body)
167 | return fmt.Sprintf("%x", hash.Sum(nil)), err
168 | }
169 |
170 | // Get the finalized value for the "Authorization" header. The signature parameter is the output from SignStringToSign
171 | func AuthHeaderValue(signature, accessKey string, signedHeaders []string) string {
172 | return fmt.Sprintf("%s Access=%s, SignedHeaders=%s, Signature=%s", Algorithm, accessKey, strings.Join(signedHeaders, ";"), signature)
173 | }
174 |
175 | // Signature HWS meta
176 | type Signer struct {
177 | Key string
178 | Secret string
179 | }
180 |
181 | // SignRequest set Authorization header
182 | func (s *Signer) Sign(r *http.Request) error {
183 | var t time.Time
184 | var err error
185 | var dt string
186 | if dt = r.Header.Get(HeaderXDate); dt != "" {
187 | t, err = time.Parse(BasicDateFormat, dt)
188 | }
189 | if err != nil || dt == "" {
190 | t = time.Now()
191 | r.Header.Set(HeaderXDate, t.UTC().Format(BasicDateFormat))
192 | }
193 | signedHeaders := SignedHeaders(r)
194 | canonicalRequest, err := CanonicalRequest(r, signedHeaders)
195 | if err != nil {
196 | return err
197 | }
198 | stringToSign, err := StringToSign(canonicalRequest, t)
199 | if err != nil {
200 | return err
201 | }
202 | signature, err := SignStringToSign(stringToSign, []byte(s.Secret))
203 | if err != nil {
204 | return err
205 | }
206 | authValue := AuthHeaderValue(signature, s.Key, signedHeaders)
207 | r.Header.Set(HeaderAuthorization, authValue)
208 | return nil
209 | }
210 |
--------------------------------------------------------------------------------
/util/ip_cache.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "os"
5 | "strconv"
6 | )
7 |
8 | const IPCacheTimesENV = "DDNS_IP_CACHE_TIMES"
9 |
10 | // IpCache 上次IP缓存
11 | type IpCache struct {
12 | Addr string // 缓存地址
13 | Times int // 剩余次数
14 | TimesFailedIP int // 获取ip失败的次数
15 | }
16 |
17 | var ForceCompareGlobal = true
18 |
19 | func (d *IpCache) Check(newAddr string) bool {
20 | if newAddr == "" {
21 | return true
22 | }
23 | // 地址改变 或 达到剩余次数
24 | if d.Addr != newAddr || d.Times <= 1 {
25 | IPCacheTimes, err := strconv.Atoi(os.Getenv(IPCacheTimesENV))
26 | if err != nil {
27 | IPCacheTimes = 5
28 | }
29 | d.Addr = newAddr
30 | d.Times = IPCacheTimes + 1
31 | return true
32 | }
33 | d.Addr = newAddr
34 | d.Times--
35 | return false
36 | }
37 |
--------------------------------------------------------------------------------
/util/net.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "net"
5 | "net/http"
6 | "strings"
7 | )
8 |
9 | // IsPrivateNetwork 是否为私有地址
10 | // https://en.wikipedia.org/wiki/Private_network
11 | func IsPrivateNetwork(remoteAddr string) bool {
12 | // removing optional port from remoteAddr
13 | if strings.HasPrefix(remoteAddr, "[") { // ipv6
14 | if index := strings.LastIndex(remoteAddr, "]"); index != -1 {
15 | remoteAddr = remoteAddr[1:index]
16 | } else {
17 | return false
18 | }
19 | } else { // ipv4
20 | if index := strings.LastIndex(remoteAddr, ":"); index != -1 {
21 | remoteAddr = remoteAddr[:index]
22 | }
23 | }
24 |
25 | if ip := net.ParseIP(remoteAddr); ip != nil {
26 | return ip.IsLoopback() || // 127/8, ::1
27 | ip.IsPrivate() || // 10/8, 172.16/12, 192.168/16, fc00::/7
28 | ip.IsLinkLocalUnicast() // 169.254/16, fe80::/10
29 | }
30 |
31 | return false
32 | }
33 |
34 | // GetRequestIPStr get IP string from request
35 | func GetRequestIPStr(r *http.Request) (addr string) {
36 | addr = "Remote: " + r.RemoteAddr
37 | if r.Header.Get("X-Real-IP") != "" {
38 | addr = addr + " ,Real-IP: " + r.Header.Get("X-Real-IP")
39 | }
40 | if r.Header.Get("X-Forwarded-For") != "" {
41 | addr = addr + " ,Forwarded-For: " + r.Header.Get("X-Forwarded-For")
42 | }
43 | return addr
44 | }
45 |
--------------------------------------------------------------------------------
/util/net_resolver.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "context"
5 | "net"
6 | "net/url"
7 | "strings"
8 |
9 | "golang.org/x/text/language"
10 | )
11 |
12 | // BackupDNS will be used if DNS error occurs.
13 | var BackupDNS = []string{"1.1.1.1", "8.8.8.8", "9.9.9.9", "223.5.5.5"}
14 |
15 | func InitBackupDNS(customDNS, lang string) {
16 | if customDNS != "" {
17 | BackupDNS = []string{customDNS}
18 | return
19 | }
20 |
21 | if lang == language.Chinese.String() {
22 | BackupDNS = []string{"223.5.5.5", "114.114.114.114", "119.29.29.29"}
23 | }
24 |
25 | }
26 |
27 | // SetDNS sets the dialer.Resolver to use the given DNS server.
28 | func SetDNS(dns string) {
29 |
30 | if !strings.Contains(dns, "://") {
31 | dns = "udp://" + dns
32 | }
33 | svrParse, _ := url.Parse(dns)
34 |
35 | var network string
36 | switch strings.ToLower(svrParse.Scheme) {
37 | case "tcp":
38 | network = "tcp"
39 | default:
40 | network = "udp"
41 | }
42 |
43 | if svrParse.Port() == "" {
44 | dns = net.JoinHostPort(svrParse.Host, "53")
45 | } else {
46 | dns = svrParse.Host
47 | }
48 |
49 | dialer.Resolver = &net.Resolver{
50 | PreferGo: true,
51 | Dial: func(ctx context.Context, _, address string) (net.Conn, error) {
52 | return net.Dial(network, dns)
53 | },
54 | }
55 | }
56 |
57 | // LookupHost looks up the host based on the given URL using the dialer.Resolver.
58 | // A wrapper for [net.Resolver.LookupHost].
59 | func LookupHost(url string) error {
60 | name := toHostname(url)
61 |
62 | _, err := dialer.Resolver.LookupHost(context.Background(), name)
63 | return err
64 | }
65 |
--------------------------------------------------------------------------------
/util/net_resolver_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "testing"
4 |
5 | const (
6 | testDNS = "1.1.1.1"
7 | testURL = "https://cloudflare.com"
8 | )
9 |
10 | func TestSetDNS(t *testing.T) {
11 | SetDNS(testDNS)
12 |
13 | if dialer.Resolver == nil {
14 | t.Error("Failed to set dialer.Resolver")
15 | }
16 | }
17 |
18 | func TestLookupHost(t *testing.T) {
19 | t.Run("Valid URL", func(t *testing.T) {
20 | if err := LookupHost(testURL); err != nil {
21 | t.Errorf("Expected nil error, got %v", err)
22 | }
23 | })
24 |
25 | t.Run("Invalid URL", func(t *testing.T) {
26 | if err := LookupHost("invalidurl"); err == nil {
27 | t.Error("Expected error, got nil")
28 | }
29 | })
30 |
31 | t.Run("After SetDNS", func(t *testing.T) {
32 | SetDNS(testDNS)
33 |
34 | if err := LookupHost(testURL); err != nil {
35 | t.Errorf("Expected nil error, got %v", err)
36 | }
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/util/net_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "net/http"
5 | "testing"
6 | )
7 |
8 | // TestIsPrivateNetwork 测试是否为私有地址
9 | func TestIsPrivateNetwork(t *testing.T) {
10 |
11 | data := map[string]bool{
12 | "127.0.0.1": true, // listen on default port
13 | "127.0.0.1:9876": true,
14 | "[::1]": true,
15 | "[::1]:9876": true,
16 | "192.168.1.18:9876": true,
17 | "172.16.1.18:9876": true,
18 | "10.1.1.18:9876": true,
19 | "[fe80::1]:9876": true,
20 | "[fd00::1]:9876": true,
21 | "100.0.0.1": false,
22 | "100.0.0.1:9876": false,
23 | "[2409::1]": false,
24 | "[2409::1]:9876": false,
25 | "223.5.5.5:9876": false,
26 | }
27 |
28 | for key, value := range data {
29 | if IsPrivateNetwork(key) != value {
30 | t.Errorf("%s 校验失败\n", key)
31 | }
32 |
33 | }
34 | }
35 |
36 | // test get request IP string from request
37 | func TestGetRequestIPStr(t *testing.T) {
38 | req := http.Request{RemoteAddr: "192.168.1.1", Header: http.Header{}}
39 | req.Header.Set("X-Real-IP", "10.0.0.1")
40 | req.Header.Set("X-Forwarded-For", "10.0.0.2")
41 | if GetRequestIPStr(&req) != "Remote: 192.168.1.1 ,Real-IP: 10.0.0.1 ,Forwarded-For: 10.0.0.2" {
42 | t.Errorf("GetRequestIPStr failed")
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/util/ordinal.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "strconv"
5 |
6 | "golang.org/x/text/language"
7 | )
8 |
9 | // Ordinal returns the ordinal format of the given number.
10 | //
11 | // See also: https://github.com/dustin/go-humanize/blob/master/ordinals.go
12 | func Ordinal(x int, lang string) string {
13 | s := strconv.Itoa(x)
14 |
15 | // Chinese doesn't require an ordinal
16 | if lang == language.Chinese.String() {
17 | return s
18 | }
19 |
20 | suffix := "th"
21 | switch x % 10 {
22 | case 1:
23 | if x%100 != 11 {
24 | suffix = "st"
25 | }
26 | case 2:
27 | if x%100 != 12 {
28 | suffix = "nd"
29 | }
30 | case 3:
31 | if x%100 != 13 {
32 | suffix = "rd"
33 | }
34 | }
35 | return s + suffix
36 | }
37 |
--------------------------------------------------------------------------------
/util/ordinal_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "testing"
4 |
5 | func TestOrdinal(t *testing.T) {
6 | lang := "en"
7 |
8 | tests := []struct {
9 | name string
10 | got string
11 | want string
12 | }{
13 | {"0", Ordinal(0, lang), "0th"},
14 | {"1", Ordinal(1, lang), "1st"},
15 | {"2", Ordinal(2, lang), "2nd"},
16 | {"3", Ordinal(3, lang), "3rd"},
17 | {"4", Ordinal(4, lang), "4th"},
18 | {"10", Ordinal(10, lang), "10th"},
19 | {"11", Ordinal(11, lang), "11th"},
20 | {"12", Ordinal(12, lang), "12th"},
21 | {"13", Ordinal(13, lang), "13th"},
22 | {"21", Ordinal(21, lang), "21st"},
23 | {"32", Ordinal(32, lang), "32nd"},
24 | {"43", Ordinal(43, lang), "43rd"},
25 | {"101", Ordinal(101, lang), "101st"},
26 | {"102", Ordinal(102, lang), "102nd"},
27 | {"103", Ordinal(103, lang), "103rd"},
28 | {"211", Ordinal(211, lang), "211th"},
29 | {"212", Ordinal(212, lang), "212th"},
30 | {"213", Ordinal(213, lang), "213th"},
31 | }
32 |
33 | for _, tt := range tests {
34 | if tt.got != tt.want {
35 | t.Errorf("On %s, Expected %s, but got %s", tt.name, tt.want, tt.got)
36 | }
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/util/semver/version.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/Masterminds/semver/blob/v3.2.1/version.go
2 |
3 | package semver
4 |
5 | import (
6 | "bytes"
7 | "fmt"
8 | "regexp"
9 | "strconv"
10 | "strings"
11 | )
12 |
13 | // 在 init() 中创建的正则表达式的编译版本被缓存在这里,这样
14 | // 它只需要被创建一次。
15 | var versionRegex *regexp.Regexp
16 |
17 | // semVerRegex 是用于解析语义化版本的正则表达式。
18 | const semVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` +
19 | `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` +
20 | `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?`
21 |
22 | // Version 表示单独的语义化版本。
23 | type Version struct {
24 | major, minor, patch uint64
25 | }
26 |
27 | func init() {
28 | versionRegex = regexp.MustCompile("^" + semVerRegex + "$")
29 | }
30 |
31 | // NewVersion 解析给定的版本并返回 Version 实例,如果
32 | // 无法解析该版本则返回错误。如果版本是类似于 SemVer 的版本,则会
33 | // 尝试将其转换为 SemVer。
34 | func NewVersion(v string) (*Version, error) {
35 | m := versionRegex.FindStringSubmatch(v)
36 | if m == nil {
37 | return nil, fmt.Errorf("the %s, it's not a semantic version", v)
38 | }
39 |
40 | sv := &Version{}
41 |
42 | var err error
43 | sv.major, err = strconv.ParseUint(m[1], 10, 64)
44 | if err != nil {
45 | return nil, fmt.Errorf("解析版本号时出错:%s", err)
46 | }
47 |
48 | if m[2] != "" {
49 | sv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], "."), 10, 64)
50 | if err != nil {
51 | return nil, fmt.Errorf("解析版本号时出错:%s", err)
52 | }
53 | } else {
54 | sv.minor = 0
55 | }
56 |
57 | if m[3] != "" {
58 | sv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], "."), 10, 64)
59 | if err != nil {
60 | return nil, fmt.Errorf("解析版本号时出错:%s", err)
61 | }
62 | } else {
63 | sv.patch = 0
64 | }
65 |
66 | return sv, nil
67 | }
68 |
69 | // String 将 Version 对象转换为字符串。
70 | // 注意,如果原始版本包含前缀 v,则转换后的版本将不包含 v。
71 | // 根据规范,语义版本不包含前缀 v,而在实现上则是可选的。
72 | func (v Version) String() string {
73 | var buf bytes.Buffer
74 |
75 | fmt.Fprintf(&buf, "%d.%d.%d", v.major, v.minor, v.patch)
76 |
77 | return buf.String()
78 | }
79 |
80 | // GreaterThan 测试一个版本是否大于另一个版本。
81 | func (v *Version) GreaterThan(o *Version) bool {
82 | return v.compare(o) > 0
83 | }
84 |
85 | // GreaterThanOrEqual 测试一个版本是否大于或等于另一个版本。
86 | func (v *Version) GreaterThanOrEqual(o *Version) bool {
87 | return v.compare(o) >= 0
88 | }
89 |
90 | // compare 比较当前版本与另一个版本。如果当前版本小于另一个版本则返回 -1;如果两个版本相等则返回 0;如果当前版本大于另一个版本,则返回 1。
91 | //
92 | // 版本比较是基于 X.Y.Z 格式进行的。
93 | func (v *Version) compare(o *Version) int {
94 | // 比较主版本号、次版本号和修订号。如果
95 | // 发现差异则返回比较结果。
96 | if d := compareSegment(v.major, o.major); d != 0 {
97 | return d
98 | }
99 | if d := compareSegment(v.minor, o.minor); d != 0 {
100 | return d
101 | }
102 | if d := compareSegment(v.patch, o.patch); d != 0 {
103 | return d
104 | }
105 |
106 | return 0
107 | }
108 |
109 | func compareSegment(v, o uint64) int {
110 | if v < o {
111 | return -1
112 | }
113 | if v > o {
114 | return 1
115 | }
116 |
117 | return 0
118 | }
119 |
--------------------------------------------------------------------------------
/util/semver/version_test.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/Masterminds/semver/blob/v3.2.1/version_test.go
2 |
3 | package semver
4 |
5 | import "testing"
6 |
7 | func TestNewVersion(t *testing.T) {
8 | tests := []struct {
9 | version string
10 | err bool
11 | }{
12 | {"1.2.3", false},
13 | {"1.2.3+test.01", false},
14 | {"1.2.3-alpha.-1", false},
15 | {"v1.2.3", false},
16 | {"1.0", false},
17 | {"v1.0", false},
18 | {"1", false},
19 | {"v1", false},
20 | {"1.2.beta", true},
21 | {"v1.2.beta", true},
22 | {"foo", true},
23 | {"1.2-5", false},
24 | {"v1.2-5", false},
25 | {"1.2-beta.5", false},
26 | {"v1.2-beta.5", false},
27 | {"\n1.2", true},
28 | {"\nv1.2", true},
29 | {"1.2.0-x.Y.0+metadata", false},
30 | {"v1.2.0-x.Y.0+metadata", false},
31 | {"1.2.0-x.Y.0+metadata-width-hyphen", false},
32 | {"v1.2.0-x.Y.0+metadata-width-hyphen", false},
33 | {"1.2.3-rc1-with-hyphen", false},
34 | {"v1.2.3-rc1-with-hyphen", false},
35 | {"1.2.3.4", true},
36 | {"v1.2.3.4", true},
37 | {"1.2.2147483648", false},
38 | {"1.2147483648.3", false},
39 | {"2147483648.3.0", false},
40 |
41 | // Due to having 4 parts these should produce an error. See
42 | // https://github.com/Masterminds/semver/issues/185 for the reason for
43 | // these tests.
44 | {"12.3.4.1234", true},
45 | {"12.23.4.1234", true},
46 | {"12.3.34.1234", true},
47 |
48 | // The SemVer spec in a pre-release expects to allow [0-9A-Za-z-].
49 | {"20221209-update-renovatejson-v4", false},
50 | }
51 |
52 | for _, tc := range tests {
53 | _, err := NewVersion(tc.version)
54 | if tc.err && err == nil {
55 | t.Fatalf("expected error for version: %s", tc.version)
56 | } else if !tc.err && err != nil {
57 | t.Fatalf("error for version %s: %s", tc.version, err)
58 | }
59 | }
60 | }
61 |
62 | func TestParts(t *testing.T) {
63 | v, err := NewVersion("1.2.3")
64 | if err != nil {
65 | t.Error("Error parsing version 1.2.3")
66 | }
67 |
68 | if v.major != 1 {
69 | t.Error("major returning wrong value")
70 | }
71 | if v.minor != 2 {
72 | t.Error("minor returning wrong value")
73 | }
74 | if v.patch != 3 {
75 | t.Error("patch returning wrong value")
76 | }
77 | }
78 |
79 | func TestCoerceString(t *testing.T) {
80 | tests := []struct {
81 | version string
82 | expected string
83 | }{
84 | {"1.2.3", "1.2.3"},
85 | {"v1.2.3", "1.2.3"},
86 | {"1.0", "1.0.0"},
87 | {"v1.0", "1.0.0"},
88 | {"1", "1.0.0"},
89 | {"v1", "1.0.0"},
90 | }
91 |
92 | for _, tc := range tests {
93 | v, err := NewVersion(tc.version)
94 | if err != nil {
95 | t.Errorf("Error parsing version %s", tc)
96 | }
97 |
98 | s := v.String()
99 | if s != tc.expected {
100 | t.Errorf("Error generating string. Expected '%s' but got '%s'", tc.expected, s)
101 | }
102 | }
103 | }
104 |
105 | func TestCompare(t *testing.T) {
106 | tests := []struct {
107 | v1 string
108 | v2 string
109 | expected int
110 | }{
111 | {"1.2.3", "1.5.1", -1},
112 | {"2.2.3", "1.5.1", 1},
113 | {"2.2.3", "2.2.2", 1},
114 | }
115 |
116 | for _, tc := range tests {
117 | v1, err := NewVersion(tc.v1)
118 | if err != nil {
119 | t.Errorf("Error parsing version: %s", err)
120 | }
121 |
122 | v2, err := NewVersion(tc.v2)
123 | if err != nil {
124 | t.Errorf("Error parsing version: %s", err)
125 | }
126 |
127 | a := v1.compare(v2)
128 | e := tc.expected
129 | if a != e {
130 | t.Errorf(
131 | "Comparison of '%s' and '%s' failed. Expected '%d', got '%d'",
132 | tc.v1, tc.v2, e, a,
133 | )
134 | }
135 | }
136 | }
137 |
138 | func TestGreaterThan(t *testing.T) {
139 | tests := []struct {
140 | v1 string
141 | v2 string
142 | expected bool
143 | }{
144 | {"1.2.3", "1.5.1", false},
145 | {"2.2.3", "1.5.1", true},
146 | {"3.2-beta", "3.2-beta", false},
147 | {"3.2.0-beta.1", "3.2.0-beta.5", false},
148 | {"7.43.0-SNAPSHOT.99", "7.43.0-SNAPSHOT.103", false},
149 | {"7.43.0-SNAPSHOT.99", "7.43.0-SNAPSHOT.BAR", false},
150 | }
151 |
152 | for _, tc := range tests {
153 | v1, err := NewVersion(tc.v1)
154 | if err != nil {
155 | t.Errorf("Error parsing version: %s", err)
156 | }
157 |
158 | v2, err := NewVersion(tc.v2)
159 | if err != nil {
160 | t.Errorf("Error parsing version: %s", err)
161 | }
162 |
163 | a := v1.GreaterThan(v2)
164 | e := tc.expected
165 | if a != e {
166 | t.Errorf(
167 | "Comparison of '%s' and '%s' failed. Expected '%t', got '%t'",
168 | tc.v1, tc.v2, e, a,
169 | )
170 | }
171 | }
172 | }
173 |
174 | func TestGreaterThanOrEqual(t *testing.T) {
175 | tests := []struct {
176 | v1 string
177 | v2 string
178 | expected bool
179 | }{
180 | {"1.2.3", "1.5.1", false},
181 | {"2.2.3", "1.5.1", true},
182 | {"3.2-beta", "3.2-beta", true},
183 | {"3.2-beta.4", "3.2-beta.2", true},
184 | {"7.43.0-SNAPSHOT.FOO", "7.43.0-SNAPSHOT.103", true},
185 | }
186 |
187 | for _, tc := range tests {
188 | v1, err := NewVersion(tc.v1)
189 | if err != nil {
190 | t.Errorf("Error parsing version: %s", err)
191 | }
192 |
193 | v2, err := NewVersion(tc.v2)
194 | if err != nil {
195 | t.Errorf("Error parsing version: %s", err)
196 | }
197 |
198 | a := v1.GreaterThanOrEqual(v2)
199 | e := tc.expected
200 | if a != e {
201 | t.Errorf(
202 | "Comparison of '%s' and '%s' failed. Expected '%t', got '%t'",
203 | tc.v1, tc.v2, e, a,
204 | )
205 | }
206 | }
207 | }
208 |
--------------------------------------------------------------------------------
/util/string.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "strings"
4 |
5 | // WriteString creates a new string using [strings.Builder].
6 | func WriteString(strs ...string) string {
7 | var b strings.Builder
8 | for _, str := range strs {
9 | b.WriteString(str)
10 | }
11 |
12 | return b.String()
13 | }
14 |
15 | // toHostname normalizes a URL with a https scheme to just its hostname.
16 | //
17 | // See also:
18 | //
19 | // - https://github.com/moby/moby/blob/v25.0.3/registry/auth.go#L132
20 | func toHostname(url string) string {
21 | stripped := url
22 | stripped = strings.TrimPrefix(stripped, "https://")
23 |
24 | return strings.Split(stripped, "/")[0]
25 | }
26 |
27 | // SplitLines splits a string into lines by '\r\n' or '\n'.
28 | func SplitLines(s string) []string {
29 | if strings.Contains(s, "\r\n") {
30 | return strings.Split(s, "\r\n")
31 | }
32 |
33 | return strings.Split(s, "\n")
34 | }
35 |
--------------------------------------------------------------------------------
/util/string_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "testing"
4 |
5 | func TestWriteString(t *testing.T) {
6 | tests := []struct {
7 | input []string
8 | expected string
9 | }{
10 | {[]string{"hello", "world"}, "helloworld"},
11 | {[]string{"", "test"}, "test"},
12 | {[]string{"hello", " ", "world"}, "hello world"},
13 | {[]string{""}, ""},
14 | }
15 |
16 | for _, tt := range tests {
17 | result := WriteString(tt.input...)
18 | if result != tt.expected {
19 | t.Errorf("Expected %s, but got %s", tt.expected, result)
20 | }
21 | }
22 | }
23 |
24 | func TestToHostname(t *testing.T) {
25 | tests := []struct {
26 | name string
27 | input string
28 | expected string
29 | }{
30 | {"With https scheme", "https://www.example.com", "www.example.com"},
31 | {"With path", "www.example.com/path", "www.example.com"},
32 | {"With https scheme and path", "https://www.example.com/path", "www.example.com"},
33 | }
34 |
35 | for _, tt := range tests {
36 | t.Run(tt.name, func(t *testing.T) {
37 | result := toHostname(tt.input)
38 | if result != tt.expected {
39 | t.Errorf("Expected %s, but got %s", tt.expected, result)
40 | }
41 | })
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/util/tencent_cloud_signer.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/hex"
7 | "net/http"
8 | "strconv"
9 | "strings"
10 | "time"
11 | )
12 |
13 | func sha256hex(s string) string {
14 | b := sha256.Sum256([]byte(s))
15 | return hex.EncodeToString(b[:])
16 | }
17 |
18 | func tencentCloudHmacsha256(s, key string) string {
19 | hashed := hmac.New(sha256.New, []byte(key))
20 | hashed.Write([]byte(s))
21 | return string(hashed.Sum(nil))
22 | }
23 |
24 | // TencentCloudSigner 腾讯云签名方法 v3 https://cloud.tencent.com/document/api/1427/56189#Golang
25 | func TencentCloudSigner(secretId string, secretKey string, r *http.Request, action string, payload string) {
26 | algorithm := "TC3-HMAC-SHA256"
27 | service := "dnspod"
28 | host := WriteString(service, ".tencentcloudapi.com")
29 | timestamp := time.Now().Unix()
30 | timestampStr := strconv.FormatInt(timestamp, 10)
31 |
32 | // step 1: build canonical request string
33 | canonicalHeaders := WriteString("content-type:application/json\nhost:", host, "\nx-tc-action:", strings.ToLower(action), "\n")
34 | signedHeaders := "content-type;host;x-tc-action"
35 | hashedRequestPayload := sha256hex(payload)
36 | canonicalRequest := WriteString("POST\n/\n\n", canonicalHeaders, "\n", signedHeaders, "\n", hashedRequestPayload)
37 |
38 | // step 2: build string to sign
39 | date := time.Unix(timestamp, 0).UTC().Format("2006-01-02")
40 | credentialScope := WriteString(date, "/", service, "/tc3_request")
41 | hashedCanonicalRequest := sha256hex(canonicalRequest)
42 | string2sign := WriteString(algorithm, "\n", timestampStr, "\n", credentialScope, "\n", hashedCanonicalRequest)
43 |
44 | // step 3: sign string
45 | secretDate := tencentCloudHmacsha256(date, WriteString("TC3", secretKey))
46 | secretService := tencentCloudHmacsha256(service, secretDate)
47 | secretSigning := tencentCloudHmacsha256("tc3_request", secretService)
48 | signature := hex.EncodeToString([]byte(tencentCloudHmacsha256(string2sign, secretSigning)))
49 |
50 | // step 4: build authorization
51 | authorization := WriteString(algorithm, " Credential=", secretId, "/", credentialScope, ", SignedHeaders=", signedHeaders, ", Signature=", signature)
52 |
53 | r.Header.Add("Authorization", authorization)
54 | r.Header.Set("Host", host)
55 | r.Header.Set("X-TC-Action", action)
56 | r.Header.Add("X-TC-Timestamp", timestampStr)
57 | }
58 |
--------------------------------------------------------------------------------
/util/termux.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import "os"
4 |
5 | // isTermux 是否在 Termux 中运行
6 | //
7 | // https://wiki.termux.com/wiki/Getting_started
8 | func isTermux() bool {
9 | return os.Getenv("PREFIX") == "/data/data/com.termux/files/usr"
10 | }
11 |
--------------------------------------------------------------------------------
/util/termux_test.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "os"
5 | "testing"
6 | )
7 |
8 | // TestIsTermux 测试在或不在 Termux 中运行都能正确判断
9 | func TestIsTermux(t *testing.T) {
10 | // 模拟在 Termux 中运行
11 | os.Setenv("PREFIX", "/data/data/com.termux/files/usr")
12 |
13 | if !isTermux() {
14 | t.Error("期待 isTermux 返回 true,但得到 false。")
15 | }
16 |
17 | // 清除 PREFIX 变量,模拟不在 Termux 中运行
18 | os.Unsetenv("PREFIX")
19 |
20 | if isTermux() {
21 | t.Error("期待 isTermux 返回 false,但得到 true。")
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/util/token.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "crypto/hmac"
5 | "crypto/sha256"
6 | "encoding/base64"
7 | "fmt"
8 | "math/rand"
9 | "time"
10 | )
11 |
12 | // GenerateToken 生成Token
13 | func GenerateToken(username string) string {
14 | key := []byte(generateRandomKey())
15 | h := hmac.New(sha256.New, key)
16 | msg := fmt.Sprintf("%s%d", username, time.Now().Unix())
17 | h.Write([]byte(msg))
18 | return base64.StdEncoding.EncodeToString(h.Sum(nil))
19 | }
20 |
21 | // generateRandomKey 生成随机密钥
22 | func generateRandomKey() string {
23 | // 设置随机种子
24 | source := rand.NewSource(time.Now().UnixNano())
25 | random := rand.New(source)
26 |
27 | // 生成随机的64位整数
28 | randomNumber := random.Uint64()
29 |
30 | return fmt.Sprint(randomNumber)
31 | }
32 |
--------------------------------------------------------------------------------
/util/traffic_route_signer.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "bytes"
5 | "crypto/hmac"
6 | "crypto/sha256"
7 | "encoding/hex"
8 | "fmt"
9 | "net/http"
10 | "net/url"
11 | "strings"
12 | "time"
13 | )
14 |
15 | const Version = "2018-08-01"
16 | const Service = "DNS"
17 | const Region = "cn-north-1"
18 | const Host = "open.volcengineapi.com"
19 |
20 | // 第一步:准备辅助函数。
21 | // sha256非对称加密
22 | func hmacSHA256(key []byte, content string) []byte {
23 | mac := hmac.New(sha256.New, key)
24 | mac.Write([]byte(content))
25 | return mac.Sum(nil)
26 | }
27 |
28 | // sha256 hash算法
29 | func hashSHA256(content []byte) string {
30 | h := sha256.New()
31 | h.Write(content)
32 | return hex.EncodeToString(h.Sum(nil))
33 | }
34 |
35 | // 第二步:准备需要用到的结构体定义。
36 | // 签算请求结构体
37 | type RequestParam struct {
38 | Body []byte
39 | Method string
40 | Date time.Time
41 | Path string
42 | Host string
43 | QueryList url.Values
44 | }
45 |
46 | // 身份证明结构体
47 | type Credentials struct {
48 | AccessKeyID string
49 | SecretAccessKey string
50 | Service string
51 | Region string
52 | }
53 |
54 | // 签算结果结构体
55 | type SignRequest struct {
56 | XDate string
57 | Host string
58 | ContentType string
59 | XContentSha256 string
60 | Authorization string
61 | }
62 |
63 | // 第三步:创建一个 DNS 的 API 请求函数。签名计算的过程包含在该函数中。
64 | func TrafficRouteSigner(method string, query map[string][]string, header map[string]string, ak string, sk string, action string, body []byte) (*http.Request, error) {
65 | // 第四步:在requestDNS中,创建一个 HTTP 请求实例。
66 | // 创建 HTTP 请求实例。该实例会在后续用到。
67 | request, _ := http.NewRequest(method, "https://"+Host+"/", bytes.NewReader(body))
68 | urlVales := url.Values{}
69 | for k, v := range query {
70 | urlVales[k] = v
71 | }
72 | urlVales["Action"] = []string{action}
73 | urlVales["Version"] = []string{Version}
74 | request.URL.RawQuery = urlVales.Encode()
75 | for k, v := range header {
76 | request.Header.Set(k, v)
77 | }
78 | // 第五步:创建身份证明。其中的 Service 和 Region 字段是固定的。ak 和 sk 分别代表 AccessKeyID 和 SecretAccessKey。同时需要初始化签名结构体。一些签名计算时需要的属性也在这里处理。
79 | // 初始化身份证明
80 | credential := Credentials{
81 | AccessKeyID: ak,
82 | SecretAccessKey: sk,
83 | Service: Service,
84 | Region: Region,
85 | }
86 | // 初始化签名结构体
87 | requestParam := RequestParam{
88 | Body: body,
89 | Host: request.Host,
90 | Path: "/",
91 | Method: request.Method,
92 | Date: time.Now().UTC(),
93 | QueryList: request.URL.Query(),
94 | }
95 | // 第六步:接下来开始计算签名。在计算签名前,先准备好用于接收签算结果的 signResult 变量,并设置一些参数。
96 | // 初始化签名结果的结构体
97 | xDate := requestParam.Date.Format("20060102T150405Z")
98 | shortXDate := xDate[:8]
99 | XContentSha256 := hashSHA256(requestParam.Body)
100 | contentType := "application/json"
101 | signResult := SignRequest{
102 | Host: requestParam.Host, // 设置Host
103 | XContentSha256: XContentSha256, // 加密body
104 | XDate: xDate, // 设置标准化时间
105 | ContentType: contentType, // 设置Content-Type 为 application/json
106 | }
107 | // 第七步:计算 Signature 签名。
108 | signedHeadersStr := strings.Join([]string{"content-type", "host", "x-content-sha256", "x-date"}, ";")
109 | canonicalRequestStr := strings.Join([]string{
110 | requestParam.Method,
111 | requestParam.Path,
112 | request.URL.RawQuery,
113 | strings.Join([]string{"content-type:" + contentType, "host:" + requestParam.Host, "x-content-sha256:" + XContentSha256, "x-date:" + xDate}, "\n"),
114 | "",
115 | signedHeadersStr,
116 | XContentSha256,
117 | }, "\n")
118 | hashedCanonicalRequest := hashSHA256([]byte(canonicalRequestStr))
119 | credentialScope := strings.Join([]string{shortXDate, credential.Region, credential.Service, "request"}, "/")
120 | stringToSign := strings.Join([]string{
121 | "HMAC-SHA256",
122 | xDate,
123 | credentialScope,
124 | hashedCanonicalRequest,
125 | }, "\n")
126 | kDate := hmacSHA256([]byte(credential.SecretAccessKey), shortXDate)
127 | kRegion := hmacSHA256(kDate, credential.Region)
128 | kService := hmacSHA256(kRegion, credential.Service)
129 | kSigning := hmacSHA256(kService, "request")
130 | signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))
131 | signResult.Authorization = fmt.Sprintf("HMAC-SHA256 Credential=%s, SignedHeaders=%s, Signature=%s", credential.AccessKeyID+"/"+credentialScope, signedHeadersStr, signature)
132 | // 第八步:将 Signature 签名写入HTTP Header 中,并发送 HTTP 请求。
133 | // 设置经过签名的5个HTTP Header
134 | request.Header.Set("Host", signResult.Host)
135 | request.Header.Set("Content-Type", signResult.ContentType)
136 | request.Header.Set("X-Date", signResult.XDate)
137 | request.Header.Set("X-Content-Sha256", signResult.XContentSha256)
138 | request.Header.Set("Authorization", signResult.Authorization)
139 |
140 | return request, nil
141 | }
142 |
--------------------------------------------------------------------------------
/util/update/apply.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/inconshreveable/go-update/blob/7a872911e5b39953310f0a04161f0d50c7e63755/apply.go
2 |
3 | package update
4 |
5 | import (
6 | "bytes"
7 | "fmt"
8 | "io"
9 | "os"
10 | "os/exec"
11 | "path/filepath"
12 | "runtime"
13 | )
14 |
15 | // apply 使用给定的 io.Reader 的内容来更新 targetPath 的可执行文件。
16 | //
17 | // apply 执行以下操作以确保安全的跨平台更新:
18 | //
19 | // 1. 创建新文件 /path/to/target.new,并将更新文件的内容写入其中
20 | //
21 | // 2. 将 /path/to/target 重命名为 /path/to/target.old
22 | //
23 | // 3. 将 /path/to/target.new 重命名为 /path/to/target
24 | //
25 | // 4.如果最终的重命名成功,删除 /path/to/target.old 并返回无错误。
26 | //
27 | // 5. 如果最终重命名失败,尝试通过将 /path/to/target.old 重命名会
28 | // /path/to/target 进行回滚。
29 | //
30 | // 如果回滚操作失败,文件系统将处于不一致状态(第 4 步和第 5 步之间),
31 | // 既没有新的可执行文件,并且旧的可执行文件无法移动回其原始位置。在这种情况下,
32 | // 应该通知用户这个坏消息,并要求他们手动恢复。
33 | func apply(update io.Reader, targetPath string) error {
34 | newBytes, err := io.ReadAll(update)
35 | if err != nil {
36 | return err
37 | }
38 |
39 | // 获取可执行文件所在的目录
40 | updateDir := filepath.Dir(targetPath)
41 | filename := filepath.Base(targetPath)
42 |
43 | // 将新二进制的内容复制到新可执行文件中。
44 | newPath := filepath.Join(updateDir, fmt.Sprintf("%s.new", filename))
45 | fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY, 0755)
46 | if err != nil {
47 | return err
48 | }
49 | defer fp.Close()
50 |
51 | _, err = io.Copy(fp, bytes.NewReader(newBytes))
52 | if err != nil {
53 | return err
54 | }
55 |
56 | // 如果我们不调用 fp.Close(),Windows 将不允许我们移动新可执行文件。
57 | // 因为文件仍处于 "in use"(使用中)状态。
58 | fp.Close()
59 |
60 | // 这是我们将要移动可执行文件的位置,以便可以将更新的文件替代进来
61 | oldPath := filepath.Join(updateDir, fmt.Sprintf("%s.old", filename))
62 |
63 | // 删除任何现有的就执行文件 - 这在 Windows 上是必要的,原因有两个:
64 | // 1. 成功更新后,Windows 无法删除 .old 文件,因为进程仍在运行
65 | // 2. 如果目标文件已存在,Windows 重命名操作将失败
66 | _ = os.Remove(oldPath)
67 |
68 | // 将现有的可执行文件移到同一目录下的新文件中
69 | err = os.Rename(targetPath, oldPath)
70 | if err != nil {
71 | return err
72 | }
73 |
74 | // 将新可执行文件移到目标位置
75 | err = os.Rename(newPath, targetPath)
76 |
77 | if err != nil {
78 | // 移动失败
79 | //
80 | // 文件系统现在处于不良状态。我们已成功将现有的二进制文件移动到新位置,
81 | // 但无法将新二进制文件移动到原来的位置。这意味着当前可执行文件的位置上没有文件!
82 | // 尝试通过将旧二进制文件恢复到原始路径来回滚。
83 | rerr := os.Rename(oldPath, targetPath)
84 | if rerr != nil {
85 | return err
86 | }
87 |
88 | return err
89 | }
90 |
91 | // 移动成功,删除旧的二进制文件
92 | err = os.Remove(oldPath)
93 | if err != nil {
94 | if runtime.GOOS == "windows" {
95 | // Windows 无法删除 .old 文件,因为进程仍在运行。删除会提示 "Access is denied"。
96 | // 因此,启动外部进程来删除旧的二进制文件。
97 | // 外部进程会等待一会以确保进程已退出。
98 | //
99 | // https://stackoverflow.com/a/73585620
100 | exec.Command("cmd.exe", "/c", "ping 127.0.0.1 -n 2 > NUL & del "+oldPath).Start()
101 | return nil
102 | }
103 |
104 | return err
105 | }
106 |
107 | return nil
108 | }
109 |
--------------------------------------------------------------------------------
/util/update/apply_test.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/inconshreveable/go-update/blob/7a872911e5b39953310f0a04161f0d50c7e63755/apply_test.go
2 |
3 | package update
4 |
5 | import (
6 | "bytes"
7 | "fmt"
8 | "os"
9 | "testing"
10 | )
11 |
12 | var (
13 | oldFile = []byte{0xDE, 0xAD, 0xBE, 0xEF}
14 | newFile = []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}
15 | )
16 |
17 | func cleanup(path string) {
18 | os.Remove(path)
19 | os.Remove(fmt.Sprintf("%s.new", path))
20 | }
21 |
22 | // we write with a separate name for each test so that we can run them in parallel
23 | func writeOldFile(path string, t *testing.T) {
24 | if err := os.WriteFile(path, oldFile, 0777); err != nil {
25 | t.Fatalf("Failed to write file for testing preparation: %v", err)
26 | }
27 | if _, err := os.Stat(path); err != nil {
28 | t.Fatalf("Failed to stat file for testing preparation: %v", err)
29 | }
30 | }
31 |
32 | func validateUpdate(path string, err error, t *testing.T) {
33 | if err != nil {
34 | t.Fatalf("Failed to update: %v", err)
35 | }
36 |
37 | buf, err := os.ReadFile(path)
38 | if err != nil {
39 | t.Fatalf("Failed to read file post-update: %v", err)
40 | }
41 |
42 | if !bytes.Equal(buf, newFile) {
43 | t.Fatalf("File was not updated! Bytes read: %v, Bytes expected: %v", buf, newFile)
44 | }
45 | }
46 |
47 | func TestApply(t *testing.T) {
48 | t.Parallel()
49 |
50 | fName := "TestApply"
51 | defer cleanup(fName)
52 | writeOldFile(fName, t)
53 |
54 | err := apply(bytes.NewReader(newFile), fName)
55 | validateUpdate(fName, err, t)
56 | }
57 |
--------------------------------------------------------------------------------
/util/update/arch.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/arch.go
2 |
3 | package update
4 |
5 | import (
6 | "fmt"
7 | "runtime"
8 | )
9 |
10 | const (
11 | minARM = 5
12 | maxARM = 7
13 | )
14 |
15 | // generateAdditionalArch 可以根据 CPU 类型使用
16 | func generateAdditionalArch() []string {
17 | if runtime.GOARCH == "arm" && goarm >= minARM && goarm <= maxARM {
18 | additionalArch := make([]string, 0, maxARM-minARM)
19 | for v := goarm; v >= minARM; v-- {
20 | additionalArch = append(additionalArch, fmt.Sprintf("armv%d", v))
21 | }
22 | return additionalArch
23 | }
24 | if runtime.GOARCH == "amd64" {
25 | return []string{"x86_64"}
26 | }
27 | return []string{}
28 | }
29 |
--------------------------------------------------------------------------------
/util/update/arm.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/arm.go
2 |
3 | package update
4 |
5 | import (
6 | // unsafe 用于从 runtime 包中获取私有变量
7 | _ "unsafe"
8 | )
9 |
10 | //go:linkname goarm runtime.goarm
11 | var goarm uint8
12 |
--------------------------------------------------------------------------------
/util/update/decompress.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/decompress.go
2 |
3 | package update
4 |
5 | import (
6 | "archive/tar"
7 | "archive/zip"
8 | "bytes"
9 | "compress/gzip"
10 | "errors"
11 | "fmt"
12 | "io"
13 | "log"
14 | "path/filepath"
15 | "strings"
16 | )
17 |
18 | var fileTypes = map[string]func(src io.Reader, cmd string) (io.Reader, error){
19 | ".zip": unzip,
20 | ".tar.gz": untar,
21 | }
22 |
23 | // decompressCommand 解压缩给定源。从 'url' 参数中自动检测存档和压缩格式,'url' 参数表示 asset 的 URL,
24 | // 或简单的文件名(带扩展名)。
25 | // 返回 reader,用于读取解压缩后与 'cmd' 相应的命令。支持 '.zip' 和 '.tar.gz'
26 | //
27 | // 可能返回以下封装过的错误:
28 | // - errCannotDecompressFile
29 | // - errExecutableNotFoundInArchive
30 | func decompressCommand(src io.Reader, url, cmd string) (io.Reader, error) {
31 | for ext, decompress := range fileTypes {
32 | if strings.HasSuffix(url, ext) {
33 | return decompress(src, cmd)
34 | }
35 | }
36 | log.Print("It's not a compressed file, skip decompressing")
37 | return src, nil
38 | }
39 |
40 | func unzip(src io.Reader, cmd string) (io.Reader, error) {
41 | // 解压 Zip 格式时需要文件大小。
42 | // 因此我们需要先将 HTTP 响应读取到缓冲区中。
43 | buf, err := io.ReadAll(src)
44 | if err != nil {
45 | return nil, fmt.Errorf("%w zip 文件: %v", errCannotDecompressFile, err)
46 | }
47 |
48 | r := bytes.NewReader(buf)
49 | z, err := zip.NewReader(r, r.Size())
50 | if err != nil {
51 | return nil, fmt.Errorf("%w zip 文件: %s", errCannotDecompressFile, err)
52 | }
53 |
54 | for _, file := range z.File {
55 | _, name := filepath.Split(file.Name)
56 | if !file.FileInfo().IsDir() && matchExecutableName(cmd, name) {
57 | return file.Open()
58 | }
59 | }
60 |
61 | return nil, fmt.Errorf("在 zip 文件中%w:%q", errExecutableNotFoundInArchive, cmd)
62 | }
63 |
64 | func untar(src io.Reader, cmd string) (io.Reader, error) {
65 | gz, err := gzip.NewReader(src)
66 | if err != nil {
67 | return nil, fmt.Errorf("%w tar.gz 文件: %s", errCannotDecompressFile, err)
68 | }
69 |
70 | t := tar.NewReader(gz)
71 | for {
72 | h, err := t.Next()
73 | if errors.Is(err, io.EOF) {
74 | break
75 | }
76 | if err != nil {
77 | return nil, fmt.Errorf("%w tar.gz 文件:%s", errCannotDecompressFile, err)
78 | }
79 | _, name := filepath.Split(h.Name)
80 | if matchExecutableName(cmd, name) {
81 | return t, nil
82 | }
83 | }
84 | return nil, fmt.Errorf("在 tar.gz 文件中%w:%q", errExecutableNotFoundInArchive, cmd)
85 | }
86 |
87 | func matchExecutableName(cmd, target string) bool {
88 | return cmd == target || cmd+".exe" == target
89 | }
90 |
--------------------------------------------------------------------------------
/util/update/decompress_test.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/decompress_test.go
2 |
3 | package update
4 |
5 | import (
6 | "bytes"
7 | "io"
8 | "strings"
9 | "testing"
10 | )
11 |
12 | var buf = []byte{'a', 'b', 'c'}
13 |
14 | func TestCompressionNotRequired(t *testing.T) {
15 | want := bytes.NewReader(buf)
16 | r, err := decompressCommand(want, "https://github.com/foo/bar/releases/download/v1.2.3/foo", "foo")
17 | if err != nil {
18 | t.Fatal(err)
19 | }
20 |
21 | have, err := io.ReadAll(r)
22 | if err != nil {
23 | t.Fatal(err)
24 | }
25 |
26 | if !bytes.Equal(buf, have) {
27 | t.Errorf("expected %v, got %v", buf, have)
28 | }
29 | }
30 |
31 | func TestMatchExecutableName(t *testing.T) {
32 | testData := []struct {
33 | cmd string
34 | target string
35 | found bool
36 | }{
37 | {"gostuff", "gostuff", true},
38 | {"gostuff", "gostuff_linux_x86_64", false},
39 | {"gostuff", "gostuff_darwin_amd64", false},
40 | {"gostuff", "gostuff.exe", true},
41 | {"gostuff", "gostuff_windows_amd64.exe", false},
42 | }
43 |
44 | for _, testItem := range testData {
45 | t.Run(testItem.target, func(t *testing.T) {
46 | if matchExecutableName(testItem.cmd, testItem.target) != testItem.found {
47 | t.Errorf("Expected '%t' but got '%t'", testItem.found, matchExecutableName(testItem.cmd, testItem.target))
48 | }
49 | })
50 | }
51 | }
52 |
53 | func TestErrorFromReader(t *testing.T) {
54 | extensions := []string{
55 | "zip",
56 | "tar.gz",
57 | }
58 |
59 | for _, extension := range extensions {
60 | t.Run(extension, func(t *testing.T) {
61 | reader, err := decompressCommand(bytes.NewReader(buf), "foo."+extension, "foo."+extension)
62 | if err != nil {
63 | if !strings.Contains(err.Error(), errCannotDecompressFile.Error()) {
64 | t.Fatalf("Expected error: EOF, got: %v", err)
65 | }
66 | } else {
67 | _, err = io.ReadAll(reader)
68 | if err == nil {
69 | t.Fatalf("An error is expected but got nil.")
70 | }
71 | }
72 | })
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/util/update/detect.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/detect.go
2 |
3 | package update
4 |
5 | import (
6 | "fmt"
7 | "log"
8 | "runtime"
9 | "strings"
10 |
11 | "github.com/jeessy2/ddns-go/v6/util/semver"
12 | )
13 |
14 | // detectLatest 尝试从源提供者获取版本信息。
15 | func detectLatest(repo string) (latest *Latest, found bool, err error) {
16 | rel, err := getLatest(repo)
17 | if err != nil {
18 | return nil, false, err
19 | }
20 |
21 | asset, ver, found := findAsset(rel)
22 | if !found {
23 | return nil, false, nil
24 | }
25 |
26 | return newLatest(asset, ver), true, nil
27 | }
28 |
29 | // findAsset 返回最新的 asset
30 | func findAsset(rel *Release) (*Asset, *semver.Version, bool) {
31 | // 将检测到的架构放在列表的末尾,对于 ARM 来说这是可以的。
32 | // 因为附加的架构比通用架构更准确
33 | for _, arch := range append(generateAdditionalArch(), runtime.GOARCH) {
34 | asset, version, found := findAssetForArch(arch, rel)
35 | if found {
36 | return asset, version, found
37 | }
38 | }
39 |
40 | return nil, nil, false
41 | }
42 |
43 | func findAssetForArch(arch string, rel *Release,
44 | ) (asset *Asset, version *semver.Version, found bool) {
45 | var release *Release
46 |
47 | // 从 release 列表中查找最新的版本。
48 | // GitHub API 返回的列表按照创建日期的顺序排列。
49 | if a, v, ok := findAssetFromRelease(rel, getSuffixes(arch)); ok {
50 | version = v
51 | asset = a
52 | release = rel
53 | }
54 |
55 | if release == nil {
56 | log.Printf("Cannot find any release for %s/%s", runtime.GOOS, runtime.GOARCH)
57 | return nil, nil, false
58 | }
59 |
60 | return asset, version, true
61 | }
62 |
63 | func findAssetFromRelease(rel *Release, suffixes []string) (*Asset, *semver.Version, bool) {
64 | if rel == nil {
65 | log.Print("There is no source release information")
66 | return nil, nil, false
67 | }
68 |
69 | // 如果无法解析版本文本,则表示该文本不符合语义化版本规范,应该跳过。
70 | ver, err := semver.NewVersion(rel.tagName)
71 | if err != nil {
72 | log.Printf("Cannot parse semantic version: %s", rel.tagName)
73 | return nil, nil, false
74 | }
75 |
76 | for _, asset := range rel.assets {
77 | if assetMatchSuffixes(asset.name, suffixes) {
78 | return &asset, ver, true
79 | }
80 | }
81 |
82 | log.Printf("Can't find suitable asset in release %s", rel.tagName)
83 | return nil, nil, false
84 | }
85 |
86 | func assetMatchSuffixes(name string, suffixes []string) bool {
87 | for _, suffix := range suffixes {
88 | if strings.HasSuffix(name, suffix) { // 需要版本、架构等
89 | // 假设唯一的构件被匹配(或者第一个匹配将足够)
90 | return true
91 | }
92 | }
93 | return false
94 | }
95 |
96 | // getSuffixes 返回所有要与 asset 进行检查的候选后缀
97 | //
98 | // TODO: 由于缺失获取 MIPS 架构 float 的方法,所以目前无法正确获取 MIPS 架构的后缀。
99 | func getSuffixes(arch string) []string {
100 | suffixes := make([]string, 0)
101 | for _, ext := range []string{".zip", ".tar.gz"} {
102 | suffix := fmt.Sprintf("%s_%s%s", runtime.GOOS, arch, ext)
103 | suffixes = append(suffixes, suffix)
104 | }
105 | return suffixes
106 | }
107 |
--------------------------------------------------------------------------------
/util/update/errors.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/errors.go
2 |
3 | package update
4 |
5 | import "errors"
6 |
7 | var (
8 | errCannotDecompressFile = errors.New("failed to decompress")
9 | errExecutableNotFoundInArchive = errors.New("executable not found")
10 | )
11 |
--------------------------------------------------------------------------------
/util/update/latest.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/release.go
2 |
3 | package update
4 |
5 | import "github.com/jeessy2/ddns-go/v6/util/semver"
6 |
7 | // Latest 表示当前操作系统和架构的最新 release asset。
8 | type Latest struct {
9 | //Name 是 asset 的文件名
10 | Name string
11 | // URL 是 release 上传文件的 URL
12 | URL string
13 | // version 是解析后的 *Version
14 | Version *semver.Version
15 | }
16 |
17 | func newLatest(asset *Asset, ver *semver.Version) *Latest {
18 | latest := &Latest{
19 | Name: asset.name,
20 | URL: asset.url,
21 | Version: ver,
22 | }
23 |
24 | return latest
25 | }
26 |
--------------------------------------------------------------------------------
/util/update/package.go:
--------------------------------------------------------------------------------
1 | package update
2 |
3 | import (
4 | "fmt"
5 | "io"
6 | "log"
7 | "os"
8 | "runtime"
9 |
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | "github.com/jeessy2/ddns-go/v6/util/semver"
12 | )
13 |
14 | // Self 更新 ddns-go 到最新版本(如果可用)。
15 | func Self(version string) {
16 | // 如果不为语义化版本立即退出
17 | v, err := semver.NewVersion(version)
18 | if err != nil {
19 | log.Printf("Cannot update because: %v", err)
20 | return
21 | }
22 |
23 | latest, found, err := detectLatest("jeessy2/ddns-go")
24 | if err != nil {
25 | log.Printf("Error happened when detecting latest version: %v", err)
26 | return
27 | }
28 | if !found {
29 | log.Printf("Cannot find any release for %s/%s", runtime.GOOS, runtime.GOARCH)
30 | return
31 | }
32 | if v.GreaterThanOrEqual(latest.Version) {
33 | log.Printf("Current version (%s) is the latest", version)
34 | return
35 | }
36 |
37 | exe, err := os.Executable()
38 | if err != nil {
39 | log.Printf("Cannot find executable path: %v", err)
40 | return
41 | }
42 |
43 | if err = to(latest.URL, latest.Name, exe); err != nil {
44 | log.Printf("Error happened when updating binary: %v", err)
45 | return
46 | }
47 |
48 | log.Printf("Success update to v%s", latest.Version.String())
49 | }
50 |
51 | // to 从 assetURL 下载可执行文件,并用下载的文件替换当前的可执行文件。
52 | // 这个函数是用于更新二进制文件的低级 API。因为它不使用源提供者,而是直接通过 HTTP 从 URL 下载 asset 。
53 | // 所以这个函数不能用于更新私有仓库的 release。
54 | // cmdPath 是命令可执行文件的文件路径。
55 | func to(assetURL, assetFileName, cmdPath string) error {
56 | src, err := downloadAssetFromURL(assetURL)
57 | if err != nil {
58 | return err
59 | }
60 | defer src.Close()
61 | return decompressAndUpdate(src, assetFileName, cmdPath)
62 | }
63 |
64 | func downloadAssetFromURL(url string) (rc io.ReadCloser, err error) {
65 | client := util.CreateHTTPClient()
66 | resp, err := client.Get(url)
67 | if err != nil {
68 | return nil, fmt.Errorf("could not download release from %s: %v", url, err)
69 | }
70 | if resp.StatusCode >= 300 {
71 | resp.Body.Close()
72 | return nil, fmt.Errorf("could not download release from %s. Response code: %d", url, resp.StatusCode)
73 | }
74 |
75 | return resp.Body, nil
76 | }
77 |
--------------------------------------------------------------------------------
/util/update/release.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/github_release.go
2 | // and https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/github_source.go
3 |
4 | package update
5 |
6 | import (
7 | "fmt"
8 |
9 | "github.com/jeessy2/ddns-go/v6/util"
10 | )
11 |
12 | type Release struct {
13 | tagName string
14 | assets []Asset
15 | }
16 |
17 | type Asset struct {
18 | name string
19 | url string
20 | }
21 |
22 | // ReleaseResp 表示仓库中的 GitHub release 和 asset。
23 | type ReleaseResp struct {
24 | TagName string `json:"tag_name,omitempty"`
25 | Assets []struct {
26 | Name string `json:"name,omitempty"`
27 | BrowserDownloadURL string `json:"browser_download_url,omitempty"`
28 | } `json:"assets,omitempty"`
29 | }
30 |
31 | // getLatest 列出仓库的最新 release 并返回包装过的 Release
32 | //
33 | // GitHub API 文档:https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
34 | func getLatest(repo string) (*Release, error) {
35 | u := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo)
36 |
37 | client := util.CreateHTTPClient()
38 | resp, err := client.Get(u)
39 | if err != nil {
40 | return nil, err
41 | }
42 |
43 | var result ReleaseResp
44 | err = util.GetHTTPResponse(resp, err, &result)
45 | if err != nil {
46 | util.Log("异常信息: %s", err)
47 | return nil, err
48 | }
49 |
50 | return newRelease(&result), err
51 | }
52 |
53 | func newRelease(from *ReleaseResp) *Release {
54 | release := &Release{
55 | tagName: from.TagName,
56 | assets: make([]Asset, len(from.Assets)),
57 | }
58 | for i, fromAsset := range from.Assets {
59 | release.assets[i] = Asset{
60 | name: fromAsset.Name,
61 | url: fromAsset.BrowserDownloadURL,
62 | }
63 | }
64 | return release
65 | }
66 |
--------------------------------------------------------------------------------
/util/update/update.go:
--------------------------------------------------------------------------------
1 | // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/update.go
2 |
3 | package update
4 |
5 | import (
6 | "io"
7 | "path/filepath"
8 | )
9 |
10 | func decompressAndUpdate(src io.Reader, assetName, cmdPath string) error {
11 | _, cmd := filepath.Split(cmdPath)
12 | asset, err := decompressCommand(src, assetName, cmd)
13 | if err != nil {
14 | return err
15 | }
16 |
17 | return apply(asset, cmdPath)
18 | }
19 |
--------------------------------------------------------------------------------
/util/user.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "os"
5 | )
6 |
7 | const ConfigFilePathENV = "DDNS_CONFIG_FILE_PATH"
8 |
9 | // GetConfigFilePath 获得配置文件路径
10 | func GetConfigFilePath() string {
11 | configFilePath := os.Getenv(ConfigFilePathENV)
12 | if configFilePath != "" {
13 | return configFilePath
14 | }
15 | return GetConfigFilePathDefault()
16 | }
17 |
18 | // GetConfigFilePathDefault 获得默认的配置文件路径
19 | func GetConfigFilePathDefault() string {
20 | dir, err := os.UserHomeDir()
21 | if err != nil {
22 | // log.Println("Getting Home directory failed: ", err)
23 | return "../.ddns_go_config.yaml"
24 | }
25 | return dir + string(os.PathSeparator) + ".ddns_go_config.yaml"
26 | }
27 |
--------------------------------------------------------------------------------
/util/wait_internet.go:
--------------------------------------------------------------------------------
1 | package util
2 |
3 | import (
4 | "strings"
5 | "time"
6 | )
7 |
8 | // Wait blocks until the Internet is connected.
9 | //
10 | // See also:
11 | //
12 | // - https://stackoverflow.com/a/50058255
13 | // - https://github.com/ddev/ddev/blob/v1.22.7/pkg/globalconfig/global_config.go#L776
14 | func WaitInternet(addresses []string) {
15 | delay := time.Second * 5
16 | retryTimes := 0
17 | failed := false
18 |
19 | for {
20 | for _, addr := range addresses {
21 |
22 | err := LookupHost(addr)
23 | // Internet is connected.
24 | if err == nil {
25 | if failed {
26 | Log("网络已连接")
27 | }
28 | return
29 | }
30 |
31 | failed = true
32 | Log("等待网络连接: %s", err)
33 | Log("%s 后重试...", delay)
34 |
35 | if isDNSErr(err) || retryTimes > 0 {
36 | dns := BackupDNS[retryTimes%len(BackupDNS)]
37 | Log("本机DNS异常! 将默认使用 %s, 可参考文档通过 -dns 自定义 DNS 服务器", dns)
38 | SetDNS(dns)
39 | retryTimes = retryTimes + 1
40 | }
41 |
42 | time.Sleep(delay)
43 | }
44 | }
45 | }
46 |
47 | // isDNSErr checks if the error is caused by DNS.
48 | func isDNSErr(e error) bool {
49 | return strings.Contains(e.Error(), "[::1]:53: read: connection refused")
50 | }
51 |
--------------------------------------------------------------------------------
/web/auth.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "net/http"
5 | "time"
6 |
7 | "github.com/jeessy2/ddns-go/v6/config"
8 | "github.com/jeessy2/ddns-go/v6/util"
9 | )
10 |
11 | // ViewFunc func
12 | type ViewFunc func(http.ResponseWriter, *http.Request)
13 |
14 | // Auth 验证Token是否已经通过
15 | func Auth(f ViewFunc) ViewFunc {
16 | return func(w http.ResponseWriter, r *http.Request) {
17 | cookieInWeb, err := r.Cookie(cookieName)
18 | if err != nil {
19 | http.Redirect(w, r, "./login", http.StatusTemporaryRedirect)
20 | return
21 | }
22 |
23 | conf, _ := config.GetConfigCached()
24 |
25 | // 禁止公网访问
26 | if conf.NotAllowWanAccess {
27 | if !util.IsPrivateNetwork(r.RemoteAddr) {
28 | w.WriteHeader(http.StatusForbidden)
29 | util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r))
30 | return
31 | }
32 | }
33 |
34 | // 验证token
35 | if cookieInSystem.Value != "" &&
36 | cookieInSystem.Value == cookieInWeb.Value &&
37 | cookieInSystem.Expires.After(time.Now()) {
38 | f(w, r) // 执行被装饰的函数
39 | return
40 | }
41 |
42 | http.Redirect(w, r, "./login", http.StatusTemporaryRedirect)
43 | }
44 | }
45 |
46 | // AuthAssert 保护静态等文件不被公网访问
47 | func AuthAssert(f ViewFunc) ViewFunc {
48 | return func(w http.ResponseWriter, r *http.Request) {
49 |
50 | conf, err := config.GetConfigCached()
51 |
52 | // 配置文件为空, 启动时间超过3小时禁止从公网访问
53 | if err != nil &&
54 | time.Since(startTime) > time.Duration(3*time.Hour) && !util.IsPrivateNetwork(r.RemoteAddr) {
55 | w.WriteHeader(http.StatusForbidden)
56 | util.Log("%q 配置文件为空, 超过3小时禁止从公网访问", util.GetRequestIPStr(r))
57 | return
58 | }
59 |
60 | // 禁止公网访问
61 | if conf.NotAllowWanAccess {
62 | if !util.IsPrivateNetwork(r.RemoteAddr) {
63 | w.WriteHeader(http.StatusForbidden)
64 | util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r))
65 | return
66 | }
67 | }
68 |
69 | f(w, r) // 执行被装饰的函数
70 |
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/web/login.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "embed"
5 | "encoding/json"
6 | "fmt"
7 | "html/template"
8 | "net/http"
9 | "net/url"
10 | "time"
11 |
12 | "github.com/jeessy2/ddns-go/v6/config"
13 | "github.com/jeessy2/ddns-go/v6/util"
14 | )
15 |
16 | //go:embed login.html
17 | var loginEmbedFile embed.FS
18 |
19 | // CookieName cookie name
20 | var cookieName = "token"
21 |
22 | // CookieInSystem only one cookie
23 | var cookieInSystem = &http.Cookie{}
24 |
25 | // 服务启动时间
26 | var startTime = time.Now()
27 |
28 | // 保存限制时间
29 | var saveLimit = time.Duration(30 * time.Minute)
30 |
31 | // 登录检测
32 | type loginDetect struct {
33 | failedTimes uint32 // 失败次数
34 | ticker *time.Ticker // 定时器
35 | }
36 |
37 | var ld = &loginDetect{ticker: time.NewTicker(5 * time.Minute)}
38 |
39 | // Login login page
40 | func Login(writer http.ResponseWriter, request *http.Request) {
41 | tmpl, err := template.ParseFS(loginEmbedFile, "login.html")
42 | if err != nil {
43 | fmt.Println("Error happened..")
44 | fmt.Println(err)
45 | return
46 | }
47 |
48 | conf, _ := config.GetConfigCached()
49 |
50 | err = tmpl.Execute(writer, struct {
51 | EmptyUser bool // 未填写用户名和密码
52 | }{
53 | EmptyUser: conf.Username == "" && conf.Password == "",
54 | })
55 | if err != nil {
56 | fmt.Println("Error happened..")
57 | fmt.Println(err)
58 | }
59 | }
60 |
61 | // LoginFunc login func
62 | func LoginFunc(w http.ResponseWriter, r *http.Request) {
63 | accept := r.Header.Get("Accept-Language")
64 | util.InitLogLang(accept)
65 |
66 | if ld.failedTimes >= 5 {
67 | lockMinute := loginUnlock()
68 | returnError(w, util.LogStr("登录失败次数过多,请等待 %d 分钟后再试", lockMinute))
69 | return
70 | }
71 |
72 | // 从请求中读取 JSON 数据
73 | var data struct {
74 | Username string `json:"Username"`
75 | Password string `json:"Password"`
76 | }
77 |
78 | err := json.NewDecoder(r.Body).Decode(&data)
79 |
80 | if err != nil {
81 | returnError(w, err.Error())
82 | return
83 | }
84 |
85 | // 用户名密码不能为空
86 | if data.Username == "" || data.Password == "" {
87 | returnError(w, util.LogStr("必须输入用户名/密码"))
88 | return
89 | }
90 |
91 | conf, _ := config.GetConfigCached()
92 |
93 | // 初始化用户名密码
94 | if conf.Username == "" && conf.Password == "" {
95 | if time.Since(startTime) > saveLimit {
96 | returnError(w, util.LogStr("需在 %s 之前完成用户名密码设置,请重启ddns-go", startTime.Add(saveLimit).Format("2006-01-02 15:04:05")))
97 | return
98 | }
99 |
100 | conf.NotAllowWanAccess = true
101 | u, err := url.Parse(r.Header.Get("referer"))
102 | if err == nil && !util.IsPrivateNetwork(u.Host) {
103 | conf.NotAllowWanAccess = false
104 | }
105 |
106 | conf.Username = data.Username
107 | hashedPwd, err := conf.CheckPassword(data.Password)
108 | if err != nil {
109 | returnError(w, err.Error())
110 | return
111 | }
112 | conf.Password = hashedPwd
113 | conf.SaveConfig()
114 | }
115 |
116 | // 登录
117 | if data.Username == conf.Username && util.PasswordOK(conf.Password, data.Password) {
118 | ld.ticker.Stop()
119 | ld.failedTimes = 0
120 |
121 | // 设置cookie过期时间为1天
122 | timeoutDays := 1
123 | if conf.NotAllowWanAccess {
124 | // 内网访问cookie过期时间为30天
125 | timeoutDays = 30
126 | }
127 |
128 | // 覆盖cookie
129 | cookieInSystem = &http.Cookie{
130 | Name: cookieName,
131 | Value: util.GenerateToken(data.Username), // 生成token
132 | Path: "/",
133 | Expires: time.Now().AddDate(0, 0, timeoutDays), // 设置过期时间
134 | HttpOnly: true,
135 | }
136 | // 写入cookie
137 | http.SetCookie(w, cookieInSystem)
138 |
139 | util.Log("%q 登录成功", util.GetRequestIPStr(r))
140 |
141 | returnOK(w, util.LogStr("登录成功"), cookieInSystem.Value)
142 | return
143 | }
144 |
145 | ld.failedTimes = ld.failedTimes + 1
146 | util.Log("%q 帐号密码不正确", util.GetRequestIPStr(r))
147 | returnError(w, util.LogStr("用户名或密码错误"))
148 | }
149 |
150 | // loginUnlock login unlock, return minute
151 | func loginUnlock() (minute uint32) {
152 | ld.failedTimes = ld.failedTimes + 1
153 | x := ld.failedTimes
154 | if x > 1440 {
155 | x = 1440 // 最多等待一天
156 | }
157 | ld.ticker.Reset(time.Duration(x) * time.Minute)
158 |
159 | go func(ticker *time.Ticker) {
160 | for range ticker.C {
161 | ld.failedTimes = 4
162 | ticker.Stop()
163 | }
164 | }(ld.ticker)
165 |
166 | return x
167 | }
168 |
--------------------------------------------------------------------------------
/web/login.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 | DDNS-GO
9 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
37 |
38 |
39 |
94 |
95 |
96 |
97 |
98 |
128 |
129 |
--------------------------------------------------------------------------------
/web/logout.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "net/http"
5 | "time"
6 | )
7 |
8 | func Logout(w http.ResponseWriter, r *http.Request) {
9 | // 覆盖cookieInSystem
10 | cookieInSystem = &http.Cookie{
11 | Name: cookieName,
12 | Value: "",
13 | Path: "/",
14 | Expires: time.Unix(0, 0), // 设置为过期时间
15 | MaxAge: -1, // 立即删除该 Cookie
16 | HttpOnly: true,
17 | }
18 | // 设置过期的 Cookie
19 | http.SetCookie(w, cookieInSystem)
20 |
21 | // 重定向用户到登录页面
22 | http.Redirect(w, r, "./login", http.StatusFound)
23 | }
24 |
--------------------------------------------------------------------------------
/web/logs.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "encoding/json"
5 | "io"
6 | "log"
7 | "net/http"
8 | "os"
9 | )
10 |
11 | // MemoryLogs 内存中的日志
12 | type MemoryLogs struct {
13 | MaxNum int // 保存最大条数
14 | Logs []string // 日志
15 | }
16 |
17 | func (mlogs *MemoryLogs) Write(p []byte) (n int, err error) {
18 | mlogs.Logs = append(mlogs.Logs, string(p))
19 | // 处理日志数量
20 | if len(mlogs.Logs) > mlogs.MaxNum {
21 | mlogs.Logs = mlogs.Logs[len(mlogs.Logs)-mlogs.MaxNum:]
22 | }
23 | return len(p), nil
24 | }
25 |
26 | var mlogs = &MemoryLogs{MaxNum: 50}
27 |
28 | // 初始化日志
29 | func init() {
30 | log.SetOutput(io.MultiWriter(mlogs, os.Stdout))
31 | // log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)
32 | }
33 |
34 | // Logs web
35 | func Logs(writer http.ResponseWriter, request *http.Request) {
36 | // mlogs.Logs数组转为json
37 | logs, _ := json.Marshal(mlogs.Logs)
38 | writer.Write(logs)
39 | }
40 |
41 | // ClearLog
42 | func ClearLog(writer http.ResponseWriter, request *http.Request) {
43 | mlogs.Logs = mlogs.Logs[:0]
44 | }
45 |
--------------------------------------------------------------------------------
/web/return_json.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | )
7 |
8 | // Result Result
9 | type Result struct {
10 | Code int // 状态
11 | Msg string // 消息
12 | Data interface{} // 数据
13 | }
14 |
15 | // returnError 返回错误信息
16 | func returnError(w http.ResponseWriter, msg string) {
17 | result := &Result{}
18 |
19 | result.Code = http.StatusInternalServerError
20 | result.Msg = msg
21 |
22 | json.NewEncoder(w).Encode(result)
23 | }
24 |
25 | // returnOK 返回成功信息
26 | func returnOK(w http.ResponseWriter, msg string, data interface{}) {
27 | result := &Result{}
28 |
29 | result.Code = http.StatusOK
30 | result.Msg = msg
31 | result.Data = data
32 |
33 | json.NewEncoder(w).Encode(result)
34 | }
35 |
--------------------------------------------------------------------------------
/web/save.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 | "strings"
7 |
8 | "github.com/jeessy2/ddns-go/v6/config"
9 | "github.com/jeessy2/ddns-go/v6/dns"
10 | "github.com/jeessy2/ddns-go/v6/util"
11 | )
12 |
13 | // Save 保存
14 | func Save(writer http.ResponseWriter, request *http.Request) {
15 | result := checkAndSave(request)
16 | dnsConfJsonStr := "[]"
17 | if result == "ok" {
18 | conf, _ := config.GetConfigCached()
19 | dnsConfJsonStr = getDnsConfStr(conf.DnsConf)
20 | }
21 | byt, _ := json.Marshal(map[string]string{"result": result, "dnsConf": dnsConfJsonStr})
22 |
23 | writer.Write(byt)
24 | }
25 |
26 | func checkAndSave(request *http.Request) string {
27 | conf, _ := config.GetConfigCached()
28 |
29 | // 从请求中读取 JSON 数据
30 | var data struct {
31 | Username string `json:"Username"`
32 | Password string `json:"Password"`
33 | NotAllowWanAccess bool `json:"NotAllowWanAccess"`
34 | WebhookURL string `json:"WebhookURL"`
35 | WebhookRequestBody string `json:"WebhookRequestBody"`
36 | WebhookHeaders string `json:"WebhookHeaders"`
37 | DnsConf []dnsConf4JS `json:"DnsConf"`
38 | }
39 |
40 | // 解析请求中的 JSON 数据
41 | err := json.NewDecoder(request.Body).Decode(&data)
42 | if err != nil {
43 | return util.LogStr("数据解析失败, 请刷新页面重试")
44 | }
45 | usernameNew := strings.TrimSpace(data.Username)
46 | passwordNew := data.Password
47 |
48 | // 国际化
49 | accept := request.Header.Get("Accept-Language")
50 | conf.Lang = util.InitLogLang(accept)
51 |
52 | conf.NotAllowWanAccess = data.NotAllowWanAccess
53 | conf.WebhookURL = strings.TrimSpace(data.WebhookURL)
54 | conf.WebhookRequestBody = strings.TrimSpace(data.WebhookRequestBody)
55 | conf.WebhookHeaders = strings.TrimSpace(data.WebhookHeaders)
56 |
57 | // 如果新密码不为空则检查是否够强, 内/外网要求强度不同
58 | conf.Username = usernameNew
59 | if passwordNew != "" {
60 | hashedPwd, err := conf.CheckPassword(passwordNew)
61 | if err != nil {
62 | return err.Error()
63 | }
64 | conf.Password = hashedPwd
65 | }
66 |
67 | // 帐号密码不能为空
68 | if conf.Username == "" || conf.Password == "" {
69 | return util.LogStr("必须输入用户名/密码")
70 | }
71 |
72 | dnsConfFromJS := data.DnsConf
73 | var dnsConfArray []config.DnsConfig
74 | empty := dnsConf4JS{}
75 | for k, v := range dnsConfFromJS {
76 | if v == empty {
77 | continue
78 | }
79 | dnsConf := config.DnsConfig{Name: v.Name, TTL: v.TTL}
80 | // 覆盖以前的配置
81 | dnsConf.DNS.Name = v.DnsName
82 | dnsConf.DNS.ID = strings.TrimSpace(v.DnsID)
83 | dnsConf.DNS.Secret = strings.TrimSpace(v.DnsSecret)
84 |
85 | if v.Ipv4Domains == "" && v.Ipv6Domains == "" {
86 | util.Log("第 %s 个配置未填写域名", util.Ordinal(k+1, conf.Lang))
87 | }
88 |
89 | dnsConf.Ipv4.Enable = v.Ipv4Enable
90 | dnsConf.Ipv4.GetType = v.Ipv4GetType
91 | dnsConf.Ipv4.URL = strings.TrimSpace(v.Ipv4Url)
92 | dnsConf.Ipv4.NetInterface = v.Ipv4NetInterface
93 | dnsConf.Ipv4.Cmd = strings.TrimSpace(v.Ipv4Cmd)
94 | dnsConf.Ipv4.Domains = util.SplitLines(v.Ipv4Domains)
95 |
96 | dnsConf.Ipv6.Enable = v.Ipv6Enable
97 | dnsConf.Ipv6.GetType = v.Ipv6GetType
98 | dnsConf.Ipv6.URL = strings.TrimSpace(v.Ipv6Url)
99 | dnsConf.Ipv6.NetInterface = v.Ipv6NetInterface
100 | dnsConf.Ipv6.Cmd = strings.TrimSpace(v.Ipv6Cmd)
101 | dnsConf.Ipv6.Ipv6Reg = strings.TrimSpace(v.Ipv6Reg)
102 | dnsConf.Ipv6.Domains = util.SplitLines(v.Ipv6Domains)
103 |
104 | if k < len(conf.DnsConf) {
105 | c := &conf.DnsConf[k]
106 | idHide, secretHide := getHideIDSecret(c)
107 | if dnsConf.DNS.ID == idHide {
108 | dnsConf.DNS.ID = c.DNS.ID
109 | }
110 | if dnsConf.DNS.Secret == secretHide {
111 | dnsConf.DNS.Secret = c.DNS.Secret
112 | }
113 | }
114 |
115 | dnsConfArray = append(dnsConfArray, dnsConf)
116 | }
117 | conf.DnsConf = dnsConfArray
118 |
119 | // 保存到用户目录
120 | err = conf.SaveConfig()
121 |
122 | // 只运行一次
123 | util.ForceCompareGlobal = true
124 | go dns.RunOnce()
125 |
126 | // 回写错误信息
127 | if err != nil {
128 | return err.Error()
129 | }
130 | return "ok"
131 | }
132 |
--------------------------------------------------------------------------------
/web/webhookTest.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "encoding/json"
5 | "net/http"
6 |
7 | "github.com/jeessy2/ddns-go/v6/config"
8 | "github.com/jeessy2/ddns-go/v6/util"
9 | )
10 |
11 | func WebhookTest(writer http.ResponseWriter, request *http.Request) {
12 | var data struct {
13 | URL string `json:"URL"`
14 | RequestBody string `json:"RequestBody"`
15 | Headers string `json:"Headers"`
16 | }
17 | err := json.NewDecoder(request.Body).Decode(&data)
18 | if err != nil {
19 | util.Log("数据解析失败, 请刷新页面重试")
20 | return
21 | }
22 |
23 | url := data.URL
24 | requestBody := data.RequestBody
25 | headers := data.Headers
26 |
27 | if url == "" {
28 | util.Log("请输入Webhook的URL")
29 | return
30 | }
31 |
32 | var domains = make([]*config.Domain, 1)
33 | domains[0] = &config.Domain{}
34 | domains[0].DomainName = "example.com"
35 | domains[0].SubDomain = "test"
36 | domains[0].UpdateStatus = config.UpdatedSuccess
37 |
38 | fakeDomains := &config.Domains{
39 | Ipv4Addr: "127.0.0.1",
40 | Ipv4Domains: domains,
41 | Ipv6Addr: "::1",
42 | Ipv6Domains: domains,
43 | }
44 |
45 | fakeConfig := &config.Config{
46 | Webhook: config.Webhook{
47 | WebhookURL: url,
48 | WebhookRequestBody: requestBody,
49 | WebhookHeaders: headers,
50 | },
51 | }
52 |
53 | config.ExecWebhook(fakeDomains, fakeConfig)
54 | }
55 |
--------------------------------------------------------------------------------
/web/writing.go:
--------------------------------------------------------------------------------
1 | package web
2 |
3 | import (
4 | "embed"
5 | "encoding/json"
6 | "fmt"
7 | "html/template"
8 | "net/http"
9 | "os"
10 | "strings"
11 |
12 | "github.com/jeessy2/ddns-go/v6/config"
13 | )
14 |
15 | //go:embed writing.html
16 | var writingEmbedFile embed.FS
17 |
18 | const VersionEnv = "DDNS_GO_VERSION"
19 |
20 | // js中的dns配置
21 | type dnsConf4JS struct {
22 | Name string
23 | DnsName string
24 | DnsID string
25 | DnsSecret string
26 | TTL string
27 | Ipv4Enable bool
28 | Ipv4GetType string
29 | Ipv4Url string
30 | Ipv4NetInterface string
31 | Ipv4Cmd string
32 | Ipv4Domains string
33 | Ipv6Enable bool
34 | Ipv6GetType string
35 | Ipv6Url string
36 | Ipv6NetInterface string
37 | Ipv6Cmd string
38 | Ipv6Reg string
39 | Ipv6Domains string
40 | }
41 |
42 | // Writing 填写信息
43 | func Writing(writer http.ResponseWriter, request *http.Request) {
44 | tmpl, err := template.ParseFS(writingEmbedFile, "writing.html")
45 | if err != nil {
46 | fmt.Println("Error happened..")
47 | fmt.Println(err)
48 | return
49 | }
50 |
51 | conf, err := config.GetConfigCached()
52 | // 默认禁止公网访问
53 | if err != nil {
54 | conf.NotAllowWanAccess = true
55 | }
56 |
57 | ipv4, ipv6, _ := config.GetNetInterface()
58 |
59 | err = tmpl.Execute(writer, struct {
60 | DnsConf template.JS
61 | NotAllowWanAccess bool
62 | Username string
63 | config.Webhook
64 | Version string
65 | Ipv4 []config.NetInterface
66 | Ipv6 []config.NetInterface
67 | }{
68 | DnsConf: template.JS(getDnsConfStr(conf.DnsConf)),
69 | NotAllowWanAccess: conf.NotAllowWanAccess,
70 | Username: conf.User.Username,
71 | Webhook: conf.Webhook,
72 | Version: os.Getenv(VersionEnv),
73 | Ipv4: ipv4,
74 | Ipv6: ipv6,
75 | })
76 | if err != nil {
77 | fmt.Println("Error happened..")
78 | fmt.Println(err)
79 | }
80 | }
81 |
82 | func getDnsConfStr(dnsConf []config.DnsConfig) string {
83 | dnsConfArray := []dnsConf4JS{}
84 | for _, conf := range dnsConf {
85 | // 已存在配置文件,隐藏真实的ID、Secret
86 | idHide, secretHide := getHideIDSecret(&conf)
87 | dnsConfArray = append(dnsConfArray, dnsConf4JS{
88 | Name: conf.Name,
89 | DnsName: conf.DNS.Name,
90 | DnsID: idHide,
91 | DnsSecret: secretHide,
92 | TTL: conf.TTL,
93 | Ipv4Enable: conf.Ipv4.Enable,
94 | Ipv4GetType: conf.Ipv4.GetType,
95 | Ipv4Url: conf.Ipv4.URL,
96 | Ipv4NetInterface: conf.Ipv4.NetInterface,
97 | Ipv4Cmd: conf.Ipv4.Cmd,
98 | Ipv4Domains: strings.Join(conf.Ipv4.Domains, "\r\n"),
99 | Ipv6Enable: conf.Ipv6.Enable,
100 | Ipv6GetType: conf.Ipv6.GetType,
101 | Ipv6Url: conf.Ipv6.URL,
102 | Ipv6NetInterface: conf.Ipv6.NetInterface,
103 | Ipv6Cmd: conf.Ipv6.Cmd,
104 | Ipv6Reg: conf.Ipv6.Ipv6Reg,
105 | Ipv6Domains: strings.Join(conf.Ipv6.Domains, "\r\n"),
106 | })
107 | }
108 | byt, _ := json.Marshal(dnsConfArray)
109 | return string(byt)
110 | }
111 |
112 | // 显示的数量
113 | const displayCount int = 3
114 |
115 | // hideIDSecret 隐藏真实的ID、Secret
116 | func getHideIDSecret(conf *config.DnsConfig) (idHide string, secretHide string) {
117 | if len(conf.DNS.ID) > displayCount && conf.DNS.Name != "callback" {
118 | idHide = conf.DNS.ID[:displayCount] + strings.Repeat("*", len(conf.DNS.ID)-displayCount)
119 | } else {
120 | idHide = conf.DNS.ID
121 | }
122 | if len(conf.DNS.Secret) > displayCount && conf.DNS.Name != "callback" {
123 | secretHide = conf.DNS.Secret[:displayCount] + strings.Repeat("*", len(conf.DNS.Secret)-displayCount)
124 | } else {
125 | secretHide = conf.DNS.Secret
126 | }
127 | return
128 | }
129 |
--------------------------------------------------------------------------------