Repository: jeessy2/ddns-go Branch: master Commit: 345d436ede27 Files: 115 Total size: 369.7 KB Directory structure: gitextract_dlov5rlz/ ├── .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 ├── dns/ │ ├── alidns.go │ ├── aliesa.go │ ├── baidu.go │ ├── callback.go │ ├── cloudflare.go │ ├── dnsla.go │ ├── dnspod.go │ ├── dynadot.go │ ├── dynv6.go │ ├── edgeone.go │ ├── eranet.go │ ├── gcore.go │ ├── godaddy.go │ ├── huawei.go │ ├── index.go │ ├── name_com.go │ ├── namecheap.go │ ├── namesilo.go │ ├── nowcn.go │ ├── nsone.go │ ├── porkbun.go │ ├── rainyun.go │ ├── spaceship.go │ ├── tencent_cloud.go │ ├── traffic_route.go │ └── vercel.go ├── go.mod ├── main.go ├── static/ │ ├── 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 │ ├── osutil/ │ │ ├── daemon_unix.go │ │ └── daemon_win32.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 ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ # http://editorconfig.org root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.go] indent_style = tab indent_size = 2 [Dockerfile] indent_style = tab indent_size = 4 [Makefile] indent_style = tab indent_size = 4 [.travis.yml] indent_style = space indent_size = 2 [*.json] indent_style = space indent_size = 2 ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.yml ================================================ name: Bug description: Report a bug in ddns-go labels: ['bug'] body: - type: textarea attributes: label: Description description: A clear and concise description of what the bug is validations: required: true - type: dropdown attributes: label: DNS Provider description: The DNS provider you are using multiple: true options: - 阿里云 - 腾讯云 - DnsPod - Cloudflare - 华为云 - Callback - 百度云 - Porkbun - GoDaddy - Namecheap - NameSilo - Vercel - Dynadot - Others - type: dropdown attributes: label: Did you search for similar issues before submitting this one? options: - No, I didn't - Yes, I did, but I didn't find anything useful validations: required: true - type: dropdown attributes: label: Operating System description: The operating system you are running ddns-go on options: - Linux - Windows - macOS (Darwin) - FreeBSD validations: required: true - type: dropdown attributes: label: Architecture description: The architecture you are running ddns-go on options: - i386 - x86_64 - armv5 - armv6 - armv7 - arm64 - mips - mipsle - mips64 - mips64le validations: required: true - type: input attributes: label: Version description: The version of ddns-go you are using placeholder: v0.0.1 validations: required: true - type: dropdown attributes: label: How are you running ddns-go? options: - Docker - Service - Other validations: required: true - type: textarea attributes: label: Any other information description: | Please provide the steps to reproduce the bug. Or any other screenshots or logs that might help us understand the issue better. ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false contact_links: - name: None of the above? url: https://github.com/jeessy2/ddns-go/discussions about: If you have any other questions, please visit our Discussions page ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.yml ================================================ name: Feature Request description: Feature request for ddns-go labels: ['enhancement'] body: - type: textarea attributes: label: Description description: A clear and concise description of what the feature is validations: required: true - type: textarea attributes: label: Problem description: Describe the problem you are facing - type: textarea attributes: label: Other Description description: Any other information you would like to provide - type: checkboxes attributes: label: Checklist description: Please check the following before submitting your feature request options: - label: I am using the latest version and have confirmed that the feature is not yet implemented in the latest version required: true - label: I have searched for similar feature requests before submitting this one required: true ================================================ FILE: .github/dependabot.yml ================================================ # To get started with Dependabot version updates, you'll need to specify which # package ecosystems to update and where the package manifests are located. # Please see the documentation for all configuration options: # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates version: 2 updates: - package-ecosystem: "gomod" # Golang directory: "/" # Location of package manifests schedule: interval: "daily" - package-ecosystem: "github-actions" # GitHub Actions directory: "/" schedule: interval: "daily" ================================================ FILE: .github/pull_request_template.md ================================================ # What does this PR do? # Motivation # Additional Notes ================================================ FILE: .github/workflows/dockerhub-description.yml ================================================ name: Update Docker Hub Description on: push: branches: - master paths: - README.md - .github/workflows/dockerhub-description.yml jobs: dockerHubDescription: runs-on: ubuntu-latest if: github.repository == 'jeessy2/ddns-go' steps: - name: Checkout uses: actions/checkout@v6 - name: Docker Hub Description uses: peter-evans/dockerhub-description@v5 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} repository: ${{ secrets.DOCKER_USERNAME }}/ddns-go short-description: ${{ github.event.repository.description }} ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: # Sequence of patterns matched against refs/tags tags: - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10 permissions: contents: write packages: write jobs: goreleaser: name: Build runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: Set up QEMU uses: docker/setup-qemu-action@v4 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Login to Docker Hub uses: docker/login-action@v4 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GitHub Container Registry uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Run GoReleaser uses: goreleaser/goreleaser-action@v7 if: startsWith(github.ref, 'refs/tags/') with: distribution: goreleaser version: latest args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} ================================================ FILE: .github/workflows/stale.yml ================================================ name: Close stale issues and PRs on: schedule: - cron: "30 1 * * *" jobs: stale: permissions: issues: write # for actions/stale to close stale issues pull-requests: write # for actions/stale to close stale PRs runs-on: ubuntu-latest steps: - name: Stale uses: actions/stale@v10 with: 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.' 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.' close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.' close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.' exempt-issue-labels: 'bug,help wanted,question,documentation,keep' exempt-pr-labels: 'bug,help wanted,question,documentation,keep' days-before-stale: 30 days-before-close: 5 ================================================ FILE: .github/workflows/test.yml ================================================ name: test on: push: pull_request: jobs: test: runs-on: ubuntu-latest strategy: matrix: goarch: [amd64, arm64, riscv64] steps: - name: Checkout uses: actions/checkout@v6 - name: Set up Go uses: actions/setup-go@v6 with: go-version-file: 'go.mod' - name: Test run: | # Run tests only when GOARCH is amd64, otherwize run builds only. if [ "${{ matrix.goarch }}" = "amd64" ]; then make build test else GOARCH=${{ matrix.goarch }} make build fi - name: Upload artifact uses: actions/upload-artifact@v7 with: name: ddns-go_${{ matrix.goarch }} path: ddns-go ================================================ FILE: .gitignore ================================================ # Compiled Object files, Static and Dynamic libs (Shared Objects) *.o *.a *.so /ddns-go __* # Folders _obj _test .vagrant releases tmp /.idea/ vendor/ /dist # Architecture specific extensions/prefixes trace.out *.out .DS_Store _testmain.go *.exe *.test *.prof profile.cov coverage.html /go.sum # Emacs backup files *~ .*~ ================================================ FILE: .goreleaser.yml ================================================ # This is an example goreleaser.yaml file with some sane defaults. # Make sure to check the documentation at http://goreleaser.com version: 2 before: hooks: # You may remove this if you don't use go modules. - go mod download # you may remove this if you don't need go generate - go generate ./... builds: - env: - CGO_ENABLED=0 flags: - -trimpath goos: - android - linux - windows - darwin - freebsd goarch: - '386' - amd64 - arm - arm64 - mips - mipsle - mips64 - mips64le - riscv64 goarm: - '5' - '6' - '7' gomips: - hardfloat - softfloat ignore: # we only need the arm64 build on android - goos: android goarch: arm - goos: android goarch: '386' - goos: android goarch: amd64 ldflags: - -s -w -X main.version={{.Tag}} -X main.buildTime={{.Date}} hooks: post: - sh -c 'test -d zoneinfo || cp -r /usr/share/zoneinfo .' archives: # use zip for windows archives - format_overrides: - goos: windows format: zip # this name template makes the OS and Arch compatible with the results of uname. name_template: >- {{ .ProjectName }}_ {{- .Version }}_ {{- .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Mips }}_{{ .Mips }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} checksum: name_template: 'checksums.txt' snapshot: version_template: "{{ incpatch .Version }}-devel" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' dockers: - image_templates: - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64" use: buildx extra_files: - zoneinfo build_flag_templates: - "--platform=linux/amd64" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.version={{.Version}}" - image_templates: - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64" use: buildx extra_files: - zoneinfo build_flag_templates: - "--platform=linux/arm64" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.version={{.Version}}" goarch: arm64 - image_templates: - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7" use: buildx extra_files: - zoneinfo build_flag_templates: - "--platform=linux/arm/v7" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.version={{.Version}}" goarch: arm goarm: 7 - image_templates: - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-riscv64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-riscv64" use: buildx extra_files: - zoneinfo build_flag_templates: - "--platform=linux/riscv64" - "--label=org.opencontainers.image.created={{.Date}}" - "--label=org.opencontainers.image.title={{.ProjectName}}" - "--label=org.opencontainers.image.revision={{.FullCommit}}" - "--label=org.opencontainers.image.version={{.Version}}" goarch: riscv64 docker_manifests: - name_template: "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}" image_templates: - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64" - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64" - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7" - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-riscv64" - name_template: "{{ .Env.DOCKER_USERNAME }}/ddns-go:latest" image_templates: - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64" - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64" - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7" - "{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-riscv64" - name_template: "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}" image_templates: - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-riscv64" - name_template: "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:latest" image_templates: - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7" - "ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-riscv64" ================================================ FILE: .vscode/launch.json ================================================ { // Use IntelliSense to learn about possible attributes. // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "name": "Launch", "type": "go", "request": "launch", "mode": "auto", "program": "${workspaceFolder}/main.go", "env": {}, "args": [] } ] } ================================================ FILE: Dockerfile ================================================ FROM alpine LABEL name=ddns-go LABEL url=https://github.com/jeessy2/ddns-go RUN apk add --no-cache curl grep WORKDIR /app COPY ddns-go /app/ COPY zoneinfo /usr/share/zoneinfo ENV TZ=Asia/Shanghai EXPOSE 9876 ENTRYPOINT ["/app/ddns-go"] CMD ["-l", ":9876", "-f", "300"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2020 jeessy Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ .PHONY: build clean test test-race # 如果找不到 tag 则使用 HEAD commit VERSION=$(shell git describe --tags `git rev-list --tags --max-count=1` 2>/dev/null || git rev-parse --short HEAD) BUILD_TIME=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") BIN=ddns-go DIR_SRC=. DOCKER_ENV=DOCKER_BUILDKIT=1 DOCKER=$(DOCKER_ENV) docker GO_ENV=CGO_ENABLED=0 GO_FLAGS=-ldflags="-X main.version=$(VERSION) -X 'main.buildTime=$(BUILD_TIME)' -extldflags -static -s -w" -trimpath GO=$(GO_ENV) $(shell which go) GOROOT=$(shell `which go` env GOROOT) GOPATH=$(shell `which go` env GOPATH) build: $(DIR_SRC)/main.go @$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC) build_docker_image: @$(DOCKER) build -f ./Dockerfile -t ddns-go:$(VERSION) . test: @$(GO) test ./... test-race: @$(GO) test -race ./... # clean all build result clean: @$(GO) clean ./... @rm -f $(BIN) @rm -rf ./dist/* ================================================ FILE: README.md ================================================ # DDNS-GO [![GitHub release](https://img.shields.io/github/release/jeessy2/ddns-go.svg?logo=github&style=flat-square) ![GitHub release downloads](https://img.shields.io/github/downloads/jeessy2/ddns-go/total?logo=github)](https://github.com/jeessy2/ddns-go/releases/latest) [![Go version](https://img.shields.io/github/go-mod/go-version/jeessy2/ddns-go)](https://github.com/jeessy2/ddns-go/blob/master/go.mod) [![](https://goreportcard.com/badge/github.com/jeessy2/ddns-go/v6)](https://goreportcard.com/report/github.com/jeessy2/ddns-go/v6) [![](https://img.shields.io/docker/image-size/jeessy/ddns-go)](https://registry.hub.docker.com/r/jeessy/ddns-go) [![](https://img.shields.io/docker/pulls/jeessy/ddns-go)](https://registry.hub.docker.com/r/jeessy/ddns-go) 中文 | [English](https://github.com/jeessy2/ddns-go/blob/master/README_EN.md) 自动获得你的公网 IPv4 或 IPv6 地址,并解析到对应的域名服务。 - [特性](#特性) - [系统中使用](#系统中使用) - [Docker中使用](#docker中使用) - [使用IPv6](#使用ipv6) - [Webhook](#webhook) - [Callback](#callback) - [界面](#界面) - [开发&自行编译](#开发自行编译) ## 特性 - 支持Mac、Windows、Linux系统,支持ARM、x86、RISC-V架构 - 支持的域名服务商 `阿里云` `阿里云 ESA` `腾讯云` `Dnspod` `Cloudflare` `华为云` `Callback` `百度云` `Porkbun` `GoDaddy` `Namecheap` `NameSilo` `Dynadot` `DNSLA` `时代互联` `Eranet` `Gcore` `IBM NS1 Connect` `雨云` - 支持接口/网卡/[命令](https://github.com/jeessy2/ddns-go/wiki/通过命令获取IP参考)获取IP - 支持以服务的方式运行 - 默认间隔5分钟同步一次 - 支持同时配置多个DNS服务商 - 支持多个域名同时解析 - 支持多级域名 - 网页中配置,简单又方便,默认勾选`禁止从公网访问` - 网页中方便快速查看最近50条日志 - 支持Webhook通知 - 支持TTL - 支持部分DNS服务商[传递自定义参数](https://github.com/jeessy2/ddns-go/wiki/传递自定义参数),实现地域解析/多IP等功能 > [!NOTE] > 建议在启用公网访问时,使用 Nginx 等反向代理软件启用 HTTPS 访问,以保证安全性。[FAQ](https://github.com/jeessy2/ddns-go/wiki/FAQ) ## 系统中使用 - 从 [Releases](https://github.com/jeessy2/ddns-go/releases) 下载并解压 ddns-go - 安装服务 - Mac/Linux: `sudo ./ddns-go -s install` - Win(以管理员打开cmd): `.\ddns-go.exe -s install` - 配置 - 打开浏览器并访问`http://localhost:9876`进行初始化配置 - [可选] 服务卸载 - Mac/Linux: `sudo ./ddns-go -s uninstall` - Win(以管理员打开cmd): `.\ddns-go.exe -s uninstall` - [可选] 支持安装带参数 - `-l` 监听地址 - `-f` 同步间隔时间(秒) - `-cacheTimes` 间隔N次与服务商比对 - `-c` 自定义配置文件路径 - `-noweb` 不启动web服务 - `-skipVerify` 跳过证书验证 - `-dns` 自定义 DNS 服务器 - `-resetPassword` 重置密码 - [可选] 参考示例 - 10分钟同步一次, 并指定了配置文件地址 ```bash ./ddns-go -s install -f 600 -c /Users/name/.ddns_go_config.yaml ``` - 每 10 秒检查一次本地 IP 变化, 每 30 分钟对比一下 IP 变化, 实现 IP 变化即时触发更新且不会被服务商限流, 如果使用接口获取IP, 需要注意接口限流 ```bash ./ddns-go -s install -f 10 -cacheTimes 180 ``` - 重置密码 ```bash ./ddns-go -resetPassword 123456 ./ddns-go -resetPassword 123456 -c /Users/name/.ddns_go_config.yaml ``` ## Docker中使用 - 挂载主机目录, 使用docker host模式。可把 `/opt/ddns-go` 替换为你主机任意目录, 配置文件为隐藏文件 ```bash docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go ``` - 打开浏览器并访问`http://Docker主机IP:9876`进行初始化配置 - [可选] 使用 `ghcr.io` 镜像 ```bash docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root ghcr.io/jeessy2/ddns-go ``` - [可选] 支持启动带参数 `-l`监听地址 `-f`间隔时间(秒) ```bash docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go -l :9877 -f 600 ``` - [可选] 不使用docker host模式 ```bash docker run -d --name ddns-go --restart=always -p 9876:9876 -v /opt/ddns-go:/root jeessy/ddns-go ``` - [可选] 重置密码 ```bash docker exec ddns-go ./ddns-go -resetPassword 123456 docker restart ddns-go ``` ## 使用IPv6 - 前提:你的电脑或终端能正常获取IPv6,并能正常访问IPv6 - Windows/Mac:推荐 [系统中使用](#系统中使用),Windows/Mac桌面版的docker不支持`--net=host` - 群晖: - 套件中心下载docker并打开 - 注册表中搜索`ddns-go`并下载 - 映像 -> 选择`jeessy/ddns-go` -> 启动 -> 高级设置 -> 网络中勾选`使用与 Docker Host 相同的网络`,高级设置中勾选`启动自动重新启动` - 在浏览器中打开`http://群晖IP:9876`,修改你的配置,成功 - Linux的x86或arm架构,推荐使用Docker的`--net=host`模式。参考 [Docker中使用](#Docker中使用) - 虚拟机中使用有可能正常获取IPv6,但不能正常访问IPv6 ## Webhook - 支持webhook, 域名更新成功或不成功时, 会回调填写的URL - 支持的变量 | 变量名 | 说明 | | ---- | ---- | | #{ipv4Addr} | 新的IPv4地址 | | #{ipv4Result} | IPv4地址更新结果: `未改变` `失败` `成功`| | #{ipv4Domains} | IPv4的域名,多个以`,`分割 | | #{ipv6Addr} | 新的IPv6地址 | | #{ipv6Result} | IPv6地址更新结果: `未改变` `失败` `成功`| | #{ipv6Domains} | IPv6的域名,多个以`,`分割 | - 如 RequestBody 为空则为 GET 请求,否则为 POST 请求 -
Server酱 ``` https://sctapi.ftqq.com/[SendKey].send?title=你的公网IP变了&desp=主人IPv4变了#{ipv4Addr},域名更新结果:#{ipv4Result} ``` -
Bark ``` https://api.day.app/[YOUR_KEY]/主人IPv4变了#{ipv4Addr},域名更新结果:#{ipv4Result} ```
-
钉钉 - 钉钉电脑端 -> 群设置 -> 智能群助手 -> 添加机器人 -> 自定义 - 只勾选 `自定义关键词`, 输入的关键字必须包含在RequestBody的content中, 如:`你的公网IP变了` - URL中输入钉钉给你的 `Webhook地址` - RequestBody中输入 ```json { "msgtype": "markdown", "markdown": { "title": "你的公网IP变了", "text": "#### 你的公网IP变了 \n - IPv4地址:#{ipv4Addr} \n - 域名更新结果:#{ipv4Result} \n" } } ```
-
飞书 - 飞书电脑端 -> 群设置 -> 添加机器人 -> 自定义机器人 - 安全设置只勾选 `自定义关键词`, 输入的关键字必须包含在RequestBody的content中, 如:`你的公网IP变了` - URL中输入飞书给你的 `Webhook地址` - RequestBody中输入 ```json { "msg_type": "post", "content": { "post": { "zh_cn": { "title": "你的公网IP变了", "content": [ [ { "tag": "text", "text": "IPv4地址:#{ipv4Addr}" } ], [ { "tag": "text", "text": "域名更新结果:#{ipv4Result}" } ] ] } } } } ```
-
Telegram [ddns-telegram-bot](https://github.com/WingLim/ddns-telegram-bot)
-
plusplus 推送加 - [获取token](https://www.pushplus.plus/push1.html) - URL中输入 `https://www.pushplus.plus/send` - RequestBody中输入 ```json { "token": "your token", "title": "你的公网IP变了", "content": "你的公网IP变了 \n - IPv4地址:#{ipv4Addr} \n - 域名更新结果:#{ipv4Result} \n" } ```
-
Discord - Discord任意客户端 -> 伺服器 -> 频道设置 -> 整合 -> 查看Webhook -> 新Webhook -> 复制Webhook网址 - URL中输入Discord复制的 `Webhook网址` - RequestBody中输入 ```json { "content": "域名 #{ipv4Domains} 动态解析 #{ipv4Result}.", "embeds": [ { "description": "#{ipv4Domains} 的动态解析 #{ipv4Result}, IP: #{ipv4Addr}", "color": 15258703, "author": { "name": "DDNS" }, "footer": { "text": "DDNS #{ipv4Result}" } } ] } ```
- [查看更多Webhook配置参考](https://github.com/jeessy2/ddns-go/issues/327) ## Callback - 通过自定义回调可支持更多的第三方DNS服务商 - 配置的域名有几行, 就会回调几次 - 支持的变量 | 变量名 | 说明 | | ---- | ---- | | #{ip} | 新的IPv4/IPv6地址 | | #{domain} | 当前域名 | | #{recordType} | 记录类型 `A`或`AAAA` | | #{ttl} | TTL | - 如 RequestBody 为空则为 GET 请求,否则为 POST 请求 - [Callback配置参考](https://github.com/jeessy2/ddns-go/wiki/Callback配置参考) ## 界面 ![screenshots](https://raw.githubusercontent.com/jeessy2/ddns-go/master/ddns-web.png) ## 开发&自行编译 - 如果喜欢从源代码编译自己的版本,可以使用本项目提供的 Makefile 构建 - 使用 `make build` 生成本地编译后的 `ddns-go` 可执行文件 - 使用 `make build_docker_image` 自行编译 Docker 镜像 ================================================ FILE: README_EN.md ================================================ # DDNS-GO [![GitHub release](https://img.shields.io/github/release/jeessy2/ddns-go.svg?logo=github&style=flat-square) ![GitHub release downloads](https://img.shields.io/github/downloads/jeessy2/ddns-go/total?logo=github)](https://github.com/jeessy2/ddns-go/releases/latest) [![Go version](https://img.shields.io/github/go-mod/go-version/jeessy2/ddns-go)](https://github.com/jeessy2/ddns-go/blob/master/go.mod) [![](https://goreportcard.com/badge/github.com/jeessy2/ddns-go/v6)](https://goreportcard.com/report/github.com/jeessy2/ddns-go/v6) [![](https://img.shields.io/docker/image-size/jeessy/ddns-go)](https://registry.hub.docker.com/r/jeessy/ddns-go) [![](https://img.shields.io/docker/pulls/jeessy/ddns-go)](https://registry.hub.docker.com/r/jeessy/ddns-go) [中文](https://github.com/jeessy2/ddns-go/blob/master/README.md) | English Automatically obtain your public IPv4 or IPv6 address and resolve it to the corresponding domain name service. - [Features](#Features) - [Use in system](#Use-in-system) - [Use in docker](#Use-in-docker) - [Webhook](#webhook) - [Callback](#callback) - [Web interfaces](#Web-interfaces) ## Features - Support Mac, Windows, Linux system, support ARM, x86, RISC-V architecture - Support domain service providers `Aliyun` `Aliyun ESA` `Tencent` `Dnspod` `Cloudflare` `Huawei` `Callback` `Baidu` `Porkbun` `GoDaddy` `Namecheap` `NameSilo` `Dynadot` `DNSLA` `Nowcn` `Eranet` `Gcore` `IBM NS1 Connect` `Rainyun` - Support interface / netcard / command to get IP - Support running as a service - Default interval is 5 minutes - Support configuring multiple DNS service providers at the same time - Support multiple domain name resolution at the same time - Support multi-level domain name - Configured on the web page, simple and convenient - In the web page, you can quickly view the latest 50 logs - Support Webhook notification - Support TTL - Support for some domain service providers to pass [custom parameters](https://github.com/jeessy2/ddns-go/wiki/传递自定义参数) to achieve multi-IP and other functions > [!NOTE] > If you enable public network access, it is recommended to use Nginx and other reverse proxy software to enable HTTPS access to ensure security. ## Use in system - Download and unzip ddns-go from [Releases](https://github.com/jeessy2/ddns-go/releases) - Run in service mode - Mac/Linux: `sudo ./ddns-go -s install` - Win(Run as administrator): `.\ddns-go.exe -s install` - Config - Please open the browser and visit `http://localhost:9876` for initial configuration - [Optional] Uninstall service - Mac/Linux: `sudo ./ddns-go -s uninstall` - Win(Run as administrator): `.\ddns-go.exe -s uninstall` - [Optional] Support installation with parameters - `-l` listen address - `-f` sync frequency(seconds) - `-cacheTimes` interval N times compared with service providers - `-c` custom configuration file path - `-noweb` does not start web service - `-skipVerify` skip certificate verification - `-dns` custom DNS server - `-resetPassword` reset password - [Optional] Examples - 10 minutes to synchronize once, and the configuration file address is specified ```bash ./ddns-go -s install -f 600 -c /Users/name/.ddns_go_config.yaml ``` - Every 10 seconds to check the local IP changes, every 30 minutes to compare the IP changes, to achieve IP changes immediately trigger updates and will not be limited by the service providers, if the use of api to obtain IP, need to pay attention to the api side of the flow limit ```bash ./ddns-go -s install -f 10 -cacheTimes 180 ``` - reset password ```bash ./ddns-go -resetPassword 123456 ``` ## Use in docker - Mount the host directory, use the docker host mode. You can replace `/opt/ddns-go` with any directory on your host, the configuration file is a hidden file ```bash docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go ``` - Please open the browser and visit `http://DOCKER_IP:9876` for initial configuration - [Optional] Use `ghcr.io` mirror ```bash docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root ghcr.io/jeessy2/ddns-go ``` - [Optional] Support startup with parameters `-l`listen address `-f`Sync frequency(seconds) ```bash docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go -l :9877 -f 600 ``` - [Optional] Without using docker host mode ```bash docker run -d --name ddns-go --restart=always -p 9876:9876 -v /opt/ddns-go:/root jeessy/ddns-go ``` - [Optional] Reset password ```bash docker exec ddns-go ./ddns-go -resetPassword 123456 docker restart ddns-go ``` ## Webhook - Support webhook, when the domain name is updated successfully or not, the URL filled in will be called back - Support variables | Variable name | Comments | | ---- | ---- | | #{ipv4Addr} | The new IPv4 | | #{ipv4Result} | IPv4 update result: `no changed` `success` `failed`| | #{ipv4Domains} | IPv4 domains,Split by `,` | | #{ipv6Addr} | The new IPv6 | | #{ipv6Result} | IPv6 update result: `no changed` `success` `failed`| | #{ipv6Domains} | IPv6 domains,Split by `,` | - If RequestBody is empty, it is a `GET` request, otherwise it is a `POST` request -
Telegram [ddns-telegram-bot](https://github.com/WingLim/ddns-telegram-bot)
-
Discord - Discord client -> Server -> Channel Settings -> Integration -> View Webhook -> New Webhook -> Copy Webhook URL - Input the `Webhook URL` copied from Discord in the URL - Input in RequestBody ```json { "content": "The domain name #{ipv4Domains} dynamically resolves to #{ipv4Result}.", "embeds": [ { "description": "Domains: #{ipv4Domains}, Result: #{ipv4Result}, IP: #{ipv4Addr}", "color": 15258703, "author": { "name": "DDNS" }, "footer": { "text": "DDNS #{ipv4Result}" } } ] } ```
- [More webhook configuration reference](https://github.com/jeessy2/ddns-go/issues/327) ## Callback - Support more third-party DNS service providers through custom callback - Callback will be called as many times as there are lines in the configured domain name - Support variables | Variable name | Comments | | ---- | ---- | | #{ip} | The new IPv4/IPv6 address| | #{domain} | Current domain | | #{recordType} | Record type `A` or `AAAA` | | #{ttl} | TTL | - If RequestBody is empty, it is a `GET` request, otherwise it is a `POST` request ## Web interfaces ![screenshots](https://raw.githubusercontent.com/jeessy2/ddns-go/master/ddns-web.png) ================================================ FILE: config/config.go ================================================ package config import ( "errors" "io" "log" "net/http" "os" "os/exec" "regexp" "runtime" "strconv" "strings" "sync" "github.com/jeessy2/ddns-go/v6/util" passwordvalidator "github.com/wagslane/go-password-validator" "gopkg.in/yaml.v3" ) // Ipv4Reg IPv4正则 var Ipv4Reg = regexp.MustCompile(`((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])`) // Ipv6Reg IPv6正则 var Ipv6Reg = regexp.MustCompile(`((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))`) // DnsConfig 配置 type DnsConfig struct { Name string Ipv4 struct { Enable bool // 获取IP类型 url/netInterface GetType string URL string NetInterface string Cmd string Domains []string } Ipv6 struct { Enable bool // 获取IP类型 url/netInterface GetType string URL string NetInterface string Cmd string Ipv6Reg string // ipv6匹配正则表达式 Domains []string } DNS DNS TTL string // 发送HTTP请求时使用的网卡名称,为空则使用默认网卡 HttpInterface string } // DNS DNS配置 type DNS struct { // 名称。如:alidns,webhook Name string ID string Secret string // ExtParam 扩展参数,用于某些DNS提供商的特殊需求(如Vercel的teamId) ExtParam string } type Config struct { DnsConf []DnsConfig User Webhook // 禁止公网访问 NotAllowWanAccess bool // 语言 Lang string } // ConfigCache ConfigCache type cacheType struct { ConfigSingle *Config Err error Lock sync.Mutex } var cache = &cacheType{} // GetConfigCached 获得缓存的配置 func GetConfigCached() (conf Config, err error) { cache.Lock.Lock() defer cache.Lock.Unlock() if cache.ConfigSingle != nil { return *cache.ConfigSingle, cache.Err } // init config cache.ConfigSingle = &Config{} configFilePath := util.GetConfigFilePath() _, err = os.Stat(configFilePath) if err != nil { cache.Err = err return *cache.ConfigSingle, err } byt, err := os.ReadFile(configFilePath) if err != nil { util.Log("异常信息: %s", err) cache.Err = err return *cache.ConfigSingle, err } err = yaml.Unmarshal(byt, cache.ConfigSingle) if err != nil { util.Log("异常信息: %s", err) cache.Err = err return *cache.ConfigSingle, err } // 未填写登录信息, 确保不能从公网访问 if cache.ConfigSingle.Username == "" && cache.ConfigSingle.Password == "" { cache.ConfigSingle.NotAllowWanAccess = true } // remove err cache.Err = nil return *cache.ConfigSingle, err } // CompatibleConfig 兼容之前的配置文件 func (conf *Config) CompatibleConfig() { // 如果之前密码不为空且不是bcrypt加密后的密码, 把密码加密并保存 if conf.Password != "" && !util.IsHashedPassword(conf.Password) { hashedPwd, err := util.HashPassword(conf.Password) if err == nil { conf.Password = hashedPwd conf.SaveConfig() } } // 兼容v5.0.0之前的配置文件 if len(conf.DnsConf) > 0 { return } configFilePath := util.GetConfigFilePath() _, err := os.Stat(configFilePath) if err != nil { return } byt, err := os.ReadFile(configFilePath) if err != nil { return } dnsConf := &DnsConfig{} err = yaml.Unmarshal(byt, dnsConf) if err != nil { return } if len(dnsConf.DNS.Name) > 0 { cache.Lock.Lock() defer cache.Lock.Unlock() conf.DnsConf = append(conf.DnsConf, *dnsConf) cache.ConfigSingle = conf } } // SaveConfig 保存配置 func (conf *Config) SaveConfig() (err error) { cache.Lock.Lock() defer cache.Lock.Unlock() byt, err := yaml.Marshal(conf) if err != nil { log.Println(err) return err } configFilePath := util.GetConfigFilePath() err = os.WriteFile(configFilePath, byt, 0600) if err != nil { log.Println(err) return } util.Log("配置文件已保存在: %s", configFilePath) // 清空配置缓存 cache.ConfigSingle = nil return } // 重置密码 func (conf *Config) ResetPassword(newPassword string) { // 初始化语言 util.InitLogLang(conf.Lang) // 先检查密码是否安全 hashedPwd, err := conf.CheckPassword(newPassword) if err != nil { util.Log(err.Error()) return } // 保存配置 conf.Password = hashedPwd conf.SaveConfig() util.Log("用户名 %s 的密码已重置成功! 请重启ddns-go", conf.Username) } // CheckPassword 检查密码 func (conf *Config) CheckPassword(newPassword string) (hashedPwd string, err error) { var minEntropyBits float64 = 30 if conf.NotAllowWanAccess { minEntropyBits = 25 } err = passwordvalidator.Validate(newPassword, minEntropyBits) if err != nil { return "", errors.New(util.LogStr("密码不安全!尝试使用更复杂的密码")) } // 加密密码 hashedPwd, err = util.HashPassword(newPassword) if err != nil { return "", errors.New(util.LogStr("异常信息: %s", err.Error())) } return } func (conf *DnsConfig) getIpv4AddrFromInterface() string { ipv4, _, err := GetNetInterface() if err != nil { util.Log("从网卡获得IPv4失败") return "" } for _, netInterface := range ipv4 { if netInterface.Name == conf.Ipv4.NetInterface && len(netInterface.Address) > 0 { return netInterface.Address[0] } } util.Log("从网卡中获得IPv4失败! 网卡名: %s", conf.Ipv4.NetInterface) return "" } func (conf *DnsConfig) getIpv4AddrFromUrl() string { client := util.CreateNoProxyHTTPClient("tcp4") urls := strings.Split(conf.Ipv4.URL, ",") for _, url := range urls { url = strings.TrimSpace(url) resp, err := client.Get(url) if err != nil { util.Log("通过接口获取IPv4失败! 接口地址: %s", url) util.Log("异常信息: %s", err) continue } defer resp.Body.Close() lr := io.LimitReader(resp.Body, 1024000) body, err := io.ReadAll(lr) if err != nil { util.Log("异常信息: %s", err) continue } result := Ipv4Reg.FindString(string(body)) if result == "" { util.Log("获取IPv4结果失败! 接口: %s ,返回值: %s", url, string(body)) } return result } return "" } func (conf *DnsConfig) getAddrFromCmd(addrType string) string { var cmd string var comp *regexp.Regexp if addrType == "IPv4" { cmd = conf.Ipv4.Cmd comp = Ipv4Reg } else { cmd = conf.Ipv6.Cmd comp = Ipv6Reg } // cmd is empty if cmd == "" { return "" } // run cmd with proper shell var execCmd *exec.Cmd if runtime.GOOS == "windows" { execCmd = exec.Command("powershell", "-Command", cmd) } else { // If Bash does not exist, use sh _, err := exec.LookPath("bash") if err != nil { execCmd = exec.Command("sh", "-c", cmd) } else { execCmd = exec.Command("bash", "-c", cmd) } } // run cmd out, err := execCmd.CombinedOutput() if err != nil { util.Log("获取%s结果失败! 未能成功执行命令:%s, 错误:%q, 退出状态码:%s", addrType, execCmd.String(), out, err) return "" } str := string(out) // get result result := comp.FindString(str) if result == "" { util.Log("获取%s结果失败! 命令: %s, 标准输出: %q", addrType, execCmd.String(), str) } return result } // GetIpv4Addr 获得IPv4地址 func (conf *DnsConfig) GetIpv4Addr() string { // 判断从哪里获取IP switch conf.Ipv4.GetType { case "netInterface": // 从网卡获取 IP return conf.getIpv4AddrFromInterface() case "url": // 从 URL 获取 IP return conf.getIpv4AddrFromUrl() case "cmd": // 从命令行获取 IP return conf.getAddrFromCmd("IPv4") default: log.Println("IPv4's get IP method is unknown") return "" // unknown type } } func (conf *DnsConfig) getIpv6AddrFromInterface() string { _, ipv6, err := GetNetInterface() if err != nil { util.Log("从网卡获得IPv6失败") return "" } for _, netInterface := range ipv6 { if netInterface.Name == conf.Ipv6.NetInterface && len(netInterface.Address) > 0 { if conf.Ipv6.Ipv6Reg != "" { // 匹配第几个IPv6 if match, err := regexp.MatchString("@\\d", conf.Ipv6.Ipv6Reg); err == nil && match { num, err := strconv.Atoi(conf.Ipv6.Ipv6Reg[1:]) if err == nil { if num > 0 { if num <= len(netInterface.Address) { return netInterface.Address[num-1] } util.Log("未找到第 %d 个IPv6地址! 将使用第一个IPv6地址", num) return netInterface.Address[0] } util.Log("IPv6匹配表达式 %s 不正确! 最小从1开始", conf.Ipv6.Ipv6Reg) return "" } } // 正则表达式匹配 util.Log("IPv6将使用正则表达式 %s 进行匹配", conf.Ipv6.Ipv6Reg) for i := 0; i < len(netInterface.Address); i++ { matched, err := regexp.MatchString(conf.Ipv6.Ipv6Reg, netInterface.Address[i]) if matched && err == nil { util.Log("匹配成功! 匹配到地址: %s", netInterface.Address[i]) return netInterface.Address[i] } } util.Log("没有匹配到任何一个IPv6地址, 将使用第一个地址") } return netInterface.Address[0] } } util.Log("从网卡中获得IPv6失败! 网卡名: %s", conf.Ipv6.NetInterface) return "" } func (conf *DnsConfig) getIpv6AddrFromUrl() string { client := util.CreateNoProxyHTTPClient("tcp6") urls := strings.Split(conf.Ipv6.URL, ",") for _, url := range urls { url = strings.TrimSpace(url) resp, err := client.Get(url) if err != nil { util.Log("通过接口获取IPv6失败! 接口地址: %s", url) util.Log("异常信息: %s", err) continue } defer resp.Body.Close() lr := io.LimitReader(resp.Body, 1024000) body, err := io.ReadAll(lr) if err != nil { util.Log("异常信息: %s", err) continue } result := Ipv6Reg.FindString(string(body)) if result == "" { util.Log("获取IPv6结果失败! 接口: %s ,返回值: %s", url, result) } return result } return "" } // GetIpv6Addr 获得IPv6地址 func (conf *DnsConfig) GetIpv6Addr() (result string) { // 判断从哪里获取IP switch conf.Ipv6.GetType { case "netInterface": // 从网卡获取 IP return conf.getIpv6AddrFromInterface() case "url": // 从 URL 获取 IP return conf.getIpv6AddrFromUrl() case "cmd": // 从命令行获取 IP return conf.getAddrFromCmd("IPv6") default: log.Println("IPv6's get IP method is unknown") return "" // unknown type } } // GetHTTPClient 获得HTTP客户端,如果配置了HttpInterface则绑定到指定网卡 func (conf *DnsConfig) GetHTTPClient() *http.Client { return util.CreateHTTPClientWithInterface(conf.HttpInterface) } ================================================ FILE: config/domains.go ================================================ package config import ( "net/url" "strings" "github.com/jeessy2/ddns-go/v6/util" "golang.org/x/net/idna" "golang.org/x/net/publicsuffix" ) // Domains Ipv4/Ipv6 domains type Domains struct { Ipv4Addr string Ipv4Cache *util.IpCache Ipv4Domains []*Domain Ipv6Addr string Ipv6Cache *util.IpCache Ipv6Domains []*Domain } // Domain 域名实体 type Domain struct { // DomainName 根域名 DomainName string // SubDomain 子域名 SubDomain string CustomParams string UpdateStatus updateStatusType // 更新状态 } // DomainTuples 域名元组映射 key: Domain.String() type DomainTuples map[string]*DomainTuple // DomainTuple 域名元组 type DomainTuple struct { RecordType string // Primary 首要域名 Domains[-1] = Primary Primary *Domain Domains []*Domain IpAddrs []string Ipv4Addr string Ipv6Addr string } // nontransitionalLookup implements the nontransitional processing as specified in // Unicode Technical Standard 46 with almost all checkings off to maximize user freedom. // // Copied from: https://github.com/cloudflare/cloudflare-go/blob/v0.97.0/dns.go#L95 var nontransitionalLookup = idna.New( idna.MapForLookup(), idna.StrictDomainName(false), idna.ValidateLabels(false), ) func (d Domain) String() string { if d.SubDomain != "" { return d.SubDomain + "." + d.DomainName } return d.DomainName } // GetFullDomain 获得全部的,子域名 func (d Domain) GetFullDomain() string { if d.SubDomain != "" { return d.SubDomain + "." + d.DomainName } return "@" + "." + d.DomainName } // GetSubDomain 获得子域名,为空返回@ // 阿里云/腾讯云/dnspod/GoDaddy/namecheap 需要 func (d Domain) GetSubDomain() string { if d.SubDomain != "" { return d.SubDomain } return "@" } // GetCustomParams not be nil func (d Domain) GetCustomParams() url.Values { if d.CustomParams != "" { q, err := url.ParseQuery(d.CustomParams) if err == nil { return q } } return url.Values{} } // ToASCII converts [Domain] to its ASCII form, // using non-transitional process specified in UTS 46. // // Note: conversion errors are silently discarded and partial conversion // results are used. func (d Domain) ToASCII() string { name, _ := nontransitionalLookup.ToASCII(d.String()) return name } // GetNewIp 接口/网卡/命令获得 ip 并校验用户输入的域名 func (domains *Domains) GetNewIp(dnsConf *DnsConfig) { domains.Ipv4Domains = checkParseDomains(dnsConf.Ipv4.Domains) domains.Ipv6Domains = checkParseDomains(dnsConf.Ipv6.Domains) // IPv4 if dnsConf.Ipv4.Enable && len(domains.Ipv4Domains) > 0 { ipv4Addr := dnsConf.GetIpv4Addr() if ipv4Addr != "" { domains.Ipv4Addr = ipv4Addr domains.Ipv4Cache.TimesFailedIP = 0 } else { // 启用IPv4 & 未获取到IP & 填写了域名 & 失败刚好3次,防止偶尔的网络连接失败,并且只发一次 domains.Ipv4Cache.TimesFailedIP++ if domains.Ipv4Cache.TimesFailedIP == 3 { domains.Ipv4Domains[0].UpdateStatus = UpdatedFailed } util.Log("未能获取IPv4地址, 将不会更新") } } // IPv6 if dnsConf.Ipv6.Enable && len(domains.Ipv6Domains) > 0 { ipv6Addr := dnsConf.GetIpv6Addr() if ipv6Addr != "" { domains.Ipv6Addr = ipv6Addr domains.Ipv6Cache.TimesFailedIP = 0 } else { // 启用IPv6 & 未获取到IP & 填写了域名 & 失败刚好3次,防止偶尔的网络连接失败,并且只发一次 domains.Ipv6Cache.TimesFailedIP++ if domains.Ipv6Cache.TimesFailedIP == 3 { domains.Ipv6Domains[0].UpdateStatus = UpdatedFailed } util.Log("未能获取IPv6地址, 将不会更新") } } } // checkParseDomains 校验并解析用户输入的域名 func checkParseDomains(domainArr []string) (domains []*Domain) { for _, domainStr := range domainArr { domainStr = strings.TrimSpace(domainStr) if domainStr == "" { continue } domain := &Domain{} // qp(queryParts) 从域名中提取自定义参数,如 baidu.com?q=1 => [baidu.com, q=1] qp := strings.Split(domainStr, "?") domainStr = qp[0] // dp(domainParts) 将域名(qp[0])分割为子域名与根域名,如 www:example.cn.eu.org => [www, example.cn.eu.org] dp := strings.Split(domainStr, ":") switch len(dp) { case 1: // 不使用冒号分割,自动识别域名 domainName, err := publicsuffix.EffectiveTLDPlusOne(domainStr) if err != nil { util.Log("域名: %s 不正确", domainStr) util.Log("异常信息: %s", err) continue } domain.DomainName = domainName domainLen := len(domainStr) - len(domainName) - 1 if domainLen > 0 { domain.SubDomain = domainStr[:domainLen] } case 2: // 使用冒号分隔,为 子域名:根域名 格式 sp := strings.Split(dp[1], ".") if len(sp) <= 1 { util.Log("域名: %s 不正确", domainStr) continue } domain.DomainName = dp[1] domain.SubDomain = dp[0] default: util.Log("域名: %s 不正确", domainStr) continue } // 参数条件 if len(qp) == 2 { u, err := url.Parse("https://baidu.com?" + qp[1]) if err != nil { util.Log("域名: %s 解析失败", domainStr) continue } domain.CustomParams = u.Query().Encode() } domains = append(domains, domain) } return } // GetNewIpResult 获得GetNewIp结果 func (domains *Domains) GetNewIpResult(recordType string) (ipAddr string, retDomains []*Domain) { if recordType == "AAAA" { if domains.Ipv6Cache.Check(domains.Ipv6Addr) { return domains.Ipv6Addr, domains.Ipv6Domains } else { util.Log("IPv6未改变, 将等待 %d 次后与DNS服务商进行比对", domains.Ipv6Cache.Times) return "", domains.Ipv6Domains } } // IPv4 if domains.Ipv4Cache.Check(domains.Ipv4Addr) { return domains.Ipv4Addr, domains.Ipv4Domains } else { util.Log("IPv4未改变, 将等待 %d 次后与DNS服务商进行比对", domains.Ipv4Cache.Times) return "", domains.Ipv4Domains } } // GetAllNewIpResult 获得getNewIp结果 func (domains *Domains) GetAllNewIpResult(multiRecordType string) (results DomainTuples) { ipv4Addr, ipv4Domains := domains.GetNewIpResult("A") ipv6Addr, ipv6Domains := domains.GetNewIpResult("AAAA") if ipv4Addr == "" && ipv6Addr == "" { return } cap := 0 if ipv4Addr != "" { cap += len(ipv4Domains) } if ipv6Addr != "" { cap += len(ipv6Domains) } results = make(DomainTuples, cap) results.append(ipv4Addr, ipv4Domains, multiRecordType, DomainTuple{RecordType: "A", Ipv4Addr: domains.Ipv4Addr, Ipv6Addr: domains.Ipv6Addr}) results.append(ipv6Addr, ipv6Domains, multiRecordType, DomainTuple{RecordType: "AAAA", Ipv4Addr: domains.Ipv4Addr, Ipv6Addr: domains.Ipv6Addr}) return } // append 添加域名到域名元组映射 func (domains DomainTuples) append(ipAddr string, retDomains []*Domain, multiRecordType string, template DomainTuple) { if ipAddr == "" { return } for _, domain := range retDomains { domainStr := domain.String() if tuple, ok := domains[domainStr]; ok { if tuple.RecordType != template.RecordType { tuple.RecordType = multiRecordType } tuple.Primary = domain tuple.Domains = append(tuple.Domains, domain) tuple.IpAddrs = append(tuple.IpAddrs, ipAddr) } else { tuple := template domains[domainStr] = &tuple tuple.Primary = domain tuple.Domains = []*Domain{domain} tuple.IpAddrs = []string{ipAddr} } } } // SetUpdateStatus 设置更新状态 func (d *DomainTuple) SetUpdateStatus(status updateStatusType) { if d.Primary.UpdateStatus == status { return } for _, domain := range d.Domains { domain.UpdateStatus = status } } // GetIpAddrPool 设置更新状态 func (d *DomainTuple) GetIpAddrPool(separator string) (result string) { s := d.Primary.GetCustomParams().Get("IpAddrPool") if len(s) != 0 { return strings.NewReplacer( "{ipv4Addr}", d.Ipv4Addr, "{ipv6Addr}", d.Ipv6Addr, ).Replace(s) } switch d.RecordType { case "A": return d.Ipv4Addr case "AAAA": return d.Ipv6Addr default: return d.Ipv4Addr + separator + d.Ipv6Addr } } ================================================ FILE: config/domains_test.go ================================================ package config import "testing" // TestToASCII test converts the name of [Domain] to its ASCII form. // // Copied from: https://github.com/cloudflare/cloudflare-go/blob/v0.97.0/dns_test.go#L15 func TestToASCII(t *testing.T) { tests := map[string]struct { domain string expected string }{ "empty": { "", "", }, "unicode get encoded": { "😺.com", "xn--138h.com", }, "unicode gets mapped and encoded": { "ÖBB.at", "xn--bb-eka.at", }, "punycode stays punycode": { "xn--138h.com", "xn--138h.com", }, "hyphens are not checked": { "s3--s4.com", "s3--s4.com", }, "STD3 rules are not enforced": { "℀.com", "a/c.com", }, "bidi check is disabled": { "englishﻋﺮﺑﻲ.com", "xn--english-gqjzfwd1j.com", }, "invalid joiners are allowed": { "a\u200cb.com", "xn--ab-j1t.com", }, "partial results are used despite errors": { "xn--:D.xn--.😺.com", "xn--:d..xn--138h.com", }, } for name, tt := range tests { t.Run(name, func(t *testing.T) { d := &Domain{DomainName: tt.domain} actual := d.ToASCII() if actual != tt.expected { t.Errorf("ToASCII() = %v, want %v", actual, tt.expected) } }) } } // TestParseDomainArr 测试 parseDomainArr func TestParseDomainArr(t *testing.T) { domains := []string{"mydomain.com", "test.mydomain.com", "test2.test.mydomain.com", "mydomain.com.mydomain.com", "mydomain.com.cn", "test.mydomain.com.cn", "test:mydomain.com.cn", "test.mydomain.com?Line=oversea&RecordId=123", "test.mydomain.com.cn?Line=oversea&RecordId=123", "test2:test.mydomain.com?Line=oversea&RecordId=123"} result := []Domain{ {DomainName: "mydomain.com", SubDomain: ""}, {DomainName: "mydomain.com", SubDomain: "test"}, {DomainName: "mydomain.com", SubDomain: "test2.test"}, {DomainName: "mydomain.com", SubDomain: "mydomain.com"}, {DomainName: "mydomain.com.cn", SubDomain: ""}, {DomainName: "mydomain.com.cn", SubDomain: "test"}, {DomainName: "mydomain.com.cn", SubDomain: "test"}, {DomainName: "mydomain.com", SubDomain: "test", CustomParams: "Line=oversea&RecordId=123"}, {DomainName: "mydomain.com.cn", SubDomain: "test", CustomParams: "Line=oversea&RecordId=123"}, {DomainName: "test.mydomain.com", SubDomain: "test2", CustomParams: "Line=oversea&RecordId=123"}, } parsedDomains := checkParseDomains(domains) for i := 0; i < len(parsedDomains); i++ { if parsedDomains[i].DomainName != result[i].DomainName || parsedDomains[i].SubDomain != result[i].SubDomain || parsedDomains[i].CustomParams != result[i].CustomParams { t.Errorf("解析 %s 失败:\n期待 DomainName:%s,得到 DomainName:%s\n期待 SubDomain:%s,得到 SubDomain:%s\n期待 CustomParams:%s,得到 CustomParams:%s", parsedDomains[i].String(), result[i].DomainName, parsedDomains[i].DomainName, result[i].SubDomain, parsedDomains[i].SubDomain, result[i].CustomParams, parsedDomains[i].CustomParams) } } } ================================================ FILE: config/netInterface.go ================================================ package config import ( "fmt" "net" ) // NetInterface 本机网络 type NetInterface struct { Name string Address []string } // GetNetInterface 获得网卡地址 // 返回ipv4, ipv6地址 func GetNetInterface() (ipv4NetInterfaces []NetInterface, ipv6NetInterfaces []NetInterface, err error) { allNetInterfaces, err := net.Interfaces() if err != nil { fmt.Println("net.Interfaces failed, err:", err.Error()) return ipv4NetInterfaces, ipv6NetInterfaces, err } // https://en.wikipedia.org/wiki/IPv6_address#General_allocation _, ipv6Unicast, _ := net.ParseCIDR("2000::/3") for i := 0; i < len(allNetInterfaces); i++ { if (allNetInterfaces[i].Flags & net.FlagUp) != 0 { addrs, _ := allNetInterfaces[i].Addrs() ipv4 := []string{} ipv6 := []string{} for _, address := range addrs { if ipnet, ok := address.(*net.IPNet); ok && ipnet.IP.IsGlobalUnicast() { _, bits := ipnet.Mask.Size() // 需匹配全局单播地址 if bits == 128 && ipv6Unicast.Contains(ipnet.IP) { ipv6 = append(ipv6, ipnet.IP.String()) } if bits == 32 { ipv4 = append(ipv4, ipnet.IP.String()) } } } if len(ipv4) > 0 { ipv4NetInterfaces = append( ipv4NetInterfaces, NetInterface{ Name: allNetInterfaces[i].Name, Address: ipv4, }, ) } if len(ipv6) > 0 { ipv6NetInterfaces = append( ipv6NetInterfaces, NetInterface{ Name: allNetInterfaces[i].Name, Address: ipv6, }, ) } } } return ipv4NetInterfaces, ipv6NetInterfaces, nil } ================================================ FILE: config/netInterface_test.go ================================================ package config import ( "testing" ) func TestGetNetInterface(t *testing.T) { ipv4NetInterfaces, ipv6NetInterfaces, err := GetNetInterface() if err != nil { t.Error(err) } t.Log(ipv4NetInterfaces, ipv6NetInterfaces) } ================================================ FILE: config/user.go ================================================ package config // User 登录用户 type User struct { Username string Password string } ================================================ FILE: config/webhook.go ================================================ package config import ( "encoding/json" "net/http" "net/url" "strings" "github.com/jeessy2/ddns-go/v6/util" ) // Webhook Webhook type Webhook struct { WebhookURL string WebhookRequestBody string WebhookHeaders string } // updateStatusType 更新状态 type updateStatusType string const ( // UpdatedNothing 未改变 UpdatedNothing updateStatusType = "未改变" // UpdatedFailed 更新失败 UpdatedFailed = "失败" // UpdatedSuccess 更新成功 UpdatedSuccess = "成功" ) // 更新失败次数 var updatedFailedTimes = 0 // hasJSONPrefix returns true if the string starts with a JSON open brace. func hasJSONPrefix(s string) bool { return strings.HasPrefix(s, "{") || strings.HasPrefix(s, "[") } // ExecWebhook 添加或更新IPv4/IPv6记录, 返回是否有更新失败的 func ExecWebhook(domains *Domains, conf *Config) (v4Status updateStatusType, v6Status updateStatusType) { v4Status = getDomainsStatus(domains.Ipv4Domains) v6Status = getDomainsStatus(domains.Ipv6Domains) if conf.WebhookURL != "" && (v4Status != UpdatedNothing || v6Status != UpdatedNothing) { // 第3次失败才触发一次webhook if v4Status == UpdatedFailed || v6Status == UpdatedFailed { updatedFailedTimes++ if updatedFailedTimes != 3 { util.Log("将不会触发Webhook, 仅在第 3 次失败时触发一次Webhook, 当前失败次数:%d", updatedFailedTimes) return } } else { updatedFailedTimes = 0 } // 成功和失败都要触发webhook method := "GET" postPara := "" contentType := "application/x-www-form-urlencoded" if conf.WebhookRequestBody != "" { method = "POST" postPara = replacePara(domains, conf.WebhookRequestBody, v4Status, v6Status) if json.Valid([]byte(postPara)) { contentType = "application/json" } else if hasJSONPrefix(postPara) { // 如果 RequestBody 的 JSON 无效但前缀为 JSON,提示无效 util.Log("Webhook中的 RequestBody JSON 无效") } } requestURL := replacePara(domains, conf.WebhookURL, v4Status, v6Status) u, err := url.Parse(requestURL) if err != nil { util.Log("Webhook配置中的URL不正确") return } q, _ := url.ParseQuery(u.RawQuery) u.RawQuery = q.Encode() req, err := http.NewRequest(method, u.String(), strings.NewReader(postPara)) if err != nil { util.Log("Webhook调用失败! 异常信息:%s", err) return } headers := extractHeaders(conf.WebhookHeaders) for key, value := range headers { req.Header.Add(key, value) } req.Header.Add("content-type", contentType) clt := util.CreateHTTPClient() resp, err := clt.Do(req) body, err := util.GetHTTPResponseOrg(resp, err) if err == nil { util.Log("Webhook调用成功! 返回数据:%s", string(body)) } else { util.Log("Webhook调用失败! 异常信息:%s", err) } } return } // getDomainsStatus 获取域名状态 func getDomainsStatus(domains []*Domain) updateStatusType { successNum := 0 for _, v46 := range domains { switch v46.UpdateStatus { case UpdatedFailed: // 一个失败,全部失败 return UpdatedFailed case UpdatedSuccess: successNum++ } } if successNum > 0 { // 迭代完成后一个成功,就成功 return UpdatedSuccess } return UpdatedNothing } // replacePara 替换参数 func replacePara(domains *Domains, orgPara string, ipv4Result updateStatusType, ipv6Result updateStatusType) string { return strings.NewReplacer( "#{ipv4Addr}", domains.Ipv4Addr, "#{ipv4Result}", util.LogStr(string(ipv4Result)), // i18n "#{ipv4Domains}", getDomainsStr(domains.Ipv4Domains), "#{ipv6Addr}", domains.Ipv6Addr, "#{ipv6Result}", util.LogStr(string(ipv6Result)), // i18n "#{ipv6Domains}", getDomainsStr(domains.Ipv6Domains), ).Replace(orgPara) } // getDomainsStr 用逗号分割域名 func getDomainsStr(domains []*Domain) string { str := "" for i, v46 := range domains { str += v46.String() if i != len(domains)-1 { str += "," } } return str } // extractHeaders converts s into a map of headers. // // See also: https://github.com/appleboy/gorush/blob/v1.17.0/notify/feedback.go#L15 func extractHeaders(s string) map[string]string { lines := util.SplitLines(s) headers := make(map[string]string, len(lines)) for _, line := range lines { line = strings.TrimSpace(line) if line == "" { continue } parts := strings.Split(line, ":") if len(parts) != 2 { util.Log("Webhook Header不正确: %s", line) continue } k, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]) headers[k] = v } return headers } ================================================ FILE: config/webhook_test.go ================================================ package config import ( "reflect" "testing" ) // TestExtractHeaders 测试 parseHeaderArr func TestExtractHeaders(t *testing.T) { input := ` a: foo b: bar` expected := map[string]string{ "a": "foo", "b": "bar", } parsedHeaders := extractHeaders(input) if !reflect.DeepEqual(parsedHeaders, expected) { t.Errorf("Expected %v, got %v", expected, parsedHeaders) } } ================================================ FILE: dns/alidns.go ================================================ package dns import ( "bytes" "net/http" "net/url" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( alidnsEndpoint string = "https://alidns.aliyuncs.com/" ) // https://help.aliyun.com/document_detail/29776.html?spm=a2c4g.11186623.6.672.715a45caji9dMA // Alidns Alidns type Alidns struct { DNS config.DNS Domains config.Domains TTL string httpClient *http.Client } // AlidnsRecord record type AlidnsRecord struct { DomainName string RecordID string Value string } // AlidnsSubDomainRecords 记录 type AlidnsSubDomainRecords struct { TotalCount int DomainRecords struct { Record []AlidnsRecord } } // AlidnsResp 修改/添加返回结果 type AlidnsResp struct { RecordID string RequestID string } // Init 初始化 func (ali *Alidns) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { ali.Domains.Ipv4Cache = ipv4cache ali.Domains.Ipv6Cache = ipv6cache ali.DNS = dnsConf.DNS ali.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s ali.TTL = "600" } else { ali.TTL = dnsConf.TTL } ali.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (ali *Alidns) AddUpdateDomainRecords() config.Domains { ali.addUpdateDomainRecords("A") ali.addUpdateDomainRecords("AAAA") return ali.Domains } func (ali *Alidns) addUpdateDomainRecords(recordType string) { ipAddr, domains := ali.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { var records AlidnsSubDomainRecords // 获取当前域名信息 params := domain.GetCustomParams() params.Set("Action", "DescribeSubDomainRecords") params.Set("DomainName", domain.DomainName) params.Set("SubDomain", domain.GetFullDomain()) params.Set("Type", recordType) err := ali.request(params, &records) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if records.TotalCount > 0 { // 默认第一个 recordSelected := records.DomainRecords.Record[0] if params.Has("RecordId") { for i := 0; i < len(records.DomainRecords.Record); i++ { if records.DomainRecords.Record[i].RecordID == params.Get("RecordId") { recordSelected = records.DomainRecords.Record[i] } } } // 存在,更新 ali.modify(recordSelected, domain, recordType, ipAddr) } else { // 不存在,创建 ali.create(domain, recordType, ipAddr) } } } // 创建 func (ali *Alidns) create(domain *config.Domain, recordType string, ipAddr string) { params := domain.GetCustomParams() params.Set("Action", "AddDomainRecord") params.Set("DomainName", domain.DomainName) params.Set("RR", domain.GetSubDomain()) params.Set("Type", recordType) params.Set("Value", ipAddr) params.Set("TTL", ali.TTL) var result AlidnsResp err := ali.request(params, &result) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if result.RecordID != "" { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, "返回RecordId为空") domain.UpdateStatus = config.UpdatedFailed } } // 修改 func (ali *Alidns) modify(recordSelected AlidnsRecord, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if recordSelected.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } params := domain.GetCustomParams() params.Set("Action", "UpdateDomainRecord") params.Set("RR", domain.GetSubDomain()) params.Set("RecordId", recordSelected.RecordID) params.Set("Type", recordType) params.Set("Value", ipAddr) params.Set("TTL", ali.TTL) var result AlidnsResp err := ali.request(params, &result) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if result.RecordID != "" { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, "返回RecordId为空") domain.UpdateStatus = config.UpdatedFailed } } // request 统一请求接口 func (ali *Alidns) request(params url.Values, result interface{}) (err error) { method := http.MethodGet util.AliyunSigner(ali.DNS.ID, ali.DNS.Secret, ¶ms, method, "2015-01-09") req, err := http.NewRequest( method, alidnsEndpoint, bytes.NewBuffer(nil), ) req.URL.RawQuery = params.Encode() if err != nil { return } client := ali.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/aliesa.go ================================================ package dns import ( "bytes" "encoding/json" "net/http" "net/url" "strconv" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( aliesaEndpoint string = "https://esa.cn-hangzhou.aliyuncs.com/" ) // Aliesa Aliesa type Aliesa struct { DNS config.DNS Domains config.Domains TTL string siteCache map[string]AliesaSite domainCache config.DomainTuples httpClient *http.Client } // AliesaSiteResp 站点返回结果 type AliesaSiteResp struct { TotalCount int Sites []AliesaSite } // AliesaSites 站点 type AliesaSite struct { SiteId int64 SiteName string AccessType string } // AliesaRecordResp 记录返回结果 type AliesaRecordResp struct { TotalCount int Records []AliesaRecord } // AliesaRecord 记录 type AliesaRecord struct { RecordId int64 RecordName string Data struct { Value string } } // AliesaResp 修改/添加返回结果 type AliesaResp struct { OriginPoolId int64 `json:"Id"` RecordID int64 RequestID string } // Init 初始化 func (ali *Aliesa) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { ali.Domains.Ipv4Cache = ipv4cache ali.Domains.Ipv6Cache = ipv6cache ali.DNS = dnsConf.DNS ali.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s ali.TTL = "600" } else { ali.TTL = dnsConf.TTL } ali.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (ali *Aliesa) AddUpdateDomainRecords() config.Domains { ali.siteCache = make(map[string]AliesaSite) ali.domainCache = ali.Domains.GetAllNewIpResult("A/AAAA") ali.addUpdateDomainRecords("A") ali.addUpdateDomainRecords("AAAA") ali.addUpdateDomainRecords("A/AAAA") return ali.Domains } func (ali *Aliesa) addUpdateDomainRecords(recordType string) { for _, domain := range ali.domainCache { if domain.RecordType != recordType { continue } // 获取站点 siteSelected, err := ali.getSite(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.SetUpdateStatus(config.UpdatedFailed) return } if siteSelected.SiteId == 0 { util.Log("在DNS服务商中未找到根域名: %s", domain.Primary.DomainName) domain.SetUpdateStatus(config.UpdatedFailed) return } // 处理源地址池 poolId, origins, err := ali.getOriginPool(siteSelected, domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.SetUpdateStatus(config.UpdatedFailed) return } // TODO:不允许相同ip if len(origins) != 0 { ali.updateOriginPool(siteSelected, domain, poolId, origins) return } // 获取记录 recordSelected, err := ali.getRecord(siteSelected, domain, "A/AAAA") if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.SetUpdateStatus(config.UpdatedFailed) return } if recordSelected.RecordId != 0 { // 存在,更新 ali.modify(recordSelected, domain, "A/AAAA") } else { // 不存在,创建 ali.create(siteSelected, domain, "A/AAAA") } } } // 创建 // https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord func (ali *Aliesa) create(site AliesaSite, domainTuple *config.DomainTuple, recordType string) { domain := domainTuple.Primary ipAddr := domainTuple.GetIpAddrPool(",") params := domain.GetCustomParams() params.Set("Action", "CreateRecord") params.Set("SiteId", strconv.FormatInt(site.SiteId, 10)) params.Set("RecordName", domain.String()) params.Set("Type", recordType) params.Set("Data", `{"Value":"`+ipAddr+`"}`) params.Set("Ttl", ali.TTL) // 兼容 CNAME 接入方式 if site.AccessType == "CNAME" && !params.Has("Proxied") { params.Set("Proxied", "true") } if params.Has("Proxied") && !params.Has("BizName") { params.Set("BizName", "web") } var result AliesaResp err := ali.request(http.MethodPost, params, &result) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domainTuple.SetUpdateStatus(config.UpdatedFailed) return } if result.RecordID != 0 { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domainTuple.SetUpdateStatus(config.UpdatedSuccess) } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, "返回RecordId为空") domainTuple.SetUpdateStatus(config.UpdatedFailed) } } // 修改 // https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-updaterecord func (ali *Aliesa) modify(record AliesaRecord, domainTuple *config.DomainTuple, recordType string) { domain := domainTuple.Primary ipAddr := domainTuple.GetIpAddrPool(",") // 相同不修改 if record.Data.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } params := domain.GetCustomParams() params.Set("Action", "UpdateRecord") params.Set("RecordId", strconv.FormatInt(record.RecordId, 10)) params.Set("Type", recordType) params.Set("Data", `{"Value":"`+ipAddr+`"}`) params.Set("Ttl", ali.TTL) var result AliesaResp err := ali.request(http.MethodPost, params, &result) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domainTuple.SetUpdateStatus(config.UpdatedFailed) return } // 不检查 result.RecordID ,更新成功也会返回 0 util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domainTuple.SetUpdateStatus(config.UpdatedSuccess) } // 获取当前域名信息 // https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listrecords func (ali *Aliesa) getRecord(site AliesaSite, domainTuple *config.DomainTuple, recordType string) (result AliesaRecord, err error) { domain := domainTuple.Primary var recordResp AliesaRecordResp params := url.Values{} params.Set("Action", "ListRecords") params.Set("SiteId", strconv.FormatInt(site.SiteId, 10)) params.Set("RecordName", domain.String()) params.Set("Type", recordType) err = ali.request(http.MethodGet, params, &recordResp) // recordResp.TotalCount == 0 if len(recordResp.Records) == 0 { return } // 指定 RecordId recordId := domain.GetCustomParams().Get("RecordId") if recordId != "" { for i := 0; i < len(recordResp.Records); i++ { if strconv.FormatInt(recordResp.Records[i].RecordId, 10) == recordId { return recordResp.Records[i], nil } } } return recordResp.Records[0], nil } // 获取域名的站点信息 // https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listsites func (ali *Aliesa) getSite(domainTuple *config.DomainTuple) (result AliesaSite, err error) { domain := domainTuple.Primary if site, ok := ali.siteCache[domain.DomainName]; ok { return site, nil } // 解析自定义参数 SiteId,但不使用 api GetSite 查询 siteIdStr := domain.GetCustomParams().Get("SiteId") if siteId, _ := strconv.ParseInt(siteIdStr, 10, 64); siteId != 0 { // 兼容 CNAME 接入方式 result.AccessType = "CNAME" result.SiteName = domain.DomainName result.SiteId = siteId return } var siteResp AliesaSiteResp params := url.Values{} params.Set("Action", "ListSites") params.Set("SiteName", domain.DomainName) err = ali.request(http.MethodGet, params, &siteResp) if err != nil { return } // siteResp.TotalCount == 0 if len(siteResp.Sites) == 0 { return } result = siteResp.Sites[0] ali.siteCache[domain.DomainName] = result return } // getOriginPool 获取源地址池 // https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listoriginpools func (ali *Aliesa) getOriginPool(site AliesaSite, domainTuple *config.DomainTuple) (id int64, origins []map[string]interface{}, err error) { name, found := strings.CutSuffix(domainTuple.Primary.SubDomain, ".origin-pool") if !found { return } params := url.Values{} params.Set("Action", "ListOriginPools") params.Set("SiteId", strconv.FormatInt(site.SiteId, 10)) params.Set("Name", name) params.Set("MatchType", "exact") result := struct { TotalCount int OriginPools []struct { Id int64 Origins []map[string]interface{} } }{} err = ali.request(http.MethodGet, params, &result) if err == nil && len(result.OriginPools) > 0 { pool := result.OriginPools[0] id = pool.Id origins = pool.Origins } return } // updateOriginPool 更新源地址池 // https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-updateoriginpool func (ali *Aliesa) updateOriginPool(site AliesaSite, domainTuple *config.DomainTuple, id int64, origins []map[string]interface{}) { needUpdate := false count := len(domainTuple.Domains) for _, origin := range origins { // 源地址池不能有多个相同地址,因此 Domain 更少放内层 for i, d := range domainTuple.Domains { name := d.GetCustomParams().Get("Name") if origin["Name"] != name { continue } // 相同不修改 address := domainTuple.IpAddrs[i] if origin["Address"] != address { origin["Address"] = address needUpdate = true } count-- break } } domain := domainTuple.Primary ipAddr := domainTuple.GetIpAddrPool(",") if count > 0 { // 有新增的源地址 util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, "不支持新增源地址") domainTuple.SetUpdateStatus(config.UpdatedFailed) return } if !needUpdate { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } originsData, _ := json.Marshal(origins) params := url.Values{} params.Set("Action", "UpdateOriginPool") params.Set("SiteId", strconv.FormatInt(site.SiteId, 10)) params.Set("Id", strconv.FormatInt(id, 10)) params.Set("Origins", string(originsData)) result := AliesaResp{} err := ali.request(http.MethodPost, params, &result) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domainTuple.SetUpdateStatus(config.UpdatedFailed) return } if result.OriginPoolId != 0 { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domainTuple.SetUpdateStatus(config.UpdatedSuccess) } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, "返回 OriginPool Id为空") domainTuple.SetUpdateStatus(config.UpdatedFailed) } } // request 统一请求接口 func (ali *Aliesa) request(method string, params url.Values, result interface{}) (err error) { util.AliyunSigner(ali.DNS.ID, ali.DNS.Secret, ¶ms, method, "2024-09-10") req, err := http.NewRequest( method, aliesaEndpoint, bytes.NewBuffer(nil), ) req.URL.RawQuery = params.Encode() if err != nil { return } client := ali.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/baidu.go ================================================ package dns import ( "bytes" "encoding/json" "net/http" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) // https://cloud.baidu.com/doc/BCD/s/4jwvymhs7 const ( baiduEndpoint = "https://bcd.baidubce.com" ) type BaiduCloud struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // BaiduRecord 单条解析记录 type BaiduRecord struct { RecordId uint `json:"recordId"` Domain string `json:"domain"` View string `json:"view"` Rdtype string `json:"rdtype"` TTL int `json:"ttl"` Rdata string `json:"rdata"` ZoneName string `json:"zoneName"` Status string `json:"status"` } // BaiduRecordsResp 获取解析列表拿到的结果 type BaiduRecordsResp struct { TotalCount int `json:"totalCount"` Result []BaiduRecord `json:"result"` } // BaiduListRequest 获取解析列表请求的body json type BaiduListRequest struct { Domain string `json:"domain"` PageNum int `json:"pageNum"` PageSize int `json:"pageSize"` } // BaiduModifyRequest 修改解析请求的body json type BaiduModifyRequest struct { RecordId uint `json:"recordId"` Domain string `json:"domain"` View string `json:"view"` RdType string `json:"rdType"` TTL int `json:"ttl"` Rdata string `json:"rdata"` ZoneName string `json:"zoneName"` } // BaiduCreateRequest 创建新解析请求的body json type BaiduCreateRequest struct { Domain string `json:"domain"` RdType string `json:"rdType"` TTL int `json:"ttl"` Rdata string `json:"rdata"` ZoneName string `json:"zoneName"` } func (baidu *BaiduCloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { baidu.Domains.Ipv4Cache = ipv4cache baidu.Domains.Ipv6Cache = ipv6cache baidu.DNS = dnsConf.DNS baidu.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认300s baidu.TTL = 300 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { baidu.TTL = 300 } else { baidu.TTL = ttl } } baidu.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (baidu *BaiduCloud) AddUpdateDomainRecords() config.Domains { baidu.addUpdateDomainRecords("A") baidu.addUpdateDomainRecords("AAAA") return baidu.Domains } func (baidu *BaiduCloud) addUpdateDomainRecords(recordType string) { ipAddr, domains := baidu.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { var records BaiduRecordsResp requestBody := BaiduListRequest{ Domain: domain.DomainName, PageNum: 1, PageSize: 1000, } err := baidu.request("POST", baiduEndpoint+"/v1/domain/resolve/list", requestBody, &records) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } find := false for _, record := range records.Result { if record.Domain == domain.GetSubDomain() { //存在就去更新 baidu.modify(record, domain, recordType, ipAddr) find = true break } } if !find { //没找到,去创建 baidu.create(domain, recordType, ipAddr) } } } // create 创建新的解析 func (baidu *BaiduCloud) create(domain *config.Domain, recordType string, ipAddr string) { var baiduCreateRequest = BaiduCreateRequest{ Domain: domain.GetSubDomain(), //处理一下@ RdType: recordType, TTL: baidu.TTL, Rdata: ipAddr, ZoneName: domain.DomainName, } var result BaiduRecordsResp err := baidu.request("POST", baiduEndpoint+"/v1/domain/resolve/add", baiduCreateRequest, &result) if err == nil { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } } // modify 更新解析 func (baidu *BaiduCloud) modify(record BaiduRecord, domain *config.Domain, rdType string, ipAddr string) { //没有变化直接跳过 if record.Rdata == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } var baiduModifyRequest = BaiduModifyRequest{ RecordId: record.RecordId, Domain: record.Domain, View: record.View, RdType: rdType, TTL: record.TTL, Rdata: ipAddr, ZoneName: record.ZoneName, } var result BaiduRecordsResp err := baidu.request("POST", baiduEndpoint+"/v1/domain/resolve/edit", baiduModifyRequest, &result) if err == nil { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } } // request 统一请求接口 func (baidu *BaiduCloud) request(method string, url string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( method, url, bytes.NewBuffer(jsonStr), ) if err != nil { return } util.BaiduSigner(baidu.DNS.ID, baidu.DNS.Secret, req) client := baidu.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/callback.go ================================================ package dns import ( "encoding/json" "fmt" "net/http" "net/url" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) type Callback struct { DNS config.DNS Domains config.Domains TTL string lastIpv4 string lastIpv6 string httpClient *http.Client } // Init 初始化 func (cb *Callback) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { cb.Domains.Ipv4Cache = ipv4cache cb.Domains.Ipv6Cache = ipv6cache cb.lastIpv4 = ipv4cache.Addr cb.lastIpv6 = ipv6cache.Addr cb.DNS = dnsConf.DNS cb.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600 cb.TTL = "600" } else { cb.TTL = dnsConf.TTL } cb.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (cb *Callback) AddUpdateDomainRecords() config.Domains { cb.addUpdateDomainRecords("A") cb.addUpdateDomainRecords("AAAA") return cb.Domains } func (cb *Callback) addUpdateDomainRecords(recordType string) { ipAddr, domains := cb.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } // 防止多次发送Webhook通知 if recordType == "A" { if cb.lastIpv4 == ipAddr { util.Log("你的IPv4未变化, 未触发 %s 请求", "Callback") return } } else { if cb.lastIpv6 == ipAddr { util.Log("你的IPv6未变化, 未触发 %s 请求", "Callback") return } } for _, domain := range domains { method := "GET" postPara := "" contentType := "application/x-www-form-urlencoded" if cb.DNS.Secret != "" { method = "POST" postPara = replacePara(cb.DNS.Secret, ipAddr, domain, recordType, cb.TTL) if json.Valid([]byte(postPara)) { contentType = "application/json" } } requestURL := replacePara(cb.DNS.ID, ipAddr, domain, recordType, cb.TTL) u, err := url.Parse(requestURL) if err != nil { util.Log("Callback的URL不正确") return } req, err := http.NewRequest(method, u.String(), strings.NewReader(postPara)) if err != nil { util.Log("异常信息: %s", err) domain.UpdateStatus = config.UpdatedFailed return } req.Header.Add("content-type", contentType) clt := util.CreateHTTPClient() resp, err := clt.Do(req) body, err := util.GetHTTPResponseOrg(resp, err) if err == nil { util.Log("Callback调用成功, 域名: %s, IP: %s, 返回数据: %s", domain, ipAddr, string(body)) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("Callback调用失败, 异常信息: %s", err) domain.UpdateStatus = config.UpdatedFailed } } } // replacePara 替换参数 func replacePara(orgPara, ipAddr string, domain *config.Domain, recordType string, ttl string) string { // params 使用 map 以便添加更多参数 params := map[string]string{ "ip": ipAddr, "domain": domain.String(), "recordType": recordType, "ttl": ttl, } // 也替换域名的自定义参数 for k, v := range domain.GetCustomParams() { if len(v) == 1 { params[k] = v[0] } } // 将 map 转换为 [NewReplacer] 所需的参数 // map 中的每个元素占用 2 个位置(kv),因此需要预留 2 倍的空间 oldnew := make([]string, 0, len(params)*2) for k, v := range params { k = fmt.Sprintf("#{%s}", k) oldnew = append(oldnew, k, v) } return strings.NewReplacer(oldnew...).Replace(orgPara) } ================================================ FILE: dns/cloudflare.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const zonesAPI = "https://api.cloudflare.com/client/v4/zones" // Cloudflare Cloudflare实现 type Cloudflare struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // CloudflareZonesResp cloudflare zones返回结果 type CloudflareZonesResp struct { CloudflareStatus Result []struct { ID string Name string Status string Paused bool } } // CloudflareRecordsResp records type CloudflareRecordsResp struct { CloudflareStatus Result []CloudflareRecord } // CloudflareRecord 记录实体 type CloudflareRecord struct { ID string `json:"id"` Name string `json:"name"` Type string `json:"type"` Content string `json:"content"` Proxied bool `json:"proxied"` TTL int `json:"ttl"` Comment string `json:"comment"` } // CloudflareStatus 公共状态 type CloudflareStatus struct { Success bool Messages []string } // Init 初始化 func (cf *Cloudflare) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { cf.Domains.Ipv4Cache = ipv4cache cf.Domains.Ipv6Cache = ipv6cache cf.DNS = dnsConf.DNS cf.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认1 auto ttl cf.TTL = 1 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { cf.TTL = 1 } else { cf.TTL = ttl } } cf.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (cf *Cloudflare) AddUpdateDomainRecords() config.Domains { cf.addUpdateDomainRecords("A") cf.addUpdateDomainRecords("AAAA") return cf.Domains } func (cf *Cloudflare) addUpdateDomainRecords(recordType string) { ipAddr, domains := cf.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { // get zone result, err := cf.getZones(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Result) == 0 { util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName) domain.UpdateStatus = config.UpdatedFailed return } params := url.Values{} params.Set("type", recordType) // The name of DNS records in Cloudflare API expects Punycode. // // See: cloudflare/cloudflare-go#690 params.Set("name", domain.ToASCII()) params.Set("per_page", "50") // Add a comment only if it exists if c := domain.GetCustomParams().Get("comment"); c != "" { params.Set("comment", c) } zoneID := result.Result[0].ID var records CloudflareRecordsResp // getDomains 最多更新前50条 err = cf.request( "GET", fmt.Sprintf(zonesAPI+"/%s/dns_records?%s", zoneID, params.Encode()), nil, &records, ) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if !records.Success { util.Log("查询域名信息发生异常! %s", strings.Join(records.Messages, ", ")) domain.UpdateStatus = config.UpdatedFailed return } if len(records.Result) > 0 { // 更新 cf.modify(records, zoneID, domain, ipAddr) } else { // 新增 cf.create(zoneID, domain, recordType, ipAddr) } } } // 创建 func (cf *Cloudflare) create(zoneID string, domain *config.Domain, recordType string, ipAddr string) { record := &CloudflareRecord{ Type: recordType, Name: domain.ToASCII(), Content: ipAddr, Proxied: false, TTL: cf.TTL, Comment: domain.GetCustomParams().Get("comment"), } record.Proxied = domain.GetCustomParams().Get("proxied") == "true" var status CloudflareStatus err := cf.request( "POST", fmt.Sprintf(zonesAPI+"/%s/dns_records", zoneID), record, &status, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Success { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, strings.Join(status.Messages, ", ")) domain.UpdateStatus = config.UpdatedFailed } } // 修改 func (cf *Cloudflare) modify(result CloudflareRecordsResp, zoneID string, domain *config.Domain, ipAddr string) { for _, record := range result.Result { // 相同不修改 if record.Content == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) continue } var status CloudflareStatus record.Content = ipAddr record.TTL = cf.TTL // 存在参数才修改proxied if domain.GetCustomParams().Has("proxied") { record.Proxied = domain.GetCustomParams().Get("proxied") == "true" } err := cf.request( "PUT", fmt.Sprintf(zonesAPI+"/%s/dns_records/%s", zoneID, record.ID), record, &status, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Success { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, strings.Join(status.Messages, ", ")) domain.UpdateStatus = config.UpdatedFailed } } } // 获得域名记录列表 func (cf *Cloudflare) getZones(domain *config.Domain) (result CloudflareZonesResp, err error) { params := url.Values{} params.Set("name", domain.DomainName) params.Set("status", "active") params.Set("per_page", "50") err = cf.request( "GET", fmt.Sprintf(zonesAPI+"?%s", params.Encode()), nil, &result, ) return } // request 统一请求接口 func (cf *Cloudflare) request(method string, url string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( method, url, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("Authorization", "Bearer "+cf.DNS.Secret) req.Header.Set("Content-Type", "application/json") client := cf.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/dnsla.go ================================================ package dns import ( "bytes" "encoding/base64" "encoding/json" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" "io" "net/http" "strconv" ) const ( recordList string = "http://api.dns.la/api/recordList" recordModify string = "http://api.dns.la/api/record" recordCreate string = "http://api.dns.la/api/record" ) // https://www.dns.la/docs/ApiDoc // dnsla dnsla实现 type Dnsla struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // DnslaRecord type DnslaRecord struct { ID string `json:"id"` Host string `json:"host"` Type int `json:"type"` Data string `json:"data"` } // DnslaRecordListResp recordListAPI结果 type DnslaRecordListResp struct { Code int `json:"code"` Msg string `json:"msg"` Data struct { Total int `json:"total"` Results []DnslaRecord `json:"results"` } `json:"data"` } // DnslaStatus DnslaStatus type DnslaStatus struct { Code int `json:"code"` Msg string `json:"msg"` Data struct { Id string `json:"id"` } `json:"data"` } // Init 初始化 func (dnsla *Dnsla) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { dnsla.Domains.Ipv4Cache = ipv4cache dnsla.Domains.Ipv6Cache = ipv6cache dnsla.DNS = dnsConf.DNS dnsla.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s dnsla.TTL = 600 } else { ttlInt, _ := strconv.Atoi(dnsConf.TTL) dnsla.TTL = ttlInt } dnsla.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (dnsla *Dnsla) AddUpdateDomainRecords() config.Domains { dnsla.addUpdateDomainRecords("A") dnsla.addUpdateDomainRecords("AAAA") return dnsla.Domains } func (dnsla *Dnsla) addUpdateDomainRecords(recordType string) { ipAddr, domains := dnsla.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { resultByte, err := dnsla.getRecordList(domain, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } var jsonResult DnslaRecordListResp errU := json.Unmarshal(resultByte, &jsonResult) if errU != nil { util.Log(errU.Error()) return } if jsonResult.Data.Total > 0 { // 默认第一个 recordSelected := jsonResult.Data.Results[0] params := domain.GetCustomParams() if params.Has("id") { for i := 0; i < len(jsonResult.Data.Results); i++ { if jsonResult.Data.Results[i].ID == params.Get("id") { recordSelected = jsonResult.Data.Results[i] } } } // 更新 dnsla.modify(recordSelected, domain, recordType, ipAddr) } else { // 新增 dnsla.create(domain, recordType, ipAddr) } } } // 创建 func (dnsla *Dnsla) create(domain *config.Domain, recordType string, ipAddr string) { recordTypeInt := 1 if recordType == "AAAA" { recordTypeInt = 28 } type CreateParams struct { Domain string `json:"Domain"` Host string `json:"Host"` Type int `json:"Type"` Data string `json:"Data"` TTL int `json:"TTL"` } createParams := CreateParams{ Domain: domain.DomainName, Host: domain.GetSubDomain(), Type: recordTypeInt, Data: ipAddr, TTL: dnsla.TTL, } jsonData, _ := json.Marshal(createParams) resultByte, err := dnsla.request("POST", recordCreate, jsonData) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } var jsonResult DnslaStatus errU := json.Unmarshal(resultByte, &jsonResult) if errU != nil { util.Log(errU.Error()) return } if jsonResult.Code == 200 { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, jsonResult.Msg) domain.UpdateStatus = config.UpdatedFailed } } // 修改 func (dnsla *Dnsla) modify(record DnslaRecord, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if record.Data == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } recordTypeInt := 1 if recordType == "AAAA" { recordTypeInt = 28 } type ModifyParams struct { ID string `json:"Id"` Host string `json:"Host"` Type int `json:"Type"` Data string `json:"Data"` TTL int `json:"TTL"` } modifyParams := ModifyParams{ ID: record.ID, Host: domain.GetSubDomain(), Type: recordTypeInt, Data: ipAddr, TTL: dnsla.TTL, } jsonData, _ := json.Marshal(modifyParams) resultByte, err := dnsla.request("PUT", recordModify, jsonData) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } var jsonResult DnslaStatus errU := json.Unmarshal(resultByte, &jsonResult) if errU != nil { util.Log(errU.Error()) return } if jsonResult.Code == 200 { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, jsonResult.Msg) domain.UpdateStatus = config.UpdatedFailed } } // request sends a POST request to the given API with the given values. func (dnsla *Dnsla) request(method, apiAddr string, values []byte) (body []byte, err error) { req, err := http.NewRequest( method, apiAddr, bytes.NewReader(values), ) if err != nil { panic(err) } // 设置自定义 Headers byteBuff := []byte(dnsla.DNS.ID + ":" + dnsla.DNS.Secret) token := "Basic " + base64.StdEncoding.EncodeToString(byteBuff) req.Header.Set("Authorization", token) req.Header.Set("Content-Type", "application/json;charset=utf-8") // 4. 发送请求 client := dnsla.httpClient resp, err := client.Do(req) if err != nil { panic(err) } defer resp.Body.Close() body, _ = io.ReadAll(resp.Body) return } // 获得域名记录列表 func (dnsla *Dnsla) getRecordList(domain *config.Domain, typ string) (result []byte, err error) { recordTypeInt := "1" if typ == "AAAA" { recordTypeInt = "28" } params := domain.GetCustomParams() params.Set("domain", domain.DomainName) params.Set("host", domain.GetSubDomain()) params.Set("type", recordTypeInt) params.Set("pageIndex", "1") params.Set("pageSize", "999") url := recordList + "?" + params.Encode() req, err := http.NewRequest("GET", url, nil) if err != nil { panic(err) } byteBuff := []byte(dnsla.DNS.ID + ":" + dnsla.DNS.Secret) token := "Basic " + base64.StdEncoding.EncodeToString(byteBuff) // 设置 Headers req.Header.Set("Authorization", token) // 发送请求 client := dnsla.httpClient resp, err := client.Do(req) if err != nil { panic(err) } defer resp.Body.Close() // 读取响应 result, errR := io.ReadAll(resp.Body) if errR != nil { util.Log(errR.Error()) return } return } ================================================ FILE: dns/dnspod.go ================================================ package dns import ( "net/http" "net/url" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( recordListAPI string = "https://dnsapi.cn/Record.List" recordModifyURL string = "https://dnsapi.cn/Record.Modify" recordCreateAPI string = "https://dnsapi.cn/Record.Create" ) // https://cloud.tencent.com/document/api/302/8516 // Dnspod 腾讯云dns实现 type Dnspod struct { DNS config.DNS Domains config.Domains TTL string httpClient *http.Client } // DnspodRecord DnspodRecord type DnspodRecord struct { ID string Name string Type string Value string Enabled string } // DnspodRecordListResp recordListAPI结果 type DnspodRecordListResp struct { DnspodStatus Records []DnspodRecord } // DnspodStatus DnspodStatus type DnspodStatus struct { Status struct { Code string Message string } } // Init 初始化 func (dnspod *Dnspod) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { dnspod.Domains.Ipv4Cache = ipv4cache dnspod.Domains.Ipv6Cache = ipv6cache dnspod.DNS = dnsConf.DNS dnspod.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s dnspod.TTL = "600" } else { dnspod.TTL = dnsConf.TTL } dnspod.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (dnspod *Dnspod) AddUpdateDomainRecords() config.Domains { dnspod.addUpdateDomainRecords("A") dnspod.addUpdateDomainRecords("AAAA") return dnspod.Domains } func (dnspod *Dnspod) addUpdateDomainRecords(recordType string) { ipAddr, domains := dnspod.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { result, err := dnspod.getRecordList(domain, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Records) > 0 { // 默认第一个 recordSelected := result.Records[0] params := domain.GetCustomParams() if params.Has("record_id") { for i := 0; i < len(result.Records); i++ { if result.Records[i].ID == params.Get("record_id") { recordSelected = result.Records[i] } } } // 更新 dnspod.modify(recordSelected, domain, recordType, ipAddr) } else { // 新增 dnspod.create(domain, recordType, ipAddr) } } } // 创建 func (dnspod *Dnspod) create(domain *config.Domain, recordType string, ipAddr string) { params := domain.GetCustomParams() params.Set("login_token", dnspod.DNS.ID+","+dnspod.DNS.Secret) params.Set("domain", domain.DomainName) params.Set("sub_domain", domain.GetSubDomain()) params.Set("record_type", recordType) params.Set("value", ipAddr) params.Set("ttl", dnspod.TTL) params.Set("format", "json") if !params.Has("record_line") { params.Set("record_line", "默认") } status, err := dnspod.request(recordCreateAPI, params) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Status.Code == "1" { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, status.Status.Message) domain.UpdateStatus = config.UpdatedFailed } } // 修改 func (dnspod *Dnspod) modify(record DnspodRecord, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if record.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } params := domain.GetCustomParams() params.Set("login_token", dnspod.DNS.ID+","+dnspod.DNS.Secret) params.Set("domain", domain.DomainName) params.Set("sub_domain", domain.GetSubDomain()) params.Set("record_type", recordType) params.Set("value", ipAddr) params.Set("ttl", dnspod.TTL) params.Set("format", "json") params.Set("record_id", record.ID) if !params.Has("record_line") { params.Set("record_line", "默认") } status, err := dnspod.request(recordModifyURL, params) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Status.Code == "1" { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, status.Status.Message) domain.UpdateStatus = config.UpdatedFailed } } // request sends a POST request to the given API with the given values. func (dnspod *Dnspod) request(apiAddr string, values url.Values) (status DnspodStatus, err error) { client := dnspod.httpClient resp, err := client.PostForm( apiAddr, values, ) err = util.GetHTTPResponse(resp, err, &status) return } // 获得域名记录列表 func (dnspod *Dnspod) getRecordList(domain *config.Domain, typ string) (result DnspodRecordListResp, err error) { params := domain.GetCustomParams() params.Set("login_token", dnspod.DNS.ID+","+dnspod.DNS.Secret) params.Set("domain", domain.DomainName) params.Set("record_type", typ) params.Set("sub_domain", domain.GetSubDomain()) params.Set("format", "json") client := dnspod.httpClient resp, err := client.PostForm( recordListAPI, params, ) err = util.GetHTTPResponse(resp, err, &result) return } ================================================ FILE: dns/dynadot.go ================================================ package dns import ( "bytes" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" "net/http" "net/url" "strconv" "strings" ) // https://www.dynadot.com/set_ddns const ( dynadotEndpoint string = "https://www.dynadot.com/set_ddns" ) // Dynadot Dynadot type Dynadot struct { DNS config.DNS Domains config.Domains TTL string LastIpv4 string LastIpv6 string httpClient *http.Client } // DynadotRecord record type DynadotRecord struct { DomainName string SubDomainNames []string CustomParams url.Values Domains []*config.Domain ContainRoot bool } // DynadotResp 修改/添加返回结果 type DynadotResp struct { Status string `json:"status"` ErrorCode int `json:"error_code"` Content []string `json:"content"` } // Init 初始化 func (dynadot *Dynadot) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { dynadot.Domains.Ipv4Cache = ipv4cache dynadot.Domains.Ipv6Cache = ipv6cache dynadot.LastIpv4 = ipv4cache.Addr dynadot.LastIpv6 = ipv6cache.Addr dynadot.DNS = dnsConf.DNS dynadot.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s dynadot.TTL = "600" } else { dynadot.TTL = dnsConf.TTL } dynadot.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (dynadot *Dynadot) AddUpdateDomainRecords() config.Domains { dynadot.addOrUpdateDomainRecords("A") dynadot.addOrUpdateDomainRecords("AAAA") return dynadot.Domains } // addOrUpdateDomainRecords 添加或更新记录 func (dynadot *Dynadot) addOrUpdateDomainRecords(recordType string) { ipAddr, domains := dynadot.Domains.GetNewIpResult(recordType) if len(ipAddr) == 0 { return } // 防止多次发送Webhook通知 if recordType == "A" { if dynadot.LastIpv4 == ipAddr { util.Log("你的IPv4未变化, 未触发 %s 请求", "dynadot") return } } else { if dynadot.LastIpv6 == ipAddr { util.Log("你的IPv6未变化, 未触发 %s 请求", "dynadot") return } } records := mergeDomains(domains) // dynadot 仅支持一个域名对应一个dynamic password if len(records) != 1 { util.Log("dynadot仅支持单域名配置,多个域名请添加更多配置") return } for _, record := range records { // 创建或更新 dynadot.createOrModify(record, recordType, ipAddr) } } // 合并域名的子域名 func mergeDomains(domains []*config.Domain) (records []*DynadotRecord) { records = make([]*DynadotRecord, 0) for _, domain := range domains { var record *DynadotRecord for _, r := range records { if r.DomainName == domain.DomainName { record = r params := domain.GetCustomParams() for key := range params { record.CustomParams.Add(key, params.Get(key)) } record.Domains = append(record.Domains, domain) record.SubDomainNames = append(record.SubDomainNames, domain.GetSubDomain()) break } } if record == nil { record = &DynadotRecord{ DomainName: domain.DomainName, CustomParams: domain.GetCustomParams(), Domains: []*config.Domain{domain}, SubDomainNames: []string{domain.GetSubDomain()}, } records = append(records, record) } if len(domain.SubDomain) == 0 { // 包含根域名 record.ContainRoot = true } } return records } // 创建或变更记录 func (dynadot *Dynadot) createOrModify(record *DynadotRecord, recordType string, ipAddr string) { params := record.CustomParams params.Set("domain", record.DomainName) params.Set("subDomain", strings.Join(record.SubDomainNames, ",")) params.Set("type", recordType) params.Set("ip", ipAddr) params.Set("pwd", dynadot.DNS.Secret) params.Set("ttl", dynadot.TTL) params.Set("containRoot", strconv.FormatBool(record.ContainRoot)) var result DynadotResp err := dynadot.request(params, &result) domains := record.Domains for _, domain := range domains { if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if result.ErrorCode != -1 { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, strings.Join(result.Content, ",")) domain.UpdateStatus = config.UpdatedFailed } } } // request 统一请求接口 func (dynadot *Dynadot) request(params url.Values, result interface{}) (err error) { req, err := http.NewRequest( "GET", dynadotEndpoint, bytes.NewBuffer(nil), ) req.URL.RawQuery = params.Encode() if err != nil { return } client := dynadot.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/dynv6.go ================================================ package dns import ( "bytes" "encoding/json" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" "net/http" "strconv" "strings" ) const ( dynv6Endpoint = "https://dynv6.com" ) type Dynv6 struct { DNS config.DNS Domains config.Domains TTL string httpClient *http.Client } type Dynv6Zone struct { ID uint `json:"id"` Name string `json:"name"` Ipv4 string `json:"ipv4address"` Ipv6 string `json:"ipv6prefix"` } type Dynv6Record struct { ID uint `json:"id"` ZoneID uint `json:"zoneID"` Name string `json:"name"` Type string `json:"type"` Data string `json:"data"` } // Init 初始化 func (dynv6 *Dynv6) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { dynv6.Domains.Ipv4Cache = ipv4cache dynv6.Domains.Ipv6Cache = ipv6cache dynv6.DNS = dnsConf.DNS dynv6.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s dynv6.TTL = "600" } else { dynv6.TTL = dnsConf.TTL } dynv6.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (dynv6 *Dynv6) AddUpdateDomainRecords() config.Domains { dynv6.addUpdateDomainRecords("A") dynv6.addUpdateDomainRecords("AAAA") return dynv6.Domains } func (dynv6 *Dynv6) addUpdateDomainRecords(recordType string) { ipAddr, domains := dynv6.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { isFindZone, findZone, isMain, err := dynv6.findZone(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if !isFindZone { util.Log("在DNS服务商中未找到根域名: %s", domain) domain.UpdateStatus = config.UpdatedFailed continue } zoneId := strconv.FormatUint(uint64(findZone.ID), 10) if isMain { // 如果使用的域名是主域名,对比DNS记录确定是否调用更新接口 if (recordType == "A" && findZone.Ipv4 == ipAddr) || (recordType == "AAAA" && findZone.Ipv6 == ipAddr) { // ip与dns服务器一致,不执行更新 util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) domain.UpdateStatus = config.UpdatedNothing } else { dynv6.modifyMain(domain, zoneId, recordType, ipAddr) } } else { // 如果是子域名,检查是否有该子域名记录,有就更新记录,没有就创建 // 处理subDomain processSubDomainOk := dynv6.processSubDomain(domain, findZone) if !processSubDomainOk { util.Log("域名: %s 不正确", domain) domain.UpdateStatus = config.UpdatedFailed continue } isFindRecord, findRecord, err := dynv6.findRecord(domain, zoneId, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if isFindRecord { // 判断是否需要更新 if findRecord.Type == recordType && findRecord.Data == ipAddr { // ip与dns服务器一致,不执行更新 util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) domain.UpdateStatus = config.UpdatedNothing } else { dynv6.modify(domain, zoneId, findRecord, recordType, ipAddr) } } else { // 创建记录 dynv6.create(domain, zoneId, recordType, ipAddr) } } } } func (dynv6 *Dynv6) processSubDomain(domain *config.Domain, zone Dynv6Zone) bool { // 确定subDomain subDomainLen := len(domain.String()) - len(zone.Name) - 1 if subDomainLen <= 0 { return false } subDomain := domain.String()[:subDomainLen] domain.DomainName = zone.Name domain.SubDomain = subDomain return true } // 根据domain获取zone func (dynv6 *Dynv6) findZone(domain *config.Domain) (isFind bool, zone Dynv6Zone, isMain bool, err error) { var zones []Dynv6Zone isFind = false isMain = false // 获取所有zone err = dynv6.request("GET", dynv6Endpoint+"/api/v2/zones", nil, &zones) if err != nil { return } // 遍历token权限下所有zone,确定当前域名属于哪个zone,并判断当前域名是主域名还是子域名 for _, z := range zones { if strings.HasSuffix(domain.String(), z.Name) { isFind = true zone = z if domain.String() == z.Name { isMain = true } break } } return } // 根据domain获取record func (dynv6 *Dynv6) findRecord(domain *config.Domain, zoneId string, recordType string) (isFind bool, record Dynv6Record, err error) { var records []Dynv6Record isFind = false err = dynv6.request("GET", dynv6Endpoint+"/api/v2/zones/"+zoneId+"/records", nil, &records) if err != nil { return } // 遍历zone下所有record,判断是更新还是创建 for _, r := range records { if r.Name == domain.SubDomain && r.Type == recordType { isFind = true record = r break } } return } // modify 更新根域名 func (dynv6 *Dynv6) modifyMain(domain *config.Domain, zoneId string, recordType string, ipAddr string) { var zoneUpdateReq = Dynv6Zone{} if recordType == "A" { zoneUpdateReq.Ipv4 = ipAddr } else { zoneUpdateReq.Ipv6 = ipAddr } err := dynv6.request("PATCH", dynv6Endpoint+"/api/v2/zones/"+zoneId, zoneUpdateReq, &Dynv6Zone{}) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } else { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } } // create 创建新的解析 func (dynv6 *Dynv6) create(domain *config.Domain, zoneId string, recordType string, ipAddr string) { recordUpdateReq := Dynv6Record{ Name: domain.SubDomain, Type: recordType, Data: ipAddr, } err := dynv6.request("POST", dynv6Endpoint+"/api/v2/zones/"+zoneId+"/records", recordUpdateReq, &Dynv6Record{}) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } else { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } } // modify 更新解析 func (dynv6 *Dynv6) modify(domain *config.Domain, zoneId string, record Dynv6Record, recordType string, ipAddr string) { record.Type = recordType record.Data = ipAddr recordId := strconv.FormatUint(uint64(record.ID), 10) err := dynv6.request("PATCH", dynv6Endpoint+"/api/v2/zones/"+zoneId+"/records/"+recordId, record, &Dynv6Record{}) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } else { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } } // request 统一请求接口 func (dynv6 *Dynv6) request(method string, url string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( method, url, bytes.NewBuffer(jsonStr), ) if err != nil { return err } req.Header.Add("Authorization", "Bearer "+dynv6.DNS.Secret) req.Header.Set("Content-Type", "application/json") client := dynv6.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return err } ================================================ FILE: dns/edgeone.go ================================================ package dns import ( "bytes" "encoding/json" "net/http" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" "golang.org/x/net/idna" ) // https://cloud.tencent.com/document/api/1552/80730 const ( edgeoneEndPoint = "https://teo.tencentcloudapi.com" edgeoneVersion = "2022-09-01" ) type EdgeOne struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } type EdgeOneRecord struct { ZoneId string `json:"ZoneId"` Name string `json:"Name"` // FullDomain Type string `json:"Type"` // record type, e.g. A AAAA Content string `json:"Content"` Location string `json:"Location"` TTL int `json:"TTL"` Weight int `json:"Weight,omitempty"` RecordId string `json:"RecordId,omitempty"` Status string `json:"Status,omitempty"` } type EdgeOneRecordResponse struct { EdgeOneStatus Response struct { DnsRecords []EdgeOneRecord `json:"DnsRecords"` TotalCount int `json:"TotalCount"` } } type EdgeOneZoneResponse struct { EdgeOneStatus Response struct { TotalCount int `json:"TotalCount"` Zones []struct { ZoneId string `json:"ZoneId"` ZoneName string `json:"ZoneName"` } `json:"Zones"` } } type Filter struct { Name string `json:"Name"` Values []string `json:"Values"` } type EdgeOneDescribeDns struct { ZoneId string `json:"ZoneId,omitempty"` Filters []Filter `json:"Filters"` } // https://cloud.tencent.com/document/product/1552/80729 type EdgeOneStatus struct { Response struct { Error struct { Code string Message string } } } // Init 初始化 func (eo *EdgeOne) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { eo.Domains.Ipv4Cache = ipv4cache eo.Domains.Ipv6Cache = ipv6cache eo.DNS = dnsConf.DNS eo.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认 600s eo.TTL = 600 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { eo.TTL = 600 } else { eo.TTL = ttl } } eo.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新 IPv4/IPv6 记录 func (eo *EdgeOne) AddUpdateDomainRecords() config.Domains { eo.addUpdateDomainRecords("A") eo.addUpdateDomainRecords("AAAA") return eo.Domains } func (eo *EdgeOne) addUpdateDomainRecords(recordType string) { ipAddr, domains := eo.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { zoneResult, err := eo.getZone(domain.DomainName) if err != nil || zoneResult.Response.TotalCount <= 0 || zoneResult.Response.Zones[0].ZoneName != domain.DomainName { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } zoneId := zoneResult.Response.Zones[0].ZoneId recordResult, err := eo.getRecordList(domain, recordType, zoneId) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } params := domain.GetCustomParams() var isValid func(*EdgeOneRecord) bool if params.Has("RecordId") { isValid = func(r *EdgeOneRecord) bool { return r.RecordId == params.Get("RecordId") } } else { isValid = func(r *EdgeOneRecord) bool { return r.Status == "enable" || r.Status == "disable" && r.Content == ipAddr } } var recordSelected *EdgeOneRecord for i := range recordResult.Response.DnsRecords { r := &recordResult.Response.DnsRecords[i] if isValid(r) { recordSelected = r break } } if recordSelected != nil { // 修改记录 eo.modify(*recordSelected, domain, recordType, ipAddr, zoneId) } else { // 添加记录 eo.create(domain, recordType, ipAddr, zoneId) } } } // CreateDnsRecord https://cloud.tencent.com/document/product/1552/80720 func (eo *EdgeOne) create(domain *config.Domain, recordType string, ipAddr string, ZoneId string) { d := domain.DomainName if domain.SubDomain != "" && domain.SubDomain != "@" { d = domain.SubDomain + "." + domain.DomainName } asciiDomain, _ := idna.ToASCII(d) record := &EdgeOneRecord{ ZoneId: ZoneId, Name: asciiDomain, Type: recordType, Content: ipAddr, Location: eo.getLocation(domain), TTL: eo.TTL, } var status EdgeOneStatus err := eo.request( "CreateDnsRecord", record, &status, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Response.Error.Code == "" { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, status.Response.Error.Message) domain.UpdateStatus = config.UpdatedFailed } } // ModifyDnsRecords https://cloud.tencent.com/document/product/1552/114252 func (eo *EdgeOne) modify(record EdgeOneRecord, domain *config.Domain, recordType string, ipAddr string, ZoneId string) { // 相同不修改 if record.Content == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } var status EdgeOneStatus d := domain.DomainName if domain.SubDomain != "" && domain.SubDomain != "@" { d = domain.SubDomain + "." + domain.DomainName } asciiDomain, _ := idna.ToASCII(d) record.ZoneId = ZoneId record.Name = asciiDomain record.Type = recordType record.Content = ipAddr record.Location = eo.getLocation(domain) record.TTL = eo.TTL err := eo.request( "ModifyDnsRecords", struct { ZoneId string `json:"ZoneId"` DnsRecords []EdgeOneRecord `json:"DnsRecords"` }{ ZoneId: ZoneId, DnsRecords: []EdgeOneRecord{record}, }, &status, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Response.Error.Code == "" { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, status.Response.Error.Message) domain.UpdateStatus = config.UpdatedFailed } } func (eo *EdgeOne) getZone(domain string) (result EdgeOneZoneResponse, err error) { asciiDomain, _ := idna.ToASCII(domain) record := EdgeOneDescribeDns{ Filters: []Filter{ {Name: "zone-name", Values: []string{asciiDomain}}, }, } err = eo.request( "DescribeZones", record, &result, ) return } // DescribeDnsRecords https://cloud.tencent.com/document/product/1552/80716 func (eo *EdgeOne) getRecordList(domain *config.Domain, recordType string, ZoneId string) (result EdgeOneRecordResponse, err error) { d := domain.DomainName if domain.SubDomain != "" && domain.SubDomain != "@" { d = domain.SubDomain + "." + domain.DomainName } asciiDomain, _ := idna.ToASCII(d) record := EdgeOneDescribeDns{ ZoneId: ZoneId, Filters: []Filter{ {Name: "name", Values: []string{asciiDomain}}, {Name: "type", Values: []string{recordType}}, }, } err = eo.request( "DescribeDnsRecords", record, &result, ) return } // getLocation 获取记录线路,为空返回默认 func (eo *EdgeOne) getLocation(domain *config.Domain) string { if domain.GetCustomParams().Has("Location") { return domain.GetCustomParams().Get("Location") } return "Default" } // request 统一请求接口 func (eo *EdgeOne) request(action string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( "POST", edgeoneEndPoint, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-TC-Version", edgeoneVersion) util.TencentCloudSigner(eo.DNS.ID, eo.DNS.Secret, req, action, string(jsonStr), util.EdgeOne) client := eo.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/eranet.go ================================================ package dns import ( "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "sort" "strconv" "strings" "time" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) // Eranet DNS实现 type Eranet struct { DNS config.DNS Domains config.Domains TTL string httpClient *http.Client } type EranetRecord struct { ID int `json:"id"` Domain string Host string Type string Value string State int // Name string // Enabled string } type EranetRecordListResp struct { EranetBaseResult Data []EranetRecord } type EranetBaseResult struct { RequestId string `json:"RequestId"` Id int `json:"Id"` Error string `json:"error"` } // Init 初始化 func (eranet *Eranet) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { eranet.Domains.Ipv4Cache = ipv4cache eranet.Domains.Ipv6Cache = ipv6cache eranet.DNS = dnsConf.DNS eranet.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s eranet.TTL = "600" } else { eranet.TTL = dnsConf.TTL } eranet.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (eranet *Eranet) AddUpdateDomainRecords() config.Domains { eranet.addUpdateDomainRecords("A") eranet.addUpdateDomainRecords("AAAA") return eranet.Domains } func (eranet *Eranet) addUpdateDomainRecords(recordType string) { ipAddr, domains := eranet.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { result, err := eranet.getRecordList(domain, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Data) > 0 { // 默认第一个 recordSelected := result.Data[0] params := domain.GetCustomParams() if params.Has("Id") { for i := 0; i < len(result.Data); i++ { if strconv.Itoa(result.Data[i].ID) == params.Get("Id") { recordSelected = result.Data[i] } } } // 更新 eranet.modify(recordSelected, domain, recordType, ipAddr) } else { // 新增 eranet.create(domain, recordType, ipAddr) } } } // create 创建DNS记录 func (eranet *Eranet) create(domain *config.Domain, recordType string, ipAddr string) { param := map[string]string{ "Domain": domain.DomainName, "Host": domain.GetSubDomain(), "Type": recordType, "Value": ipAddr, "Ttl": eranet.TTL, } res, err := eranet.request("/api/Dns/AddDomainRecord", param, "GET") if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } var result NowcnBaseResult err = json.Unmarshal(res, &result) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } if result.Error != "" { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, result.Error) domain.UpdateStatus = config.UpdatedFailed } else { domain.UpdateStatus = config.UpdatedSuccess } } // modify 修改DNS记录 func (eranet *Eranet) modify(record EranetRecord, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if record.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } param := map[string]string{ "Id": strconv.Itoa(record.ID), "Domain": domain.DomainName, "Host": domain.GetSubDomain(), "Type": recordType, "Value": ipAddr, "Ttl": eranet.TTL, } res, err := eranet.request("/api/Dns/UpdateDomainRecord", param, "GET") if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } var result NowcnBaseResult err = json.Unmarshal(res, &result) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } if result.Error != "" { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, result.Error) domain.UpdateStatus = config.UpdatedFailed } else { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } } // getRecordList 获取域名记录列表 func (eranet *Eranet) getRecordList(domain *config.Domain, typ string) (result EranetRecordListResp, err error) { param := map[string]string{ "Domain": domain.DomainName, "Type": typ, "Host": domain.GetSubDomain(), } res, err := eranet.request("/api/Dns/DescribeRecordIndex", param, "GET") err = json.Unmarshal(res, &result) return } func (eranet *Eranet) queryParams(param map[string]any) string { var queryParams []string for key, value := range param { // 只对键进行URL编码,值保持原样(特别是@符号) encodedKey := url.QueryEscape(key) valueStr := fmt.Sprintf("%v", value) // 对值进行选择性编码,保留@符号 encodedValue := strings.ReplaceAll(url.QueryEscape(valueStr), "%40", "@") encodedValue = strings.ReplaceAll(encodedValue, "%3A", ":") queryParams = append(queryParams, encodedKey+"="+encodedValue) } return strings.Join(queryParams, "&") } func (t *Eranet) sign(params map[string]string, method string) (string, error) { // 添加公共参数 params["AccessInstanceID"] = t.DNS.ID params["SignatureMethod"] = "HMAC-SHA1" params["SignatureNonce"] = fmt.Sprintf("%d", time.Now().UnixNano()) params["Timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05Z") // 1. 排序参数(按首字母顺序) var keys []string for k := range params { if k != "Signature" { // 排除Signature参数 keys = append(keys, k) } } sort.Strings(keys) // 2. 构造规范化请求字符串 var canonicalizedQuery []string for _, k := range keys { // URL编码参数名和参数值 encodedKey := util.PercentEncode(k) encodedValue := util.PercentEncode(params[k]) canonicalizedQuery = append(canonicalizedQuery, encodedKey+"="+encodedValue) } canonicalizedQueryString := strings.Join(canonicalizedQuery, "&") // 3. 构造待签名字符串 stringToSign := method + "&" + util.PercentEncode("/") + "&" + util.PercentEncode(canonicalizedQueryString) // 4. 计算HMAC-SHA1签名 key := t.DNS.Secret + "&" h := hmac.New(sha1.New, []byte(key)) h.Write([]byte(stringToSign)) signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) // 5. 添加签名到参数中 params["Signature"] = signature // 6. 重新构造最终的查询字符串(包含签名) keys = append(keys, "Signature") sort.Strings(keys) var finalQuery []string for _, k := range keys { encodedKey := util.PercentEncode(k) encodedValue := util.PercentEncode(params[k]) finalQuery = append(finalQuery, encodedKey+"="+encodedValue) } return strings.Join(finalQuery, "&"), nil } func (t *Eranet) request(apiPath string, params map[string]string, method string) ([]byte, error) { // 生成签名 queryString, err := t.sign(params, method) if err != nil { return nil, fmt.Errorf("生成签名失败: %v", err) } // 构造完整URL baseURL := "https://www.eranet.com" fullURL := baseURL + apiPath + "?" + queryString // 创建HTTP请求 req, err := http.NewRequest(method, fullURL, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %v", err) } // 设置请求头 req.Header.Set("Accept", "application/json") // 发送请求 client := t.httpClient resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %v", err) } // 检查HTTP状态码 if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API请求失败,状态码: %d, 响应: %s", resp.StatusCode, string(body)) } return body, nil } ================================================ FILE: dns/gcore.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const gcoreAPIEndpoint = "https://api.gcore.com/dns/v2" // Gcore Gcore DNS实现 type Gcore struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // GcoreZoneResponse zones返回结果 type GcoreZoneResponse struct { Zones []GcoreZone `json:"zones"` TotalAmount int `json:"total_amount"` } // GcoreZone 域名信息 type GcoreZone struct { ID int `json:"id"` Name string `json:"name"` } // GcoreRRSetListResponse RRSet列表返回结果 type GcoreRRSetListResponse struct { RRSets []GcoreRRSet `json:"rrsets"` TotalAmount int `json:"total_amount"` } // GcoreRRSet RRSet记录实体 type GcoreRRSet struct { Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl"` ResourceRecords []GcoreResourceRecord `json:"resource_records"` Meta map[string]interface{} `json:"meta,omitempty"` } // GcoreResourceRecord 资源记录 type GcoreResourceRecord struct { Content []interface{} `json:"content"` Enabled bool `json:"enabled"` ID int `json:"id,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"` } // GcoreInputRRSet 输入的RRSet type GcoreInputRRSet struct { TTL int `json:"ttl"` ResourceRecords []GcoreInputResourceRecord `json:"resource_records"` Meta map[string]interface{} `json:"meta,omitempty"` } // GcoreInputResourceRecord 输入的资源记录 type GcoreInputResourceRecord struct { Content []interface{} `json:"content"` Enabled bool `json:"enabled"` Meta map[string]interface{} `json:"meta,omitempty"` } // Init 初始化 func (gc *Gcore) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { gc.Domains.Ipv4Cache = ipv4cache gc.Domains.Ipv6Cache = ipv6cache gc.DNS = dnsConf.DNS gc.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认 120 秒(免费版最低值) gc.TTL = 120 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { gc.TTL = 120 } else { gc.TTL = ttl } } gc.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新 IPv4 / IPv6 记录 func (gc *Gcore) AddUpdateDomainRecords() config.Domains { gc.addUpdateDomainRecords("A") gc.addUpdateDomainRecords("AAAA") return gc.Domains } func (gc *Gcore) addUpdateDomainRecords(recordType string) { ipAddr, domains := gc.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { // get zone zoneInfo, err := gc.getZoneByDomain(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed continue } if zoneInfo == nil { util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName) domain.UpdateStatus = config.UpdatedFailed continue } // 查询现有记录 existingRecord, err := gc.getRRSet(zoneInfo.Name, domain.GetSubDomain(), recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed continue } if existingRecord != nil { // 更新现有记录 gc.updateRecord(zoneInfo.Name, domain, recordType, ipAddr, existingRecord) } else { // 创建新记录 gc.createRecord(zoneInfo.Name, domain, recordType, ipAddr) } } } // 获取域名对应的Zone信息 func (gc *Gcore) getZoneByDomain(domain *config.Domain) (*GcoreZone, error) { var result GcoreZoneResponse params := url.Values{} params.Set("name", domain.DomainName) err := gc.request( "GET", fmt.Sprintf("%s/zones?%s", gcoreAPIEndpoint, params.Encode()), nil, &result, ) if err != nil { return nil, err } if len(result.Zones) > 0 { return &result.Zones[0], nil } return nil, nil } // 获取指定的RRSet记录 func (gc *Gcore) getRRSet(zoneName, recordName, recordType string) (*GcoreRRSet, error) { var result GcoreRRSetListResponse err := gc.request( "GET", fmt.Sprintf("%s/zones/%s/rrsets", gcoreAPIEndpoint, zoneName), nil, &result, ) if err != nil { return nil, err } // 查找匹配的记录 fullRecordName := recordName if recordName != "" && recordName != "@" { fullRecordName = recordName + "." + zoneName } else { fullRecordName = zoneName } for _, rrset := range result.RRSets { if rrset.Name == fullRecordName && rrset.Type == recordType { return &rrset, nil } } return nil, nil } // 创建新记录 func (gc *Gcore) createRecord(zoneName string, domain *config.Domain, recordType string, ipAddr string) { recordName := domain.GetSubDomain() if recordName == "" || recordName == "@" { recordName = zoneName } else { recordName = recordName + "." + zoneName } inputRRSet := GcoreInputRRSet{ TTL: gc.TTL, ResourceRecords: []GcoreInputResourceRecord{ { Content: []interface{}{ipAddr}, Enabled: true, }, }, } var result interface{} err := gc.request( "POST", fmt.Sprintf("%s/zones/%s/%s/%s", gcoreAPIEndpoint, zoneName, recordName, recordType), inputRRSet, &result, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } // 更新现有记录 func (gc *Gcore) updateRecord(zoneName string, domain *config.Domain, recordType string, ipAddr string, existingRecord *GcoreRRSet) { // 检查IP是否相同 if len(existingRecord.ResourceRecords) > 0 && len(existingRecord.ResourceRecords[0].Content) > 0 { if existingRecord.ResourceRecords[0].Content[0] == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } } recordName := domain.GetSubDomain() if recordName == "" || recordName == "@" { recordName = zoneName } else { recordName = recordName + "." + zoneName } inputRRSet := GcoreInputRRSet{ TTL: gc.TTL, ResourceRecords: []GcoreInputResourceRecord{ { Content: []interface{}{ipAddr}, Enabled: true, }, }, } var result interface{} err := gc.request( "PUT", fmt.Sprintf("%s/zones/%s/%s/%s", gcoreAPIEndpoint, zoneName, recordName, recordType), inputRRSet, &result, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } // request 统一请求接口 func (gc *Gcore) request(method string, url string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( method, url, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("Authorization", "APIKey "+gc.DNS.Secret) req.Header.Set("Content-Type", "application/json") client := gc.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/godaddy.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) type godaddyRecord struct { Data string `json:"data"` Name string `json:"name"` TTL int `json:"ttl"` Type string `json:"type"` } type godaddyRecords []godaddyRecord type GoDaddyDNS struct { dns config.DNS domains config.Domains ttl int header http.Header client *http.Client lastIpv4 string lastIpv6 string } func (g *GoDaddyDNS) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { g.domains.Ipv4Cache = ipv4cache g.domains.Ipv6Cache = ipv6cache g.lastIpv4 = ipv4cache.Addr g.lastIpv6 = ipv6cache.Addr g.dns = dnsConf.DNS g.domains.GetNewIp(dnsConf) g.ttl = 600 if val, err := strconv.Atoi(dnsConf.TTL); err == nil { g.ttl = val } g.header = map[string][]string{ "Authorization": {fmt.Sprintf("sso-key %s:%s", g.dns.ID, g.dns.Secret)}, "Content-Type": {"application/json"}, } g.client = dnsConf.GetHTTPClient() } func (g *GoDaddyDNS) updateDomainRecord(recordType string, ipAddr string, domains []*config.Domain) { if ipAddr == "" { return } // 防止多次发送Webhook通知 if recordType == "A" { if g.lastIpv4 == ipAddr { util.Log("你的IPv4未变化, 未触发 %s 请求", "godaddy") return } } else { if g.lastIpv6 == ipAddr { util.Log("你的IPv6未变化, 未触发 %s 请求", "godaddy") return } } for _, domain := range domains { err := g.sendReq(http.MethodPut, recordType, domain, &godaddyRecords{godaddyRecord{ Data: ipAddr, Name: domain.GetSubDomain(), TTL: g.ttl, Type: recordType, }}) if err == nil { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } } } func (g *GoDaddyDNS) AddUpdateDomainRecords() config.Domains { if ipv4Addr, ipv4Domains := g.domains.GetNewIpResult("A"); ipv4Addr != "" { g.updateDomainRecord("A", ipv4Addr, ipv4Domains) } if ipv6Addr, ipv6Domains := g.domains.GetNewIpResult("AAAA"); ipv6Addr != "" { g.updateDomainRecord("AAAA", ipv6Addr, ipv6Domains) } return g.domains } func (g *GoDaddyDNS) sendReq(method string, rType string, domain *config.Domain, data *godaddyRecords) error { var body *bytes.Buffer if data != nil { if buffer, err := json.Marshal(data); err != nil { return err } else { body = bytes.NewBuffer(buffer) } } path := fmt.Sprintf("https://api.godaddy.com/v1/domains/%s/records/%s/%s", domain.DomainName, rType, domain.GetSubDomain()) req, err := http.NewRequest(method, path, body) if err != nil { return err } req.Header = g.header resp, err := g.client.Do(req) _, err = util.GetHTTPResponseOrg(resp, err) return err } ================================================ FILE: dns/huawei.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( huaweicloudEndpoint string = "https://dns.myhuaweicloud.com" ) // https://support.huaweicloud.com/api-dns/dns_api_64001.html // Huaweicloud Huaweicloud type Huaweicloud struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // HuaweicloudZonesResp zones response type HuaweicloudZonesResp struct { Zones []struct { ID string Name string Recordsets []HuaweicloudRecordsets } } // HuaweicloudRecordsResp 记录返回结果 type HuaweicloudRecordsResp struct { Recordsets []HuaweicloudRecordsets } // HuaweicloudRecordsets 记录 type HuaweicloudRecordsets struct { ID string Name string `json:"name"` ZoneID string `json:"zone_id"` Status string Type string `json:"type"` TTL int `json:"ttl"` Records []string `json:"records"` Weight int `json:"weight"` } // Init 初始化 func (hw *Huaweicloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { hw.Domains.Ipv4Cache = ipv4cache hw.Domains.Ipv6Cache = ipv6cache hw.DNS = dnsConf.DNS hw.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认300s hw.TTL = 300 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { hw.TTL = 300 } else { hw.TTL = ttl } } hw.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (hw *Huaweicloud) AddUpdateDomainRecords() config.Domains { hw.addUpdateDomainRecords("A") hw.addUpdateDomainRecords("AAAA") return hw.Domains } func (hw *Huaweicloud) addUpdateDomainRecords(recordType string) { ipAddr, domains := hw.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { customParams := domain.GetCustomParams() params := url.Values{} params.Set("name", domain.String()) params.Set("type", recordType) // 如果有精准匹配 // 详见 查询记录集 https://support.huaweicloud.com/api-dns/dns_api_64002.html if customParams.Has("zone_id") && customParams.Has("recordset_id") { var record HuaweicloudRecordsets err := hw.request( "GET", fmt.Sprintf(huaweicloudEndpoint+"/v2.1/zones/%s/recordsets/%s", customParams.Get("zone_id"), customParams.Get("recordset_id")), params, &record, ) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } // 更新 hw.modify(record, domain, ipAddr) } else { // 没有精准匹配,则支持更多的查询参数。详见 查询租户记录集列表 https://support.huaweicloud.com/api-dns/dns_api_64003.html // 复制所有自定义参数 util.CopyUrlParams(customParams, params, nil) // 参数名修正 if params.Has("recordset_id") { params.Set("id", params.Get("recordset_id")) params.Del("recordset_id") } var records HuaweicloudRecordsResp err := hw.request( "GET", huaweicloudEndpoint+"/v2.1/recordsets", params, &records, ) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } find := false for _, record := range records.Recordsets { // 名称相同才更新。华为云默认是模糊搜索 if record.Name == domain.String()+"." { // 更新 hw.modify(record, domain, ipAddr) find = true break } } if !find { thIdParamName := "" if customParams.Has("id") { thIdParamName = "id" } else if customParams.Has("recordset_id") { thIdParamName = "recordset_id" } if thIdParamName != "" { util.Log("域名 %s 解析未找到,且因添加了参数 %s=%s 导致无法创建。本次更新已被忽略", domain, thIdParamName, customParams.Get(thIdParamName)) } else { // 新增 hw.create(domain, recordType, ipAddr) } } } } } // 创建 func (hw *Huaweicloud) create(domain *config.Domain, recordType string, ipAddr string) { zone, err := hw.getZones(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if len(zone.Zones) == 0 { util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName) domain.UpdateStatus = config.UpdatedFailed return } zoneID := zone.Zones[0].ID for _, z := range zone.Zones { if z.Name == domain.DomainName+"." { zoneID = z.ID break } } record := &HuaweicloudRecordsets{ Type: recordType, Name: domain.String() + ".", Records: []string{ipAddr}, TTL: hw.TTL, Weight: 1, } var result HuaweicloudRecordsets err = hw.request( "POST", fmt.Sprintf(huaweicloudEndpoint+"/v2.1/zones/%s/recordsets", zoneID), record, &result, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Records) > 0 && result.Records[0] == ipAddr { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, result.Status) domain.UpdateStatus = config.UpdatedFailed } } // 修改 func (hw *Huaweicloud) modify(record HuaweicloudRecordsets, domain *config.Domain, ipAddr string) { // 相同不修改 if len(record.Records) > 0 && record.Records[0] == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } var request = make(map[string]interface{}) request["name"] = record.Name request["type"] = record.Type request["records"] = []string{ipAddr} request["ttl"] = hw.TTL var result HuaweicloudRecordsets err := hw.request( "PUT", fmt.Sprintf(huaweicloudEndpoint+"/v2.1/zones/%s/recordsets/%s", record.ZoneID, record.ID), &request, &result, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Records) > 0 && result.Records[0] == ipAddr { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, result.Status) domain.UpdateStatus = config.UpdatedFailed } } // 获得域名记录列表 func (hw *Huaweicloud) getZones(domain *config.Domain) (result HuaweicloudZonesResp, err error) { err = hw.request( "GET", huaweicloudEndpoint+"/v2/zones", url.Values{"name": []string{domain.DomainName}}, &result, ) return } // request 统一请求接口 func (hw *Huaweicloud) request(method string, urlString string, data interface{}, result interface{}) (err error) { var ( req *http.Request ) if method == "GET" { req, err = http.NewRequest( method, urlString, bytes.NewBuffer(nil), ) req.URL.RawQuery = data.(url.Values).Encode() } else { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err = http.NewRequest( method, urlString, bytes.NewBuffer(jsonStr), ) } if err != nil { return } s := util.Signer{ Key: hw.DNS.ID, Secret: hw.DNS.Secret, } s.Sign(req) req.Header.Add("content-type", "application/json") client := hw.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/index.go ================================================ package dns import ( "time" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) // DNS interface type DNS interface { Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) // 添加或更新IPv4/IPv6记录 AddUpdateDomainRecords() (domains config.Domains) } var ( Addresses = []string{ alidnsEndpoint, aliesaEndpoint, baiduEndpoint, zonesAPI, recordListAPI, huaweicloudEndpoint, nameCheapEndpoint, nameSiloListRecordEndpoint, porkbunEndpoint, tencentCloudEndPoint, dynadotEndpoint, dynv6Endpoint, gcoreAPIEndpoint, edgeoneEndPoint, rainyunEndpoint, } Ipcache = [][2]util.IpCache{} ) // RunTimer 定时运行 func RunTimer(delay time.Duration) { for { RunOnce() time.Sleep(delay) } } // RunOnce RunOnce func RunOnce() { conf, err := config.GetConfigCached() if err != nil { return } if util.ForceCompareGlobal || len(Ipcache) != len(conf.DnsConf) { Ipcache = [][2]util.IpCache{} for range conf.DnsConf { Ipcache = append(Ipcache, [2]util.IpCache{{}, {}}) } } for i, dc := range conf.DnsConf { var dnsSelected DNS switch dc.DNS.Name { case "alidns": dnsSelected = &Alidns{} case "aliesa": dnsSelected = &Aliesa{} case "tencentcloud": dnsSelected = &TencentCloud{} case "trafficroute": dnsSelected = &TrafficRoute{} case "dnspod": dnsSelected = &Dnspod{} case "dnsla": dnsSelected = &Dnsla{} case "cloudflare": dnsSelected = &Cloudflare{} case "huaweicloud": dnsSelected = &Huaweicloud{} case "callback": dnsSelected = &Callback{} case "baiducloud": dnsSelected = &BaiduCloud{} case "porkbun": dnsSelected = &Porkbun{} case "godaddy": dnsSelected = &GoDaddyDNS{} case "namecheap": dnsSelected = &NameCheap{} case "namesilo": dnsSelected = &NameSilo{} case "vercel": dnsSelected = &Vercel{} case "dynadot": dnsSelected = &Dynadot{} case "dynv6": dnsSelected = &Dynv6{} case "spaceship": dnsSelected = &Spaceship{} case "nowcn": dnsSelected = &Nowcn{} case "eranet": dnsSelected = &Eranet{} case "gcore": dnsSelected = &Gcore{} case "edgeone": dnsSelected = &EdgeOne{} case "nsone": dnsSelected = &NSOne{} case "name_com": dnsSelected = &NameCom{} case "rainyun": dnsSelected = &Rainyun{} default: dnsSelected = &Alidns{} } dnsSelected.Init(&dc, &Ipcache[i][0], &Ipcache[i][1]) domains := dnsSelected.AddUpdateDomainRecords() // webhook v4Status, v6Status := config.ExecWebhook(&domains, &conf) // 重置单个cache if v4Status == config.UpdatedFailed { Ipcache[i][0] = util.IpCache{} } if v6Status == config.UpdatedFailed { Ipcache[i][1] = util.IpCache{} } } util.ForceCompareGlobal = false } ================================================ FILE: dns/name_com.go ================================================ package dns import ( "bytes" "encoding/base64" "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( listRecords = "https://api.name.com/core/v1/domains/%s/records" createRecord = "https://api.name.com/core/v1/domains/%s/records" updateRecord = "https://api.name.com/core/v1/domains/%s/records/%d" ) type NameCom struct { DNS config.DNS Domains config.Domains TTL string httpClient *http.Client } type NameComRecord struct { TTL int `json:"ttl"` Type string `json:"type"` Answer string `json:"answer"` Host string `json:"host"` } type NameComRecordResp struct { TTL int `json:"ttl"` Type string `json:"type"` Answer string `json:"answer"` DomainName string `json:"domainName"` Fqdn string `json:"fqdn"` Host string `json:"host"` Id int `json:"id"` Priority int `json:"priority"` } type NameComRecordListResp struct { TotalCount int `json:"totalCount"` From int `json:"from"` To int `json:"to"` Records []NameComRecordResp `json:"records"` LastPage int `json:"lastPage"` NextPage int `json:"nextPage"` } func (n *NameCom) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { n.Domains.Ipv4Cache = ipv4cache n.Domains.Ipv6Cache = ipv6cache n.DNS = dnsConf.DNS n.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { n.TTL = "300" } else { n.TTL = dnsConf.TTL } n.httpClient = dnsConf.GetHTTPClient() } func (n *NameCom) AddUpdateDomainRecords() (domains config.Domains) { n.addUpdateDomainRecords("A") n.addUpdateDomainRecords("AAAA") domains = n.Domains return } func (n *NameCom) addUpdateDomainRecords(recordType string) { ipAddr, domains := n.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { resp, err := n.getRecordList(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } resp4TypeRecords := make([]NameComRecordResp, 0, resp.TotalCount) if resp.TotalCount > 0 { for _, r := range resp.Records { if r.Type == recordType && r.Host == domain.SubDomain { resp4TypeRecords = append(resp4TypeRecords, r) } } } if len(resp4TypeRecords) > 0 { for _, r := range resp4TypeRecords { err := n.update(r, domain, ipAddr, recordType) if err != nil { domain.UpdateStatus = config.UpdatedFailed return } } } else { _, err := n.create(domain, recordType, ipAddr) if err != nil { domain.UpdateStatus = config.UpdatedFailed return } } } } func (n *NameCom) getRecordList(domain *config.Domain) (resp *NameComRecordListResp, err error) { url := fmt.Sprintf(listRecords, domain.DomainName) err = n.request("GET", url, nil, &resp) return } func (n *NameCom) create(domain *config.Domain, recordType string, ipAddr string) (resp *NameComRecord, err error) { i, err := strconv.Atoi(n.TTL) if err != nil { return } resq := &NameComRecord{ TTL: i, Answer: ipAddr, Host: domain.SubDomain, Type: recordType, } url := fmt.Sprintf(createRecord, domain.DomainName) err = n.request("POST", url, resq, resp) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) return } util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) return } func (n *NameCom) update(record NameComRecordResp, domain *config.Domain, ipAddr, recordType string) (err error) { if record.Answer == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } record.Answer = ipAddr record.Type = recordType url := fmt.Sprintf(updateRecord, domain.DomainName, record.Id) err = n.request("PUT", url, record, nil) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) return } util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) return } func (n *NameCom) request(action string, url string, data any, result any) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, err = json.Marshal(data) if err != nil { return } } req, err := http.NewRequest( action, url, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(n.DNS.ID+":"+n.DNS.Secret))) if strings.EqualFold(action, "POST") || strings.EqualFold(action, "PUT") { req.Header.Add("Content-Type", "application/json") } client := n.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/namecheap.go ================================================ package dns import ( "io" "net/http" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( nameCheapEndpoint string = "https://dynamicdns.park-your-domain.com/update?host=#{host}&domain=#{domain}&password=#{password}&ip=#{ip}" ) // NameCheap Domain type NameCheap struct { DNS config.DNS Domains config.Domains lastIpv4 string lastIpv6 string httpClient *http.Client } // NameCheap 修改域名解析结果 type NameCheapResp struct { Status string Errors []string } // Init 初始化 func (nc *NameCheap) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { nc.Domains.Ipv4Cache = ipv4cache nc.Domains.Ipv6Cache = ipv6cache nc.lastIpv4 = ipv4cache.Addr nc.lastIpv6 = ipv6cache.Addr nc.DNS = dnsConf.DNS nc.Domains.GetNewIp(dnsConf) nc.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (nc *NameCheap) AddUpdateDomainRecords() config.Domains { nc.addUpdateDomainRecords("A") nc.addUpdateDomainRecords("AAAA") return nc.Domains } func (nc *NameCheap) addUpdateDomainRecords(recordType string) { ipAddr, domains := nc.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } // 防止多次发送Webhook通知 if recordType == "A" { if nc.lastIpv4 == ipAddr { util.Log("你的IPv4未变化, 未触发 %s 请求", "NameCheap") return } } else { // https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-http-request/ util.Log("Namecheap 不支持更新 IPv6") return } for _, domain := range domains { nc.modify(domain, ipAddr) } } // 修改 func (nc *NameCheap) modify(domain *config.Domain, ipAddr string) { var result NameCheapResp err := nc.request(&result, ipAddr, domain) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } switch result.Status { case "Success": util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess default: util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, result.Status) domain.UpdateStatus = config.UpdatedFailed } } // request 统一请求接口 func (nc *NameCheap) request(result *NameCheapResp, ipAddr string, domain *config.Domain) (err error) { url := strings.NewReplacer( "#{host}", domain.GetSubDomain(), "#{domain}", domain.DomainName, "#{password}", nc.DNS.Secret, "#{ip}", ipAddr, ).Replace(nameCheapEndpoint) req, err := http.NewRequest( http.MethodGet, url, http.NoBody, ) if err != nil { return } client := nc.httpClient resp, err := client.Do(req) if err != nil { return } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) if err != nil { return err } status := string(data) if strings.Contains(status, "0") { result.Status = "Success" } else { result.Status = status } return } ================================================ FILE: dns/namesilo.go ================================================ package dns import ( "encoding/xml" "io" "net/http" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( nameSiloListRecordEndpoint = "https://www.namesilo.com/api/dnsListRecords?version=1&type=xml&key=#{password}&domain=#{domain}" nameSiloAddRecordEndpoint = "https://www.namesilo.com/api/dnsAddRecord?version=1&type=xml&key=#{password}&domain=#{domain}&rrhost=#{host}&rrtype=#{recordType}&rrvalue=#{ip}&rrttl=3600" nameSiloUpdateRecordEndpoint = "https://www.namesilo.com/api/dnsUpdateRecord?version=1&type=xml&key=#{password}&domain=#{domain}&rrhost=#{host}&rrid=#{recordID}&rrvalue=#{ip}&rrttl=3600" ) // NameSilo Domain type NameSilo struct { DNS config.DNS Domains config.Domains lastIpv4 string lastIpv6 string httpClient *http.Client } // NameSiloResp 修改域名解析结果 type NameSiloResp struct { XMLName xml.Name `xml:"namesilo"` Request Request `xml:"request"` Reply ReplyResponse `xml:"reply"` } type ReplyResponse struct { Code int `xml:"code"` Detail string `xml:"detail"` RecordID string `xml:"record_id"` } type NameSiloDNSListRecordResp struct { XMLName xml.Name `xml:"namesilo"` Request Request `xml:"request"` Reply Reply `xml:"reply"` } type Request struct { Operation string `xml:"operation"` IP string `xml:"ip"` } type Reply struct { Code int `xml:"code"` Detail string `xml:"detail"` ResourceItems []ResourceRecord `xml:"resource_record"` } type ResourceRecord struct { RecordID string `xml:"record_id"` Type string `xml:"type"` Host string `xml:"host"` Value string `xml:"value"` TTL int `xml:"ttl"` Distance int `xml:"distance"` } // Init 初始化 func (ns *NameSilo) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { ns.Domains.Ipv4Cache = ipv4cache ns.Domains.Ipv6Cache = ipv6cache ns.lastIpv4 = ipv4cache.Addr ns.lastIpv6 = ipv6cache.Addr ns.DNS = dnsConf.DNS ns.Domains.GetNewIp(dnsConf) ns.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (ns *NameSilo) AddUpdateDomainRecords() config.Domains { ns.addUpdateDomainRecords("A") ns.addUpdateDomainRecords("AAAA") return ns.Domains } func (ns *NameSilo) addUpdateDomainRecords(recordType string) { ipAddr, domains := ns.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { if domain.SubDomain == "" { domain.SubDomain = "@" } // 拿到DNS记录列表,从列表中去取对应域名的id,有id进行修改,没ID进行新增 records, err := ns.listRecords(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } items := records.Reply.ResourceItems record := findResourceRecord(items, recordType, domain.SubDomain) var isAdd bool var recordID string if record == nil { isAdd = true } else { recordID = record.RecordID if record.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) continue } } ns.modify(domain, recordID, recordType, ipAddr, isAdd) } } // 修改 func (ns *NameSilo) modify(domain *config.Domain, recordID, recordType, ipAddr string, isAdd bool) { var err error var result string var requestType string if isAdd { requestType = "新增" result, err = ns.request(ipAddr, domain, "", recordType, nameSiloAddRecordEndpoint) } else { requestType = "更新" result, err = ns.request(ipAddr, domain, recordID, "", nameSiloUpdateRecordEndpoint) } if err != nil { util.Log("异常信息: %s", err) domain.UpdateStatus = config.UpdatedFailed return } var resp NameSiloResp xml.Unmarshal([]byte(result), &resp) if resp.Reply.Code == 300 { util.Log(requestType+"域名解析 %s 成功! IP: %s\n", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log(requestType+"域名解析 %s 失败! 异常信息: %s", domain, resp.Reply.Detail) domain.UpdateStatus = config.UpdatedFailed } } func (ns *NameSilo) listRecords(domain *config.Domain) (*NameSiloDNSListRecordResp, error) { result, err := ns.request("", domain, "", "", nameSiloListRecordEndpoint) if err != nil { return nil, err } var resp NameSiloDNSListRecordResp if err = xml.Unmarshal([]byte(result), &resp); err != nil { return nil, err } return &resp, nil } // request 统一请求接口 func (ns *NameSilo) request(ipAddr string, domain *config.Domain, recordID, recordType, url string) (result string, err error) { url = strings.NewReplacer( "#{host}", domain.SubDomain, "#{domain}", domain.DomainName, "#{password}", ns.DNS.Secret, "#{recordID}", recordID, "#{recordType}", recordType, "#{ip}", ipAddr, ).Replace(url) req, err := http.NewRequest( http.MethodGet, url, http.NoBody, ) if err != nil { return } client := ns.httpClient resp, err := client.Do(req) if err != nil { return } defer resp.Body.Close() data, err := io.ReadAll(resp.Body) result = string(data) return } func findResourceRecord(data []ResourceRecord, recordType, domain string) *ResourceRecord { for i := 0; i < len(data); i++ { if data[i].Host == domain && data[i].Type == recordType { return &data[i] } } return nil } ================================================ FILE: dns/nowcn.go ================================================ package dns import ( "crypto/hmac" "crypto/sha1" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "sort" "strconv" "strings" "time" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) // https://www.todaynic.com/docApi/ // Nowcn nowcn DNS实现 type Nowcn struct { DNS config.DNS Domains config.Domains TTL string httpClient *http.Client } // NowcnRecord DNS记录结构 type NowcnRecord struct { ID int `json:"id"` Domain string Host string Type string Value string State int // Name string // Enabled string } // NowcnRecordListResp 记录列表响应 type NowcnRecordListResp struct { NowcnBaseResult Data []NowcnRecord } // NowcnStatus API响应状态 type NowcnBaseResult struct { RequestId string `json:"RequestId"` Id int `json:"Id"` Error string `json:"error"` } // Init 初始化 func (nowcn *Nowcn) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { nowcn.Domains.Ipv4Cache = ipv4cache nowcn.Domains.Ipv6Cache = ipv6cache nowcn.DNS = dnsConf.DNS nowcn.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s nowcn.TTL = "600" } else { nowcn.TTL = dnsConf.TTL } nowcn.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (nowcn *Nowcn) AddUpdateDomainRecords() config.Domains { nowcn.addUpdateDomainRecords("A") nowcn.addUpdateDomainRecords("AAAA") return nowcn.Domains } func (nowcn *Nowcn) addUpdateDomainRecords(recordType string) { ipAddr, domains := nowcn.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { result, err := nowcn.getRecordList(domain, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Data) > 0 { // 默认第一个 recordSelected := result.Data[0] params := domain.GetCustomParams() if params.Has("Id") { for i := 0; i < len(result.Data); i++ { if strconv.Itoa(result.Data[i].ID) == params.Get("Id") { recordSelected = result.Data[i] } } } // 更新 nowcn.modify(recordSelected, domain, recordType, ipAddr) } else { // 新增 nowcn.create(domain, recordType, ipAddr) } } } // create 创建DNS记录 func (nowcn *Nowcn) create(domain *config.Domain, recordType string, ipAddr string) { param := map[string]string{ "Domain": domain.DomainName, "Host": domain.GetSubDomain(), "Type": recordType, "Value": ipAddr, "Ttl": nowcn.TTL, } res, err := nowcn.request("/api/Dns/AddDomainRecord", param, "GET") if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } var result NowcnBaseResult err = json.Unmarshal(res, &result) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } if result.Error != "" { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, result.Error) domain.UpdateStatus = config.UpdatedFailed } else { domain.UpdateStatus = config.UpdatedSuccess } } // modify 修改DNS记录 func (nowcn *Nowcn) modify(record NowcnRecord, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if record.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } param := map[string]string{ "Id": strconv.Itoa(record.ID), "Domain": domain.DomainName, "Host": domain.GetSubDomain(), "Type": recordType, "Value": ipAddr, "Ttl": nowcn.TTL, } res, err := nowcn.request("/api/Dns/UpdateDomainRecord", param, "GET") if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } var result NowcnBaseResult err = json.Unmarshal(res, &result) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err.Error()) domain.UpdateStatus = config.UpdatedFailed } if result.Error != "" { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, result.Error) domain.UpdateStatus = config.UpdatedFailed } else { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } } // getRecordList 获取域名记录列表 func (nowcn *Nowcn) getRecordList(domain *config.Domain, typ string) (result NowcnRecordListResp, err error) { param := map[string]string{ "Domain": domain.DomainName, "Type": typ, "Host": domain.GetSubDomain(), } res, err := nowcn.request("/api/Dns/DescribeRecordIndex", param, "GET") err = json.Unmarshal(res, &result) return } func (t *Nowcn) sign(params map[string]string, method string) (string, error) { // 添加公共参数 params["AccessInstanceID"] = t.DNS.ID params["SignatureMethod"] = "HMAC-SHA1" params["SignatureNonce"] = fmt.Sprintf("%d", time.Now().UnixNano()) params["Timestamp"] = time.Now().UTC().Format("2006-01-02T15:04:05Z") // 1. 排序参数(按首字母顺序) var keys []string for k := range params { if k != "Signature" { // 排除Signature参数 keys = append(keys, k) } } sort.Strings(keys) // 2. 构造规范化请求字符串 var canonicalizedQuery []string for _, k := range keys { // URL编码参数名和参数值 encodedKey := util.PercentEncode(k) encodedValue := util.PercentEncode(params[k]) canonicalizedQuery = append(canonicalizedQuery, encodedKey+"="+encodedValue) } canonicalizedQueryString := strings.Join(canonicalizedQuery, "&") // 3. 构造待签名字符串 stringToSign := method + "&" + util.PercentEncode("/") + "&" + util.PercentEncode(canonicalizedQueryString) // 4. 计算HMAC-SHA1签名 key := t.DNS.Secret + "&" h := hmac.New(sha1.New, []byte(key)) h.Write([]byte(stringToSign)) signature := base64.StdEncoding.EncodeToString(h.Sum(nil)) // 5. 添加签名到参数中 params["Signature"] = signature // 6. 重新构造最终的查询字符串(包含签名) keys = append(keys, "Signature") sort.Strings(keys) var finalQuery []string for _, k := range keys { encodedKey := util.PercentEncode(k) encodedValue := util.PercentEncode(params[k]) finalQuery = append(finalQuery, encodedKey+"="+encodedValue) } return strings.Join(finalQuery, "&"), nil } func (t *Nowcn) request(apiPath string, params map[string]string, method string) ([]byte, error) { // 生成签名 queryString, err := t.sign(params, method) if err != nil { return nil, fmt.Errorf("生成签名失败: %v", err) } // 构造完整URL baseURL := "https://api.now.cn" fullURL := baseURL + apiPath + "?" + queryString // 创建HTTP请求 req, err := http.NewRequest(method, fullURL, nil) if err != nil { return nil, fmt.Errorf("创建请求失败: %v", err) } // 设置请求头 req.Header.Set("Accept", "application/json") // 发送请求 client := t.httpClient resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("请求失败: %v", err) } defer resp.Body.Close() // 读取响应 body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("读取响应失败: %v", err) } // 检查HTTP状态码 if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("API请求失败,状态码: %d, 响应: %s", resp.StatusCode, string(body)) } return body, nil } ================================================ FILE: dns/nsone.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const nsoneAPIEndpoint = "https://api.nsone.net/v1/zones" type NSOne struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } type NSOneZone struct { AssignedNameservers []string `json:"assigned_nameservers"` DNSServers []string `json:"dns_servers"` Expiry int `json:"expiry"` Name string `json:"name"` Link string `json:"link"` PrimaryMaster string `json:"primary_master"` Hostmaster string `json:"hostmaster"` ID string `json:"id"` Meta struct { Asn []string `json:"asn"` CaProvince []string `json:"ca_province"` Connections int `json:"connections"` Country []string `json:"country"` Georegion []string `json:"georegion"` HighWatermark float64 `json:"high_watermark"` IPPrefixes []string `json:"ip_prefixes"` Latitude float64 `json:"latitude"` LoadAvg float64 `json:"loadAvg"` Longitude float64 `json:"longitude"` LowWatermark float64 `json:"low_watermark"` Note string `json:"note"` Priority int `json:"priority"` Pulsar string `json:"pulsar"` Requests int `json:"requests"` Up bool `json:"up"` UsState []string `json:"us_state"` Weight float64 `json:"weight"` } `json:"meta"` NetworkPools []string `json:"network_pools"` Networks []int `json:"networks"` NxTTL int `json:"nx_ttl"` Serial int `json:"serial"` Primary struct { Enabled bool `json:"enabled"` Secondaries []struct { IP string `json:"ip"` Network int `json:"network"` Notify bool `json:"notify"` Port int `json:"port"` Tsig struct { Enabled bool `json:"enabled"` Hash string `json:"hash"` Name string `json:"name"` Key string `json:"key"` } `json:"tsig"` } `json:"secondaries"` } `json:"primary"` Refresh int `json:"refresh"` Retry int `json:"retry"` Secondary struct { Status string `json:"status"` Error string `json:"error"` LastXfr int `json:"last_xfr"` LastTry int `json:"last_try"` Enabled bool `json:"enabled"` Expired bool `json:"expired"` PrimaryIP string `json:"primary_ip"` PrimaryPort int `json:"primary_port"` PrimaryNetwork int `json:"primary_network"` Tsig struct { Enabled bool `json:"enabled"` Hash string `json:"hash"` Name string `json:"name"` Key string `json:"key"` SignedNotifies bool `json:"signed_notifies"` } `json:"tsig"` OtherPorts []int `json:"other_ports"` OtherIps []string `json:"other_ips"` OtherNetworks []int `json:"other_networks"` OtherNotifyOnly []bool `json:"other_notify_only"` } `json:"secondary"` TTL int `json:"ttl"` Zone string `json:"zone"` Views []string `json:"views"` LocalTags []string `json:"local_tags"` Tags struct { ID int64 `json:"id"` } `json:"tags"` CreatedAt int `json:"created_at"` UpdatedAt int `json:"updated_at"` Dnssec bool `json:"dnssec"` Signatures []struct { Answer []string `json:"answer"` } `json:"signatures"` Presigned bool `json:"presigned"` IDVersion int `json:"id_version"` ActiveVersion bool `json:"active_version"` } type NSOneRecordAnswer struct { Answer []string `json:"answer"` ID string `json:"id,omitempty"` Meta struct { ID int64 `json:"id,omitempty"` } `json:"meta,omitempty"` Region string `json:"region,omitempty"` Feeds []struct { Source string `json:"source,omitempty"` Feed string `json:"feed,omitempty"` } `json:"feeds,omitempty"` } type NSOneRecordResponse struct { Answers []NSOneRecordAnswer `json:"answers"` Domain string `json:"domain"` Filters []struct { Config struct { Eliminate bool `json:"eliminate"` } `json:"config"` } `json:"filters"` Link string `json:"link"` Meta struct { ID int64 `json:"id"` } `json:"meta"` Networks []int `json:"networks"` Regions struct { ID int64 `json:"id"` } `json:"regions"` Tier int `json:"tier"` TTL int `json:"ttl"` OverrideTTL bool `json:"override_ttl"` Type string `json:"type"` UseClientSubnet bool `json:"use_client_subnet"` Zone string `json:"zone"` ZoneName string `json:"zone_name"` BlockedTags []string `json:"blocked_tags"` LocalTags []string `json:"local_tags"` Tags struct { ID int64 `json:"id"` } `json:"tags"` OverrideAddressRecords bool `json:"override_address_records"` Signatures []struct { Answer []string `json:"answer"` } `json:"signatures"` CreatedAt int `json:"created_at"` UpdatedAt int `json:"updated_at"` ID string `json:"id"` Customer int `json:"customer"` Feeds []struct { Source string `json:"source"` Feed string `json:"feed"` } `json:"feeds"` } type NSOneRecordRequest struct { Answers []NSOneRecordAnswer `json:"answers"` Domain string `json:"domain"` TTL int `json:"ttl"` Type string `json:"type"` Zone string `json:"zone"` } func (nsone *NSOne) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { nsone.Domains.Ipv4Cache = ipv4cache nsone.Domains.Ipv6Cache = ipv6cache nsone.DNS = dnsConf.DNS nsone.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { nsone.TTL = 60 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { // Default TTL in documentation is 1 hour nsone.TTL = 3600 } else { nsone.TTL = ttl } } nsone.httpClient = dnsConf.GetHTTPClient() } func (nsone *NSOne) AddUpdateDomainRecords() config.Domains { nsone.addUpdateDomainRecords("A") nsone.addUpdateDomainRecords("AAAA") return nsone.Domains } func (nsone *NSOne) addUpdateDomainRecords(recordType string) { ipAddr, domains := nsone.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { zoneInfo, err := nsone.getZone(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed continue } if zoneInfo == nil { util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName) domain.UpdateStatus = config.UpdatedFailed continue } existingRecord, err := nsone.getRecord(domain, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed continue } if existingRecord != nil { nsone.updateRecord(domain, recordType, ipAddr, existingRecord) } else { nsone.createRecord(domain, recordType, ipAddr) } } } func (nsone *NSOne) getZone(domain *config.Domain) (*NSOneZone, error) { var result NSOneZone params := url.Values{} params.Set("records", "false") err := nsone.request( "GET", fmt.Sprintf("%s/%s?%s", nsoneAPIEndpoint, domain.DomainName, params.Encode()), nil, &result, ) if err != nil { return nil, err } return &result, nil } func (nsone *NSOne) getRecord(domain *config.Domain, recordType string) (*NSOneRecordResponse, error) { var result NSOneRecordResponse params := url.Values{} params.Set("records", "false") err := nsone.request( "GET", fmt.Sprintf("%s/%s/%s/%s?%s", nsoneAPIEndpoint, domain.DomainName, domain.GetFullDomain(), recordType, params.Encode()), nil, &result, ) if err == nil && len(result.Answers) > 0 { return &result, nil } return nil, nil } func (nsone *NSOne) createRecord(domain *config.Domain, recordType string, ipAddr string) { recordName := domain.GetFullDomain() request := NSOneRecordRequest{ Answers: []NSOneRecordAnswer{ { Answer: []string{ ipAddr, }, }, }, Domain: recordName, TTL: nsone.TTL, Type: recordType, Zone: domain.DomainName, } var response NSOneRecordResponse err := nsone.request( "PUT", fmt.Sprintf("%s/%s/%s/%s", nsoneAPIEndpoint, domain.DomainName, recordName, recordType), request, &response, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } func (nsone *NSOne) updateRecord(domain *config.Domain, recordType string, ipAddr string, existingRecord *NSOneRecordResponse) { if len(existingRecord.Answers) > 0 && len(existingRecord.Answers[0].Answer) > 0 { if existingRecord.Answers[0].Answer[0] == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } } recordName := domain.GetFullDomain() request := NSOneRecordRequest{ Answers: []NSOneRecordAnswer{ { Answer: []string{ ipAddr, }, }, }, Domain: recordName, TTL: nsone.TTL, Type: recordType, Zone: domain.DomainName, } var response NSOneRecordResponse err := nsone.request( "POST", fmt.Sprintf("%s/%s/%s/%s", nsoneAPIEndpoint, domain.DomainName, recordName, recordType), request, &response, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } func (nsone *NSOne) request(method string, url string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( method, url, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("X-NSONE-Key", nsone.DNS.Secret) req.Header.Set("Content-Type", "application/json") client := nsone.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/porkbun.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( porkbunEndpoint string = "https://api.porkbun.com/api/json/v3/dns" ) type Porkbun struct { DNSConfig config.DNS Domains config.Domains TTL string httpClient *http.Client } type PorkbunDomainRecord struct { Name *string `json:"name"` // subdomain Type *string `json:"type"` // record type, e.g. A AAAA CNAME Content *string `json:"content"` // value Ttl *string `json:"ttl"` // default 300 } type PorkbunResponse struct { Status string `json:"status"` } type PorkbunDomainQueryResponse struct { *PorkbunResponse Records []PorkbunDomainRecord `json:"records"` } type PorkbunApiKey struct { AccessKey string `json:"apikey"` SecretKey string `json:"secretapikey"` } type PorkbunDomainCreateOrUpdateVO struct { *PorkbunApiKey *PorkbunDomainRecord } // Init 初始化 func (pb *Porkbun) Init(conf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { pb.Domains.Ipv4Cache = ipv4cache pb.Domains.Ipv6Cache = ipv6cache pb.DNSConfig = conf.DNS pb.Domains.GetNewIp(conf) if conf.TTL == "" { // 默认600s pb.TTL = "600" } else { pb.TTL = conf.TTL } pb.httpClient = conf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (pb *Porkbun) AddUpdateDomainRecords() config.Domains { pb.addUpdateDomainRecords("A") pb.addUpdateDomainRecords("AAAA") return pb.Domains } func (pb *Porkbun) addUpdateDomainRecords(recordType string) { ipAddr, domains := pb.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { var record PorkbunDomainQueryResponse // 获取当前域名信息 err := pb.request( porkbunEndpoint+fmt.Sprintf("/retrieveByNameType/%s/%s/%s", domain.DomainName, recordType, domain.SubDomain), &PorkbunApiKey{ AccessKey: pb.DNSConfig.ID, SecretKey: pb.DNSConfig.Secret, }, &record, ) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if record.Status == "SUCCESS" { if len(record.Records) > 0 { // 存在,更新 pb.modify(&record, domain, recordType, ipAddr) } else { // 不存在,创建 pb.create(domain, recordType, ipAddr) } } else { util.Log("在DNS服务商中未找到根域名: %s", domain.DomainName) domain.UpdateStatus = config.UpdatedFailed } } } // 创建 func (pb *Porkbun) create(domain *config.Domain, recordType string, ipAddr string) { var response PorkbunResponse err := pb.request( porkbunEndpoint+fmt.Sprintf("/create/%s", domain.DomainName), &PorkbunDomainCreateOrUpdateVO{ PorkbunApiKey: &PorkbunApiKey{ AccessKey: pb.DNSConfig.ID, SecretKey: pb.DNSConfig.Secret, }, PorkbunDomainRecord: &PorkbunDomainRecord{ Name: &domain.SubDomain, Type: &recordType, Content: &ipAddr, Ttl: &pb.TTL, }, }, &response, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if response.Status == "SUCCESS" { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, response.Status) domain.UpdateStatus = config.UpdatedFailed } } // 修改 func (pb *Porkbun) modify(record *PorkbunDomainQueryResponse, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if len(record.Records) > 0 && *record.Records[0].Content == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } var response PorkbunResponse err := pb.request( porkbunEndpoint+fmt.Sprintf("/editByNameType/%s/%s/%s", domain.DomainName, recordType, domain.SubDomain), &PorkbunDomainCreateOrUpdateVO{ PorkbunApiKey: &PorkbunApiKey{ AccessKey: pb.DNSConfig.ID, SecretKey: pb.DNSConfig.Secret, }, PorkbunDomainRecord: &PorkbunDomainRecord{ Content: &ipAddr, Ttl: &pb.TTL, }, }, &response, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if response.Status == "SUCCESS" { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, response.Status) domain.UpdateStatus = config.UpdatedFailed } } // request 统一请求接口 func (pb *Porkbun) request(url string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( "POST", url, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("Content-Type", "application/json") client := pb.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/rainyun.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "net/url" "strconv" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( rainyunEndpoint = "https://api.v2.rainyun.com" ) // https://s.apifox.cn/a4595cc8-44c5-4678-a2a3-eed7738dab03/api-153559362 // Rainyun Rainyun type Rainyun struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // RainyunRecord 雨云DNS记录 type RainyunRecord struct { RecordID int64 `json:"record_id"` Host string `json:"host"` Type string `json:"type"` Value string `json:"value"` Line string `json:"line"` TTL int `json:"ttl"` Level int `json:"level"` } // RainyunResp 雨云API通用响应 type RainyunResp struct { Code int `json:"code"` Message string `json:"message"` Data any `json:"data"` } // Init 初始化 func (rainyun *Rainyun) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { rainyun.Domains.Ipv4Cache = ipv4cache rainyun.Domains.Ipv6Cache = ipv6cache rainyun.DNS = dnsConf.DNS rainyun.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认600s rainyun.TTL = 600 } else { ttlInt, _ := strconv.Atoi(dnsConf.TTL) rainyun.TTL = ttlInt } rainyun.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (rainyun *Rainyun) AddUpdateDomainRecords() (domains config.Domains) { rainyun.addUpdateDomainRecords("A") rainyun.addUpdateDomainRecords("AAAA") return rainyun.Domains } func (rainyun *Rainyun) addUpdateDomainRecords(recordType string) { ipAddr, domains := rainyun.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { // 获取Domain ID domainID := rainyun.DNS.ID // 获取记录列表 records, err := rainyun.getRecordList(domainID) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed continue } // 查找匹配的记录 var recordSelected *RainyunRecord for i := range records { if strings.EqualFold(records[i].Host, domain.GetSubDomain()) && strings.EqualFold(records[i].Type, recordType) { recordSelected = &records[i] break } } if recordSelected != nil { // 更新记录 rainyun.modify(domainID, recordSelected, domain, ipAddr) } else { // 新增记录 rainyun.create(domainID, domain, recordType, ipAddr) } } } // getRecordList 获取域名记录列表 func (rainyun *Rainyun) getRecordList(domainID string) ([]RainyunRecord, error) { query := url.Values{} query.Set("limit", "100") query.Set("page_no", "1") var result struct { TotalRecords int `json:"TotalRecords"` Records []RainyunRecord `json:"Records"` } err := rainyun.request( http.MethodGet, fmt.Sprintf("/product/domain/%s/dns/", url.PathEscape(domainID)), query, nil, &result, ) if err != nil { return nil, err } return result.Records, nil } // create 创建DNS记录 func (rainyun *Rainyun) create(domainID string, domain *config.Domain, recordType string, ipAddr string) { record := &RainyunRecord{ Host: domain.GetSubDomain(), Type: recordType, Value: ipAddr, Line: "DEFAULT", TTL: rainyun.TTL, Level: 10, } err := rainyun.createRecord(domainID, record) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } // createRecord 发送POST请求创建记录 func (rainyun *Rainyun) createRecord(domainID string, record *RainyunRecord) error { payload := map[string]any{ "host": record.Host, "line": record.Line, "level": record.Level, "ttl": record.TTL, "type": record.Type, "value": record.Value, "record_id": 0, } byt, _ := json.Marshal(payload) return rainyun.request( http.MethodPost, fmt.Sprintf("/product/domain/%s/dns", url.PathEscape(domainID)), nil, byt, nil, ) } // modify 修改DNS记录 func (rainyun *Rainyun) modify(domainID string, record *RainyunRecord, domain *config.Domain, ipAddr string) { if record.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } record.Value = ipAddr record.TTL = rainyun.TTL err := rainyun.patchRecord(domainID, record) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } // patchRecord 发送PATCH请求更新记录 func (rainyun *Rainyun) patchRecord(domainID string, record *RainyunRecord) error { payload := map[string]any{ "host": record.Host, "line": record.Line, "level": record.Level, "ttl": record.TTL, "type": record.Type, "value": record.Value, "record_id": record.RecordID, } byt, _ := json.Marshal(payload) return rainyun.request( http.MethodPatch, fmt.Sprintf("/product/domain/%s/dns", url.PathEscape(domainID)), nil, byt, nil, ) } // request 统一请求接口 func (rainyun *Rainyun) request(method string, path string, query url.Values, body []byte, result any) error { u, err := url.Parse(rainyunEndpoint) if err != nil { return err } u.Path = path if query != nil { u.RawQuery = query.Encode() } var reader *bytes.Reader if body == nil { reader = bytes.NewReader(nil) } else { reader = bytes.NewReader(body) } req, err := http.NewRequest(method, u.String(), reader) if err != nil { return err } // 认证 req.Header.Set("x-api-key", rainyun.DNS.Secret) if method == http.MethodPost || method == http.MethodPatch || method == http.MethodPut { req.Header.Set("Content-Type", "application/json") } resp, err := rainyun.httpClient.Do(req) if err != nil { return err } var apiResp RainyunResp err = util.GetHTTPResponse(resp, err, &apiResp) if err != nil { return err } if apiResp.Code != 200 { if apiResp.Message != "" { return fmt.Errorf("%s", apiResp.Message) } return fmt.Errorf("Rainyun API error, code=%d", apiResp.Code) } if result == nil { return nil } dataBytes, err := json.Marshal(apiResp.Data) if err != nil { return err } return json.Unmarshal(dataBytes, result) } ================================================ FILE: dns/spaceship.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/url" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const spaceshipAPI = "https://spaceship.dev/api/v1/dns/records" const maxRecords = 500 type Spaceship struct { domains config.Domains header http.Header ttl int httpClient *http.Client } func (s *Spaceship) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { s.domains.Ipv4Cache = ipv4cache s.domains.Ipv6Cache = ipv6cache s.domains.GetNewIp(dnsConf) s.ttl = 600 if val, err := strconv.Atoi(dnsConf.TTL); err == nil { s.ttl = val } s.header = http.Header{ "X-API-Key": {dnsConf.DNS.ID}, "X-API-Secret": {dnsConf.DNS.Secret}, "Content-Type": {"application/json"}, } s.httpClient = dnsConf.GetHTTPClient() } func (s *Spaceship) AddUpdateDomainRecords() (domains config.Domains) { for _, recordType := range []string{"A", "AAAA"} { ip, domains := s.domains.GetNewIpResult(recordType) if ip == "" { continue } for _, domain := range domains { hasUpdated, err := s.updateRecord(recordType, ip, domain) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed continue } if !hasUpdated { util.Log("你的IP %s 没有变化, 域名 %s", ip, domain) } else { util.Log("更新域名解析 %s 成功! IP: %s", domain, ip) domain.UpdateStatus = config.UpdatedSuccess } } } return s.domains } func (s *Spaceship) request(domain *config.Domain, method string, query url.Values, payload []byte) (response []byte, err error) { url := fmt.Sprintf("%s/%s", spaceshipAPI, domain.DomainName) req, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(payload))) if err != nil { return } req.Header = s.header req.URL.RawQuery = query.Encode() cli := s.httpClient resp, err := cli.Do(req) if err != nil { return } defer resp.Body.Close() response, err = io.ReadAll(resp.Body) if err != nil { return } type DataItem struct { Field string `json:"field"` Details string `json:"details"` } type ErrorResponse struct { Detail string `json:"detail"` Data *[]DataItem `json:"data,omitempty"` } if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { var e ErrorResponse err = json.Unmarshal(response, &e) if err != nil { return } err = fmt.Errorf("request error: %s", e.Detail) return } return } func (s *Spaceship) createRecord(recordType string, ip string, domain *config.Domain) (err error) { type Item struct { Type string `json:"type"` Address string `json:"address"` Name string `json:"name"` TTL int `json:"ttl"` } type Payload struct { Force bool `json:"force"` Items []Item `json:"items"` } payload := Payload{ Force: true, Items: []Item{ { Type: recordType, Address: ip, Name: domain.SubDomain, TTL: s.ttl, }, }, } data, err := json.Marshal(payload) if err != nil { return } _, err = s.request(domain, "PUT", url.Values{}, data) return } func (s *Spaceship) getRecords(recordType string, domain *config.Domain) (ips []string, err error) { type Group struct { Type string `json:"type"` } type Item struct { Type string `json:"type"` Address string `json:"address"` Name string `json:"name"` TTL int `json:"ttl"` Group Group `json:"group"` } type Response struct { Items []Item `json:"items"` Total int `json:"total"` } resp, err := s.request(domain, "GET", url.Values{"take": {strconv.Itoa(maxRecords)}, "skip": {"0"}}, []byte{}) if err != nil { return } var response Response err = json.Unmarshal(resp, &response) if err != nil { return } if response.Total > maxRecords { err = fmt.Errorf("could not fetch all %d records in a one request", response.Total) return } for _, item := range response.Items { if item.Type == recordType && item.Name == domain.SubDomain { ips = append(ips, item.Address) } } return } func (s *Spaceship) deleteRecords(recordType string, domain *config.Domain, ips []string) (err error) { if len(ips) == 0 { return } if len(ips) > maxRecords { err = fmt.Errorf("could not delete all %d records in a one request", len(ips)) return } type Item struct { Type string `json:"type"` Address string `json:"address"` Name string `json:"name"` } var payload []Item for _, ip := range ips { payload = append(payload, Item{ Type: recordType, Address: ip, Name: domain.SubDomain, }) } data, err := json.Marshal(payload) if err != nil { return } _, err = s.request(domain, "DELETE", url.Values{}, data) return } func (s *Spaceship) updateRecord(recordType string, ip string, domain *config.Domain) (hasUpdated bool, err error) { ips, err := s.getRecords(recordType, domain) if err != nil { return } if len(ips) == 1 && ips[0] == ip { return } err = s.deleteRecords(recordType, domain, ips) if err != nil { return } err = s.createRecord(recordType, ip, domain) hasUpdated = true return } ================================================ FILE: dns/tencent_cloud.go ================================================ package dns import ( "bytes" "encoding/json" "net/http" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) const ( tencentCloudEndPoint = "https://dnspod.tencentcloudapi.com" tencentCloudVersion = "2021-03-23" ) // TencentCloud 腾讯云 DNSPod API 3.0 实现 // https://cloud.tencent.com/document/api/1427/56193 type TencentCloud struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // TencentCloudRecord 腾讯云记录 type TencentCloudRecord struct { Domain string `json:"Domain"` // DescribeRecordList 不需要 SubDomain SubDomain string `json:"SubDomain,omitempty"` // CreateRecord/ModifyRecord 不需要 Subdomain Subdomain string `json:"Subdomain,omitempty"` RecordType string `json:"RecordType"` RecordLine string `json:"RecordLine"` // DescribeRecordList 不需要 Value Value string `json:"Value,omitempty"` // CreateRecord/DescribeRecordList 不需要 RecordId RecordId int64 `json:"RecordId,omitempty"` // DescribeRecordList 不需要 TTL TTL int `json:"TTL,omitempty"` } // TencentCloudRecordListsResp 获取域名的解析记录列表返回结果 type TencentCloudRecordListsResp struct { TencentCloudStatus Response struct { RecordCountInfo struct { TotalCount int `json:"TotalCount"` } `json:"RecordCountInfo"` RecordList []TencentCloudRecord `json:"RecordList"` } } // TencentCloudStatus 腾讯云返回状态 // https://cloud.tencent.com/document/product/1427/56192 type TencentCloudStatus struct { Response struct { Error struct { Code string Message string } } } func (tc *TencentCloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { tc.Domains.Ipv4Cache = ipv4cache tc.Domains.Ipv6Cache = ipv6cache tc.DNS = dnsConf.DNS tc.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { // 默认 600s tc.TTL = 600 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { tc.TTL = 600 } else { tc.TTL = ttl } } tc.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新 IPv4/IPv6 记录 func (tc *TencentCloud) AddUpdateDomainRecords() config.Domains { tc.addUpdateDomainRecords("A") tc.addUpdateDomainRecords("AAAA") return tc.Domains } func (tc *TencentCloud) addUpdateDomainRecords(recordType string) { ipAddr, domains := tc.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { result, err := tc.getRecordList(domain, recordType) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if result.Response.RecordCountInfo.TotalCount > 0 { // 默认第一个 recordSelected := result.Response.RecordList[0] params := domain.GetCustomParams() if params.Has("RecordId") { for i := 0; i < result.Response.RecordCountInfo.TotalCount; i++ { if strconv.FormatInt(result.Response.RecordList[i].RecordId, 10) == params.Get("RecordId") { recordSelected = result.Response.RecordList[i] } } } // 修改记录 tc.modify(recordSelected, domain, recordType, ipAddr) } else { // 添加记录 tc.create(domain, recordType, ipAddr) } } } // create 添加记录 // CreateRecord https://cloud.tencent.com/document/api/1427/56180 func (tc *TencentCloud) create(domain *config.Domain, recordType string, ipAddr string) { record := &TencentCloudRecord{ Domain: domain.DomainName, SubDomain: domain.GetSubDomain(), RecordType: recordType, RecordLine: tc.getRecordLine(domain), Value: ipAddr, TTL: tc.TTL, } var status TencentCloudStatus err := tc.request( "CreateRecord", record, &status, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Response.Error.Code == "" { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, status.Response.Error.Message) domain.UpdateStatus = config.UpdatedFailed } } // modify 修改记录 // ModifyRecord https://cloud.tencent.com/document/api/1427/56157 func (tc *TencentCloud) modify(record TencentCloudRecord, domain *config.Domain, recordType string, ipAddr string) { // 相同不修改 if record.Value == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) return } var status TencentCloudStatus record.Domain = domain.DomainName record.SubDomain = domain.GetSubDomain() record.RecordType = recordType record.RecordLine = tc.getRecordLine(domain) record.Value = ipAddr record.TTL = tc.TTL err := tc.request( "ModifyRecord", record, &status, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if status.Response.Error.Code == "" { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, status.Response.Error.Message) domain.UpdateStatus = config.UpdatedFailed } } // getRecordList 获取域名的解析记录列表 // DescribeRecordList https://cloud.tencent.com/document/api/1427/56166 func (tc *TencentCloud) getRecordList(domain *config.Domain, recordType string) (result TencentCloudRecordListsResp, err error) { record := TencentCloudRecord{ Domain: domain.DomainName, Subdomain: domain.GetSubDomain(), RecordType: recordType, RecordLine: tc.getRecordLine(domain), } err = tc.request( "DescribeRecordList", record, &result, ) return } // getRecordLine 获取记录线路,为空返回默认 func (tc *TencentCloud) getRecordLine(domain *config.Domain) string { if domain.GetCustomParams().Has("RecordLine") { return domain.GetCustomParams().Get("RecordLine") } return "默认" } // request 统一请求接口 func (tc *TencentCloud) request(action string, data interface{}, result interface{}) (err error) { jsonStr := make([]byte, 0) if data != nil { jsonStr, _ = json.Marshal(data) } req, err := http.NewRequest( "POST", tencentCloudEndPoint, bytes.NewBuffer(jsonStr), ) if err != nil { return } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-TC-Version", tencentCloudVersion) util.TencentCloudSigner(tc.DNS.ID, tc.DNS.Secret, req, action, string(jsonStr), util.DnsPod) client := tc.httpClient resp, err := client.Do(req) err = util.GetHTTPResponse(resp, err, result) return } ================================================ FILE: dns/traffic_route.go ================================================ package dns import ( "encoding/json" "net/http" "strconv" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) // TrafficRoute 火山引擎DNS服务 type TrafficRoute struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } // TrafficRouteMeta 解析记录 type TrafficRouteMeta struct { ZID int `json:"ZID"` // 域名ID RecordID string `json:"RecordID"` // 解析记录ID Host string `json:"Host"` // 主机记录 Type string `json:"Type"` // 记录类型 Value string `json:"Value"` // 记录值 TTL int `json:"TTL"` // TTL值 Line string `json:"Line"` // 解析线路 } // TrafficRouteResp API响应通用结构 type TrafficRouteResp struct { ResponseMetadata struct { RequestId string `json:"RequestId"` Action string `json:"Action"` Version string `json:"Version"` Service string `json:"Service"` Region string `json:"Region"` Error struct { Code string `json:"Code"` Message string `json:"Message"` } `json:"Error"` } `json:"ResponseMetadata"` Result struct { // 域名列表相关字段 Zones []struct { ZID int `json:"ZID"` ZoneName string `json:"ZoneName"` RecordCount int `json:"RecordCount"` } `json:"Zones,omitempty"` Total int `json:"Total,omitempty"` // 解析记录相关字段 Records []TrafficRouteMeta `json:"Records,omitempty"` TotalCount int `json:"TotalCount,omitempty"` // 创建/更新记录相关字段 RecordID string `json:"RecordID,omitempty"` Status bool `json:"Status,omitempty"` } `json:"Result"` } // TrafficRouteListZonesParams ListZones查询参数 type TrafficRouteListZonesParams struct { Key string `json:"Key,omitempty"` // 获取包含特定关键字的域名(默认模糊搜索) } // TrafficRouteListZonesResp type TrafficRouteListZonesResp struct { ZID int `json:"ZID"` // 域名ID } func (tr *TrafficRoute) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { tr.Domains.Ipv4Cache = ipv4cache tr.Domains.Ipv6Cache = ipv6cache tr.DNS = dnsConf.DNS tr.Domains.GetNewIp(dnsConf) if dnsConf.TTL == "" { tr.TTL = 600 } else { ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { tr.TTL = 600 } else { tr.TTL = ttl } } tr.httpClient = dnsConf.GetHTTPClient() } // AddUpdateDomainRecords 添加或更新IPv4/IPv6记录 func (tr *TrafficRoute) AddUpdateDomainRecords() config.Domains { tr.addUpdateDomainRecords("A") tr.addUpdateDomainRecords("AAAA") return tr.Domains } func (tr *TrafficRoute) addUpdateDomainRecords(recordType string) { ipAddr, domains := tr.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } for _, domain := range domains { resp := TrafficRouteListZonesResp{} tr.getZID(domain, &resp) zoneID := resp.ZID var recordResp TrafficRouteResp tr.request( "GET", "ListRecords", map[string][]string{ "ZID": {strconv.Itoa(zoneID)}, "Type": {recordType}, "Host": {domain.GetSubDomain()}, "SearchMode": {"exact"}, "PageNumber": {"1"}, "PageSize": {"500"}, }, &recordResp, ) found := false for _, record := range recordResp.Result.Records { if record.Type == recordType && record.Host == domain.GetSubDomain() { tr.modify(record, domain, ipAddr) found = true break } } if !found { tr.create(zoneID, domain, recordType, ipAddr) } } } // getZID 获取域名的ZID func (tr *TrafficRoute) getZID(domain *config.Domain, resp *TrafficRouteListZonesResp) { var result TrafficRouteResp err := tr.request( "GET", "ListZones", map[string][]string{"Key": {domain.DomainName}}, &result, ) if err != nil { util.Log("查询域名信息发生异常! %s", err) domain.UpdateStatus = config.UpdatedFailed return } if len(result.Result.Zones) == 0 { util.Log("在DNS服务商中未找到域名: %s", domain.DomainName) domain.UpdateStatus = config.UpdatedFailed return } for _, zone := range result.Result.Zones { if zone.ZoneName == domain.DomainName { resp.ZID = zone.ZID return } } } // create 添加解析记录 func (tr *TrafficRoute) create(zoneID int, domain *config.Domain, recordType, ipAddr string) { record := &TrafficRouteMeta{ ZID: zoneID, Host: domain.GetSubDomain(), Type: recordType, Value: ipAddr, TTL: tr.TTL, Line: "default", } var result TrafficRouteResp err := tr.request( "POST", "CreateRecord", record, &result, ) if err != nil { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if result.ResponseMetadata.Error.Code == "" { util.Log("新增域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("新增域名解析 %s 失败! 异常信息: %s", domain, result.ResponseMetadata.Error.Message) domain.UpdateStatus = config.UpdatedFailed } } // modify 修改解析记录 func (tr *TrafficRoute) modify(record TrafficRouteMeta, domain *config.Domain, ipAddr string) { if record.Value == ipAddr { util.Log("IP %s 没有变化,域名 %s", ipAddr, domain) domain.UpdateStatus = config.UpdatedNothing return } record.Value = ipAddr record.TTL = tr.TTL var result TrafficRouteResp err := tr.request( "POST", "UpdateRecord", record, &result, ) if err != nil { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed return } if result.ResponseMetadata.Error.Code == "" { util.Log("更新域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log("更新域名解析 %s 失败! 异常信息: %s", domain, result.ResponseMetadata.Error.Message) domain.UpdateStatus = config.UpdatedFailed } } // parseRequestParams 解析请求参数 func (tr *TrafficRoute) parseRequestParams(action string, data interface{}) (queryParams map[string][]string, jsonStr []byte, err error) { queryParams = make(map[string][]string) switch v := data.(type) { case map[string][]string: queryParams = v jsonStr = []byte{} case *TrafficRouteMeta: jsonStr, _ = json.Marshal(v) default: if data != nil { jsonStr, _ = json.Marshal(data) } } // 根据不同action处理参数 switch action { case "ListZones": if len(queryParams) == 0 && len(jsonStr) > 0 { var params TrafficRouteListZonesParams if err = json.Unmarshal(jsonStr, ¶ms); err == nil && params.Key != "" { queryParams["Key"] = []string{params.Key} } jsonStr = []byte{} } case "ListRecords": if len(queryParams) == 0 && len(jsonStr) > 0 { var params TrafficRouteListZonesResp if err = json.Unmarshal(jsonStr, ¶ms); err == nil && params.ZID != 0 { queryParams["ZID"] = []string{strconv.Itoa(params.ZID)} } jsonStr = []byte{} } } return } // request 统一请求接口 func (tr *TrafficRoute) request(method string, action string, data interface{}, result interface{}) error { queryParams, jsonStr, err := tr.parseRequestParams(action, data) if err != nil { return err } req, err := util.TrafficRouteSigner(method, queryParams, map[string]string{}, tr.DNS.ID, tr.DNS.Secret, action, jsonStr) if err != nil { return err } client := tr.httpClient resp, err := client.Do(req) return util.GetHTTPResponse(resp, err, result) } ================================================ FILE: dns/vercel.go ================================================ package dns import ( "bytes" "encoding/json" "fmt" "net/http" "strconv" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) type Vercel struct { DNS config.DNS Domains config.Domains TTL int httpClient *http.Client } type ListExistingRecordsResponse struct { Records []Record `json:"records"` } type Record struct { ID string `json:"id"` // 记录ID Slug string `json:"slug"` Name string `json:"name"` // 记录名称 Type string `json:"type"` // 记录类型 Value string `json:"value"` // 记录值 Creator string `json:"creator"` Created int64 `json:"created"` Updated int64 `json:"updated"` CreatedAt int64 `json:"createdAt"` UpdatedAt int64 `json:"updatedAt"` TTL int64 `json:"ttl"` Comment *string `json:"comment,omitempty"` } func (v *Vercel) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) { v.Domains.Ipv4Cache = ipv4cache v.Domains.Ipv6Cache = ipv6cache v.DNS = dnsConf.DNS v.Domains.GetNewIp(dnsConf) // Must be greater than 60 ttl, err := strconv.Atoi(dnsConf.TTL) if err != nil { ttl = 60 } if ttl < 60 { ttl = 60 } v.TTL = ttl v.httpClient = dnsConf.GetHTTPClient() } func (v *Vercel) AddUpdateDomainRecords() (domains config.Domains) { v.addUpdateDomainRecords("A") v.addUpdateDomainRecords("AAAA") return v.Domains } func (v *Vercel) addUpdateDomainRecords(recordType string) { ipAddr, domains := v.Domains.GetNewIpResult(recordType) if ipAddr == "" { return } ipAddr = strings.ToLower(ipAddr) var ( records []Record err error ) for _, domain := range domains { records, err = v.listExistingRecords(domain) if err != nil { util.Log("查询域名信息发生异常! %s", err) continue } var targetRecord *Record for _, record := range records { if record.Name == domain.SubDomain { targetRecord = &record break } } if targetRecord == nil { err = v.createRecord(domain, recordType, ipAddr) } else { if strings.ToLower(targetRecord.Value) == ipAddr { util.Log("你的IP %s 没有变化, 域名 %s", ipAddr, domain) domain.UpdateStatus = config.UpdatedNothing continue } else { err = v.updateRecord(targetRecord, recordType, ipAddr) } } operation := "新增" if targetRecord != nil { operation = "更新" } if err == nil { util.Log(operation+"域名解析 %s 成功! IP: %s", domain, ipAddr) domain.UpdateStatus = config.UpdatedSuccess } else { util.Log(operation+"域名解析 %s 失败! 异常信息: %s", domain, err) domain.UpdateStatus = config.UpdatedFailed } } } func (v *Vercel) listExistingRecords(domain *config.Domain) (records []Record, err error) { var result ListExistingRecordsResponse err = v.request(http.MethodGet, "https://api.vercel.com/v4/domains/"+domain.DomainName+"/records", nil, &result) if err != nil { return } records = result.Records return } func (v *Vercel) createRecord(domain *config.Domain, recordType string, recordValue string) (err error) { err = v.request(http.MethodPost, "https://api.vercel.com/v2/domains/"+domain.DomainName+"/records", map[string]interface{}{ "name": domain.SubDomain, "type": recordType, "value": recordValue, "ttl": v.TTL, "comment": "Created by ddns-go", }, nil) return } func (v *Vercel) updateRecord(record *Record, recordType string, recordValue string) (err error) { err = v.request(http.MethodPatch, "https://api.vercel.com/v1/domains/records/"+record.ID, map[string]interface{}{ "type": recordType, "value": recordValue, "ttl": v.TTL, }, nil) return } func (v *Vercel) request(method, api string, data, result interface{}) (err error) { var payload []byte if data != nil { payload, _ = json.Marshal(data) } // 如果设置了 ExtParam (TeamId),添加查询参数 if v.DNS.ExtParam != "" { if strings.Contains(api, "?") { api = api + "&teamId=" + v.DNS.ExtParam } else { api = api + "?teamId=" + v.DNS.ExtParam } } req, err := http.NewRequest( method, api, bytes.NewBuffer(payload), ) if err != nil { return } req.Header.Set("Authorization", "Bearer "+v.DNS.Secret) req.Header.Set("Content-Type", "application/json") client := v.httpClient resp, err := client.Do(req) if err != nil { return err } if resp.StatusCode != 200 { return fmt.Errorf("Vercel API returned status code %d", resp.StatusCode) } if result != nil { err = util.GetHTTPResponse(resp, err, result) } return } ================================================ FILE: go.mod ================================================ module github.com/jeessy2/ddns-go/v6 go 1.25.0 require ( github.com/kardianos/service v1.2.4 github.com/wagslane/go-password-validator v0.3.0 golang.org/x/crypto v0.48.0 golang.org/x/net v0.51.0 gopkg.in/yaml.v3 v3.0.1 ) require ( golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.34.0 ) ================================================ FILE: main.go ================================================ package main import ( "embed" "errors" "flag" "fmt" "log" "net" "net/http" "os" "os/exec" "path/filepath" "runtime" "strconv" "time" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/dns" "github.com/jeessy2/ddns-go/v6/util" "github.com/jeessy2/ddns-go/v6/util/osutil" "github.com/jeessy2/ddns-go/v6/util/update" "github.com/jeessy2/ddns-go/v6/web" "github.com/kardianos/service" ) // ddns-go 版本 // ddns-go version var versionFlag = flag.Bool("v", false, "ddns-go version") // 更新 ddns-go var updateFlag = flag.Bool("u", false, "Upgrade ddns-go to the latest version") // 监听地址 var listen = flag.String("l", ":9876", "Listen address") // 更新频率(秒) var every = flag.Int("f", 300, "Update frequency(seconds)") // 缓存次数 var ipCacheTimes = flag.Int("cacheTimes", 5, "Cache times") // 服务管理 var serviceType = flag.String("s", "", "Service management (install|uninstall|restart)") // 配置文件路径 var configFilePath = flag.String("c", util.GetConfigFilePathDefault(), "Custom configuration file path") // Web 服务 var noWebService = flag.Bool("noweb", false, "No web service") // 跳过验证证书 var skipVerify = flag.Bool("skipVerify", false, "Skip certificate verification") // 自定义 DNS 服务器 var customDNS = flag.String("dns", "", "Custom DNS server address, example: 8.8.8.8") // 重置密码 var newPassword = flag.String("resetPassword", "", "Reset password to the one entered") // 后台运行 var daemonize = flag.Bool("d", false, "Run in background (daemon/detached)") //go:embed static var staticEmbeddedFiles embed.FS //go:embed favicon.ico var faviconEmbeddedFile embed.FS // version var version = "DEV" func main() { flag.Parse() if *versionFlag { fmt.Println(version) return } if *updateFlag { update.Self(version) return } if *daemonize && os.Getenv("DDNS_GO_DAEMON") != "1" { if err := runAsDaemon(); err != nil { log.Fatalf("Daemonize failed: %v", err) } return } // 安卓 go/src/time/zoneinfo_android.go 固定localLoc 为 UTC if runtime.GOOS == "android" { util.FixTimezone() } // 检查监听地址 if _, err := net.ResolveTCPAddr("tcp", *listen); err != nil { log.Fatalf("Parse listen address failed! Exception: %s", err) } // 设置版本号 os.Setenv(web.VersionEnv, version) // 设置配置文件路径 if *configFilePath != "" { absPath, _ := filepath.Abs(*configFilePath) os.Setenv(util.ConfigFilePathENV, absPath) } // 重置密码 if *newPassword != "" { conf, err := config.GetConfigCached() if err == nil { conf.ResetPassword(*newPassword) } else { util.Log("配置文件 %s 不存在, 可通过-c指定配置文件", *configFilePath) } return } // 设置跳过证书验证 if *skipVerify { util.SetInsecureSkipVerify() } // 设置自定义DNS if *customDNS != "" { util.SetDNS(*customDNS) } os.Setenv(util.IPCacheTimesENV, strconv.Itoa(*ipCacheTimes)) switch *serviceType { case "install": installService() case "uninstall": uninstallService() case "restart": restartService() default: if util.IsRunInDocker() || os.Getenv("DDNS_GO_DAEMON") == "1" { run() } else { s := getService() status, _ := s.Status() if status != service.StatusUnknown { // 以服务方式运行 s.Run() } else { // 非服务方式运行 switch s.Platform() { case "windows-service": util.Log("可使用 .\\ddns-go.exe -s install 安装服务运行") default: util.Log("可使用 sudo ./ddns-go -s install 安装服务运行") } run() } } } } func run() { // 兼容之前的配置文件 conf, _ := config.GetConfigCached() conf.CompatibleConfig() // 初始化语言 util.InitLogLang(conf.Lang) if !*noWebService { go func() { // 启动web服务 err := runWebServer() if err != nil { log.Println(err) time.Sleep(time.Minute) os.Exit(1) } }() } // 初始化备用DNS util.InitBackupDNS(*customDNS, conf.Lang) // 等待网络连接 util.WaitInternet(dns.Addresses) // 定时运行 dns.RunTimer(time.Duration(*every) * time.Second) } func staticFsFunc(writer http.ResponseWriter, request *http.Request) { http.FileServer(http.FS(staticEmbeddedFiles)).ServeHTTP(writer, request) } func faviconFsFunc(writer http.ResponseWriter, request *http.Request) { http.FileServer(http.FS(faviconEmbeddedFile)).ServeHTTP(writer, request) } func runWebServer() error { // 启动静态文件服务 http.HandleFunc("/static/", web.AuthAssert(staticFsFunc)) http.HandleFunc("/favicon.ico", web.AuthAssert(faviconFsFunc)) http.HandleFunc("/login", web.AuthAssert(web.Login)) http.HandleFunc("/loginFunc", web.AuthAssert(web.LoginFunc)) http.HandleFunc("/", web.Auth(web.Writing)) http.HandleFunc("/save", web.Auth(web.Save)) http.HandleFunc("/logs", web.Auth(web.Logs)) http.HandleFunc("/clearLog", web.Auth(web.ClearLog)) http.HandleFunc("/webhookTest", web.Auth(web.WebhookTest)) http.HandleFunc("/logout", web.Auth(web.Logout)) util.Log("监听 %s", *listen) l, err := net.Listen("tcp", *listen) if err != nil { return errors.New(util.LogStr("监听端口发生异常, 请检查端口是否被占用! %s", err)) } return http.Serve(l, nil) } // 以守护/分离进程方式运行(Unix 使用 setsid,Windows 使用 DETACHED_PROCESS) func runAsDaemon() error { exe, err := os.Executable() if err != nil { return err } // 过滤掉 -d 参数 args := make([]string, 0, len(os.Args)) args = append(args, exe) for i := 1; i < len(os.Args); i++ { if os.Args[i] == "-d" { continue } args = append(args, os.Args[i]) } // 重定向到系统空设备 nullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0) if err != nil { return err } defer nullFile.Close() proc, err := osutil.StartDetachedProcess(exe, args, nullFile) if err != nil { return err } return proc.Release() } type program struct{} func (p *program) Start(s service.Service) error { // Start should not block. Do the actual work async. go p.run() return nil } func (p *program) run() { run() } func (p *program) Stop(s service.Service) error { // Stop should not block. Return with a few seconds. return nil } func getService() service.Service { options := make(service.KeyValue) var depends []string // 确保服务等待网络就绪后再启动 switch service.ChosenSystem().String() { case "unix-systemv": options["SysvScript"] = sysvScript case "windows-service": // 将 Windows 服务的启动类型设为自动(延迟启动) options["DelayedAutoStart"] = true default: // 向 Systemd 添加网络依赖 depends = append(depends, "Requires=network.target", "After=network-online.target") } svcConfig := &service.Config{ Name: "ddns-go", DisplayName: "ddns-go", Description: "Simple and easy to use DDNS. Automatically update domain name resolution to public IP (Support Aliyun, Tencent Cloud, Dnspod, Cloudflare, Callback, Huawei Cloud, Baidu Cloud, Porkbun, GoDaddy...)", Arguments: []string{"-l", *listen, "-f", strconv.Itoa(*every), "-cacheTimes", strconv.Itoa(*ipCacheTimes), "-c", *configFilePath}, Dependencies: depends, Option: options, } if *noWebService { svcConfig.Arguments = append(svcConfig.Arguments, "-noweb") } if *skipVerify { svcConfig.Arguments = append(svcConfig.Arguments, "-skipVerify") } if *customDNS != "" { svcConfig.Arguments = append(svcConfig.Arguments, "-dns", *customDNS) } prg := &program{} s, err := service.New(prg, svcConfig) if err != nil { log.Fatalln(err) } return s } // 卸载服务 func uninstallService() { s := getService() s.Stop() if service.ChosenSystem().String() == "unix-systemv" { if _, err := exec.Command("/etc/init.d/ddns-go", "stop").Output(); err != nil { log.Println(err) } } if err := s.Uninstall(); err == nil { util.Log("ddns-go 服务卸载成功") } else { util.Log("ddns-go 服务卸载失败, 异常信息: %s", err) } } // 安装服务 func installService() { s := getService() status, err := s.Status() if err != nil && status == service.StatusUnknown { // 服务未知,创建服务 if err = s.Install(); err == nil { s.Start() util.Log("安装 ddns-go 服务成功! 请打开浏览器并进行配置") if service.ChosenSystem().String() == "unix-systemv" { if _, err := exec.Command("/etc/init.d/ddns-go", "enable").Output(); err != nil { log.Println(err) } if _, err := exec.Command("/etc/init.d/ddns-go", "start").Output(); err != nil { log.Println(err) } } return } util.Log("安装 ddns-go 服务失败, 异常信息: %s", err) } if status != service.StatusUnknown { util.Log("ddns-go 服务已安装, 无需再次安装") } } // 重启服务 func restartService() { s := getService() status, err := s.Status() if err == nil { if status == service.StatusRunning { if err = s.Restart(); err == nil { util.Log("重启 ddns-go 服务成功") } } else if status == service.StatusStopped { if err = s.Start(); err == nil { util.Log("启动 ddns-go 服务成功") } } } else { util.Log("ddns-go 服务未安装, 请先安装服务") } } const sysvScript = `#!/bin/sh /etc/rc.common DESCRIPTION="{{.Description}}" cmd="{{.Path}}{{range .Arguments}} {{.|cmd}}{{end}}" name="ddns-go" pid_file="/var/run/$name.pid" stdout_log="/var/log/$name.log" stderr_log="/var/log/$name.err" START=99 get_pid() { cat "$pid_file" } is_running() { [ -f "$pid_file" ] && cat /proc/$(get_pid)/stat > /dev/null 2>&1 } start() { if is_running; then echo "Already started" else echo "Starting $name" {{if .WorkingDirectory}}cd '{{.WorkingDirectory}}'{{end}} $cmd >> "$stdout_log" 2>> "$stderr_log" & echo $! > "$pid_file" if ! is_running; then echo "Unable to start, see $stdout_log and $stderr_log" exit 1 fi fi } stop() { if is_running; then echo -n "Stopping $name.." kill $(get_pid) for i in $(seq 1 10) do if ! is_running; then break fi echo -n "." sleep 1 done echo if is_running; then echo "Not stopped; may still be shutting down or shutdown may have failed" exit 1 else echo "Stopped" if [ -f "$pid_file" ]; then rm "$pid_file" fi fi else echo "Not running" fi } restart() { stop if is_running; then echo "Unable to stop, will not attempt to start" exit 1 fi start } ` ================================================ FILE: static/common.css ================================================ :root { color-scheme: light; --bg-color: #f2f3f8; --text-color: black; } [data-theme="dark"] { color-scheme: dark; --bg-color: #22272e; --text-color: #adbac7; } body { background-color: var(--bg-color) !important; } #mask { background-color: #00000088; height: 100%; width: 100%; position: absolute; z-index: 1; } [data-theme='dark'] .form-control { background-color: #1c2128 !important; border-color: #444c56 !important; color: var(--text-color) !important; } [data-theme='dark'] .row { background-color: var(--bg-color); color: var(--text-color); } .portlet { display: -webkit-box; display: flex; -webkit-box-flex: 1; flex-grow: 1; -webkit-box-orient: vertical; -webkit-box-direction: normal; flex-direction: column; box-shadow: 0px 0px 13px 3px rgba(82, 63, 105, 0.05); background-color: #ffffff; margin-bottom: 20px; border-radius: 4px; } [data-theme='dark'] .portlet { background-color: #32353b; color: #adbac7; border: 2px solid #444c56; border-radius: 5px; box-shadow: unset; } .portlet .portlet__head { display: flex; -webkit-box-align: stretch; -webkit-box-pack: justify; justify-content: space-between; position: relative; padding: 0 20px; margin: 0; border-bottom: 1px solid #ebedf2; min-height: 60px; border-top-left-radius: 4px; border-top-right-radius: 4px; align-items: center; font-size: 1.2rem; font-weight: 540; color: #48465b; } [data-theme='dark'] .portlet .portlet__head { border-bottom: 1px solid #444c56; background-color: #2d333b !important; color: #adbac7; } .portlet .portlet__body { display: -webkit-box; display: -ms-flexbox; -webkit-box-orient: vertical; -webkit-box-direction: normal; -ms-flex-direction: column; flex-direction: column; padding: 20px; border-radius: 4px; } [data-theme='dark'] .portlet__body { background-color: #22272e !important; } .navbar { color: #adbac7; position: fixed !important; width: 100vw; z-index: 2; height: 3.5rem; } main { position: relative; padding-top: 3.5rem; overflow: hidden; } [data-theme='dark'] .navbar { background-image: linear-gradient(#2d333b, #22272e) !important; } [data-theme='dark'] .form { background-color: #1c2128 !important; color: #adbac7 !important; border-radius: 5px !important; border-color: #444c56 !important; } [data-theme='dark'] .form-group { background-color: transparent !important; } #logsBtn { position: relative; margin-left: auto; margin-right: 25px; } .unread:after { content: ''; position: absolute; top: 0; right: 0; width: 8px; height: 8px; border-radius: 50%; background-color: #ff0000; } .theme-button { background-color: transparent; cursor: pointer; font-size: 15px; margin-right: 25px; } .theme-button:hover { box-shadow: 0px 0px 15px #0d0d0dab; } .theme-button:active { transform: scale(0.98); } #logs { max-height: 50vh !important; height: 600px !important; margin-bottom: 10px; overflow-y: auto; font-size: 13px !important; background-color: #f6f6f6; } .logs-panel { z-index: 2; background-color: rgb(255, 255, 255); color: var(--text-color); border-radius: 10px; padding: 15px !important; padding-bottom: 10px !important; border: 1px solid #cbcbcb; box-shadow: 0px 0px 13px 3px rgba(52, 52, 52, 0.226); } [data-theme='dark'] .logs-panel { background-color: #22272e; color: #adbac7; border: 1px solid #444c56; box-shadow: unset; } .col-md-6.logs-panel { position: fixed; left: 0; } #msg-container { pointer-events: none; z-index: 3; position: fixed; width: 100vw; padding: 0 5vw; display: flex; flex-direction: column; align-items: center; top: 0; } #msg-container .msg { pointer-events: all; padding: 9px 12px; text-align: center; line-height: 1.5714285714285714; border-radius: 8px; 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); font-size: 14px; background-color: #ffffff; margin: 8px 0; color: var(--text-color); transition: all 0.2s ease-in; } [data-theme='dark'] #msg-container .msg { background-color: #1f1f1f; } #msg-container .msg-fade { opacity: 0; transform: translateY(-1rem) scale(0.6); transition: all 0.2s ease-in-out; } #msg-container .msg-icon { margin-right: 8px; line-height: 0; text-align: center; font-size: 16px; } .badge { margin-right: 20px; /* 给版本号添加右侧间距 */ } .button-container { padding: 0 !important; } .action-button { flex: none; padding: 4px 6px; font-size: 14px; color: white; border: 1px solid white; border-radius: 8px; background-color: transparent; text-align: center; text-decoration: none; } .action-button:hover, .action-button:visited, .action-button:active, .action-button:focus { color: white; border-color: white; background-color: transparent; text-decoration: none; outline: none; } .tooltip[x-placement^="top"] .arrow, .tooltip[x-placement^="bottom"] .arrow { left: 50%; } .tooltip[x-placement^="left"] .arrow, .tooltip[x-placement^="right"] .arrow { top: 50%; } .tooltip[x-placement^="top"] .arrow::before, .tooltip[x-placement^="bottom"] .arrow::before { transform: translateX(-50%); } .tooltip[x-placement^="left"] .arrow::before, .tooltip[x-placement^="right"] .arrow::before { transform: translateY(-50%); } /* Tooltip theme support */ .tooltip .tooltip-inner { background-color: #fff; color: #000; border: 1px solid #ccc; } .tooltip .arrow::before { border-top-color: #fff; border-bottom-color: #fff; border-left-color: #fff; border-right-color: #fff; } [data-theme="dark"] .tooltip .tooltip-inner { background-color: #2d333b; color: #adbac7; border: 1px solid #444c56; } [data-theme="dark"] .tooltip.bs-tooltip-top .arrow::before { border-top-color: #2d333b; } [data-theme="dark"] .tooltip.bs-tooltip-bottom .arrow::before { border-bottom-color: #2d333b; } [data-theme="dark"] .tooltip.bs-tooltip-left .arrow::before { border-left-color: #2d333b; } [data-theme="dark"] .tooltip.bs-tooltip-right .arrow::before { border-right-color: #2d333b; } ================================================ FILE: static/constant.js ================================================ const DNS_PROVIDERS = { alidns: { name: { "en": "Aliyun", "zh-cn": "阿里云", }, idLabel: "AccessKey ID", secretLabel: "AccessKey Secret", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建 AccessKey", } }, aliesa: { name: { "en": "Aliyun ESA", "zh-cn": "阿里云 ESA", }, idLabel: "AccessKey ID", secretLabel: "AccessKey Secret", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建 AccessKey", } }, tencentcloud: { name: { "en": "Tencent", "zh-cn": "腾讯云", }, idLabel: "SecretId", secretLabel: "SecretKey", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建腾讯云 API 密钥", } }, dnspod: { name: { "en": "DnsPod", }, idLabel: "ID", secretLabel: "Token", helpHtml: { "en": "Create Token", "zh-cn": "创建 DNSPod Token", } }, cloudflare: { name: { "en": "Cloudflare", }, idLabel: "", secretLabel: "Token", helpHtml: { "en": "Create Token -> Edit Zone DNS (Use template)", "zh-cn": "创建令牌 -> 编辑区域 DNS (使用模板)", } }, huaweicloud: { name: { "en": "Huawei", "zh-cn": "华为云", }, idLabel: "Access Key Id", secretLabel: "Secret Access Key", helpHtml: { "en": "Create", "zh-cn": "新增访问密钥", } }, callback: { name: { "en": "Callback", }, idLabel: "URL", secretLabel: "RequestBody", helpHtml: { "en": "Callback Support variables #{ip}, #{domain}, #{recordType}, #{ttl}", "zh-cn": "自定义回调 支持的变量 #{ip}, #{domain}, #{recordType}, #{ttl}", } }, baiducloud: { name: { "en": "Baidu", "zh-cn": "百度云", }, idLabel: "AccessKey ID", secretLabel: "AccessKey Secret", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建 AccessKey", } }, porkbun: { name: { "en": "Porkbun", }, idLabel: "API Key", secretLabel: "Secret Key", helpHtml: { "en": "Create Access", "zh-cn": "创建 Access", } }, godaddy: { name: { "en": "GoDaddy", }, idLabel: "Key", secretLabel: "Secret", helpHtml: { "en": "Create API KEY
⚠️ Note: GoDaddy API requires you to have 10 or more domains or a Pro plan", "zh-cn": "创建 API KEY
⚠️ 温馨提示:GoDaddy 现在需要拥有 10 个及以上的域名或 Pro Plan 才可以使用 API", } }, namecheap: { name: { "en": "Namecheap", }, idLabel: "", secretLabel: "Password", helpHtml: { "en": "How to get started Namecheap DDNS does not support updating IPv6", "zh-cn": "开启namecheap动态域名解析 Namecheap DDNS 不支持更新 IPv6", } }, namesilo: { name: { "en": "NameSilo", }, idLabel: "", secretLabel: "Password", helpHtml: { "en": "How to get started Please note that the TTL of namesilo is at least 1 hour", "zh-cn": "开启namesilo动态域名解析 请注意namesilo的TTL最低1小时", } }, vercel: { name: { "en": "Vercel", }, idLabel: "", secretLabel: "Token", helpHtml: { "en": "Create Token", "zh-cn": "创建令牌", }, extParamLabel: "Team ID", extParamHelpHtml: { "en": "Optional. If you are using a Vercel Team account, please fill in the Team ID", "zh-cn": "可选项,如果您使用的是 Vercel 团队账户,请填写团队 ID" } }, dynadot: { name: { "en": "Dynadot", }, idLabel: "", secretLabel: "Password", helpHtml: { "en": "How to get started", "zh-cn": "开启Dynadot动态域名解析", } }, trafficroute: { name: { "en": "TrafficRoute", "zh-cn": "火山引擎", }, idLabel: "AccessKey", secretLabel: "SecretAccessKey", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建火山引擎 API 密钥", } }, dynv6: { name: { "en": "Dynv6", }, idLabel: "", secretLabel: "Token", helpHtml: { "en": "Create Token", "zh-cn": "创建令牌", } }, spaceship: { name: { "en": "Spaceship", }, idLabel: "API Key", secretLabel: "API Secret", helpHtml: { "en": "Create API Key", "zh-cn": "创建 API 密钥", } }, dnsla: { name: { "en": "Dnsla", "zh-cn": "Dnsla", }, idLabel: "APIID", secretLabel: "API密钥", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建 AccessKey", } }, nowcn: { name: { "en": "Nowcn", "zh-cn": "时代互联", }, idLabel: "auth-userid", secretLabel: "api-key", helpHtml: { "en": "api-key", "zh-cn": "获取 api-key", } }, eranet: { name: { "en": "Eranet", "zh-cn": "Eranet", }, idLabel: "auth-userid", secretLabel: "api-key", helpHtml: { "en": "api-key", "zh-cn": "获取 api-key", } }, gcore: { name: { "en": "Gcore", }, idLabel: "", secretLabel: "API Token", helpHtml: { "en": "Create API Token", "zh-cn": "创建 API Token", } }, edgeone: { name: { "en": "Edgeone", "zh-cn": "Edgeone", }, idLabel: "SecretId", secretLabel: "SecretKey", helpHtml: { "en": "Create AccessKey", "zh-cn": "创建腾讯云 API 密钥", } }, nsone: { name: { "en": "IBM NS1 Connect", "zh-cn": "IBM NS1 Connect", }, idLabel: "", secretLabel: "API Key", helpHtml: { "en": "Create API Key", "zh-cn": "创建 API 密钥", } }, name_com: { name: { "en": "name.com", "zh-cn": "name.com", }, idLabel: "username", secretLabel: "token", helpHtml: { "en": "name.com Create API Token", "zh-cn": "name.com 创建 API Token", } }, rainyun: { name: { "en": "Rainyun", "zh-cn": "雨云", }, idLabel: "Domain ID", secretLabel: "API Key", helpHtml: { "en": "Get Domain ID" + " Get API Token", "zh-cn": "获取 Domain ID" + " 获取 API Token", } }, }; const SVG_CODE = { success: ``, info: ``, warning: '', error: '' } ================================================ FILE: static/i18n.js ================================================ const I18N_MAP = { 'Logs': { 'en': 'Logs', 'zh-cn': '日志' }, 'Save': { 'en': 'Save', 'zh-cn': '保存' }, 'Config:': { 'en': 'Config:', 'zh-cn': '配置切换:' }, 'Add': { 'en': 'Add', 'zh-cn': '添加' }, 'Rename': { 'en': 'Rename', 'zh-cn': '重命名' }, 'RenameHelp': { 'en': 'Enter a new name:', 'zh-cn': '输入新名称:' }, 'Delete': { 'en': 'Delete', 'zh-cn': '删除' }, 'DNS Provider': { 'en': 'DNS Provider', 'zh-cn': 'DNS服务商' }, 'Create AccessKey': { 'en': 'Create AccessKey', 'zh-cn': '创建 AccessKey' }, 'Auto': { 'en': 'Auto', 'zh-cn': '自动' }, '1s': { 'en': '1s', 'zh-cn': '1秒' }, '5s': { 'en': '5s', 'zh-cn': '5秒' }, '10s': { 'en': '10s', 'zh-cn': '10秒' }, '1m': { 'en': '1m', 'zh-cn': '1分钟' }, '2m': { 'en': '2m', 'zh-cn': '2分钟' }, '10m': { 'en': '10m', 'zh-cn': '10分钟' }, '30m': { 'en': '30m', 'zh-cn': '30分钟' }, '1h': { 'en': '1h', 'zh-cn': '1小时' }, 'ttlHelp': { 'en': 'You can modify it if the account supports a smaller TTL. The TTL will only be updated when the IP changes', 'zh-cn': '如账号支持更小的 TTL, 可修改。IP 有变化时才会更新TTL' }, 'Enabled': { 'en': 'Enabled', 'zh-cn': '是否启用' }, 'Get IP method': { 'en': 'Get IP method', 'zh-cn': '获取 IP 方式' }, 'By api': { 'en': 'By api', 'zh-cn': '通过接口获取' }, 'By network card': { 'en': 'By network card', 'zh-cn': '通过网卡获取' }, 'By command': { 'en': 'By command', 'zh-cn': '通过命令获取' }, 'domainsHelp': { 'en': ` Enter one domain per line. If the domain is unregistrable, manually separate it into a subdomain and a root domain by using a colon. e.g. www:domain.example.com
Support for custom parameters (Simplified Chinese) `, 'zh-cn': ` 每行一个域名。 如果域名不可注册,请使用冒号手动将其分为子域名和根域名。如 www:domain.example.com
支持自定义参数 ` }, 'Regular exp.': { 'en': 'Regular exp.', 'zh-cn': '匹配正则表达式' }, 'regHelp': { 'en': 'You can use @1 to specify the first IPv6 address, @2 to specify the second IPv6 address... You can also use regular expressions to match the specified IPv6 address, leave it blank to disable it', 'zh-cn': '可使用 @1 指定第一个IPv6地址, @2 指定第二个IPv6地址... 也可使用正则表达式匹配指定的IPv6地址, 留空则不启用' }, 'Others': { 'en': 'Others', 'zh-cn': '其他' }, 'Deny from WAN': { 'en': 'Deny from WAN', 'zh-cn': '禁止公网访问' }, 'NotAllowWanAccessHelp': { 'en': 'Enable to deny access from the public network', 'zh-cn': '启用后禁止从公网访问此页面' }, 'Username': { 'en': 'Username', 'zh-cn': '用户名' }, 'accountHelp': { 'en': 'Username/Password is required', 'zh-cn': '必须输入用户名/密码' }, 'passwordHelp': { 'en': 'If you need to change the password, please enter it here', 'zh-cn': '如需修改密码,请在此处输入新密码' }, 'Password': { 'en': 'Password', 'zh-cn': '密码' }, 'WebhookURLHelp': { 'en': ` Click to get more info
Support variables #{ipv4Addr}, #{ipv4Result}, #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains} `, 'zh-cn': ` 点击参考官方 Webhook 说明
支持的变量 #{ipv4Addr}, #{ipv4Result}, #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains} ` }, 'WebhookRequestBodyHelp': { 'en': 'If RequestBody is empty, it is a GET request, otherwise it is a POST request. Supported variables are the same as above', 'zh-cn': '如果 RequestBody 为空, 则为 GET 请求, 否则为 POST 请求。支持的变量同上' }, 'WebhookHeadersHelp': { 'en': 'One header per line, such as: Authorization: Bearer API_KEY', 'zh-cn': '一行一个Header, 如: Authorization: Bearer API_KEY' }, 'Try it': { 'en': 'Try it', 'zh-cn': '模拟测试Webhook' }, 'Clear': { 'en': 'Clear', 'zh-cn': '清空' }, 'OK': { 'en': 'OK', 'zh-cn': '确定' }, "Ipv4UrlHelp": { 'en': "https://api.ipify.org, https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson", 'zh-cn': "https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson" }, "Ipv6UrlHelp": { 'en': "https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson", 'zh-cn': "https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson" }, "Ipv4NetInterfaceHelp": { 'en': "Get IPv4 address through network card", 'zh-cn': "通过网卡获取IPv4" }, "Ipv6NetInterfaceHelp": { 'en': "If you do not specify a matching regular expression, the first IPv6 address will be used by default", 'zh-cn': "如不指定匹配正则表达式,将默认使用第一个 IPv6 地址" }, "Ipv4CmdHelp": { 'en': "Get IPv4 through command, only use the first matching IPv4 address of standard output(stdout). Such as: ip -4 addr show eth1", 'zh-cn': ` 通过命令获取IPv4, 仅使用标准输出(stdout)的第一个匹配的 IPv4 地址。如: ip -4 addr show eth1 点击参考更多 ` }, "Ipv6CmdHelp": { 'en': "Get IPv6 through command, only use the first matching IPv6 address of standard output(stdout). Such as: ip -6 addr show eth1", 'zh-cn': ` 通过命令获取IPv6, 仅使用标准输出(stdout)的第一个匹配的 IPv6 地址。如: ip -6 addr show eth1 点击参考更多 ` }, "NetInterfaceEmptyHelp": { 'en': 'No available network card found', 'zh-cn': '没有找到可用的网卡' }, "Http Interface": { 'en': 'Http Interface', 'zh-cn': 'HTTP 请求网卡' }, "Default": { 'en': 'Default', 'zh-cn': '默认' }, "HttpInterfaceHelp": { 'en': 'Bind HTTP requests to a specific network interface (similar to curl --interface). Leave empty to use the default.', 'zh-cn': '发送 HTTP 请求时绑定指定网卡(类似 curl --interface)。留空则使用默认网卡。' }, "Login": { 'en': 'Login', 'zh-cn': '登录' }, "LoginInit": { 'en': 'Login and configure as an administrator account', 'zh-cn': '登录并配置为管理员账号' }, "Logout": { 'en': 'Logout', 'zh-cn': '注销' }, "webhookTestTooltip": { 'en': 'Send a fake data to the Webhook URL immediately to test if the Webhook is working properly', 'zh-cn': '立即发送一条假数据到Webhook URL,用于测试Webhook是否正常工作' }, "themeTooltip": { 'en': 'Click: Switch theme
Long press: Restore auto mode', 'zh-cn': '单击:切换明暗主题
长按:恢复自动跟随系统' }, "extParamHelp": { 'en': 'Optional. If you are using a Vercel Team account, please fill in the Team ID', 'zh-cn': '可选项,如果您使用的是 Vercel 团队账户,请填写团队 ID' }, }; const LANG = localStorage.getItem('lang') || (navigator.language || navigator.browserLanguage).replaceAll('_', '-').toLowerCase(); const getLocalLang = (langs) => { // 优先取地区语言 if (langs.includes(LANG)) { return LANG; } // 其次取表示语言 if (langs.includes(LANG.split('-')[0])) { return LANG.split('-')[0]; } // 再取表示语言相同的地区语言 for (const l of langs) { if (l.split('-')[0] === LANG.split('-')[0]) { return l; } } // 无法匹配则取英文 return 'en'; } // 支持两种调用方式: // 1. 文本在I18N字典中的key,如"hello" // 2. 语言字符串字典,{en: "hello", zh: "你好"} const i18n = (keyOrLangDict) => { let key = keyOrLangDict; let langDict = keyOrLangDict; if (typeof keyOrLangDict === 'string') { langDict = I18N_MAP[keyOrLangDict]; } else { key = null; } if (!langDict) { console.warn(`i18n: No translation for key "${key}"`); return key; } const lang = getLocalLang(Object.keys(langDict)); if (lang in langDict) { return langDict[lang]; } console.warn(`i18n: No such language "${lang}" in langDict ${langDict}`); return key; } const convertDom = (dom = document) => { dom.querySelectorAll('[data-i18n]').forEach(el => { const key = el.dataset.i18n; el.textContent = i18n(key); }); dom.querySelectorAll('[data-i18n-html]').forEach(el => { const key = el.dataset.i18nHtml; el.innerHTML = i18n(key); }); dom.querySelectorAll('[data-i18n-attr]').forEach(el => { el.dataset.i18nAttr.split(',').forEach(item => { let [attr, key] = item.split(':'); attr = attr.trim(); key = key || el.getAttribute(attr); el.setAttribute(attr, i18n(key)); }); }); } document.addEventListener('DOMContentLoaded', () => { convertDom(); }); ================================================ FILE: static/theme-button.css ================================================ /* From https://css.gg */ .gg-dark-mode { box-sizing: border-box; position: relative; display: block; transform: scale(var(--ggs, 1)); border: 2px solid; border-radius: 100px; width: 20px; height: 20px } .gg-dark-mode::after, .gg-dark-mode::before { content: ""; box-sizing: border-box; position: absolute; display: block } .gg-dark-mode::before { border: 5px solid; border-top-left-radius: 100px; border-bottom-left-radius: 100px; border-right: 0; width: 9px; height: 18px; top: -1px; left: -1px } .gg-dark-mode::after { border: 4px solid; border-top-right-radius: 100px; border-bottom-right-radius: 100px; border-left: 0; width: 4px; height: 8px; right: 4px; top: 4px } ================================================ FILE: static/theme.js ================================================ function updateColorSchemeMeta(isDark) { const meta = document.querySelector('meta[name="color-scheme"]'); if (meta) { meta.setAttribute('content', isDark ? 'dark' : 'light'); } } function toggleTheme(write = false) { const docEle = document.documentElement; if (docEle.getAttribute("data-theme") === "dark") { docEle.removeAttribute("data-theme"); updateColorSchemeMeta(false); write && localStorage.setItem("theme", "light"); } else { docEle.setAttribute("data-theme", "dark"); updateColorSchemeMeta(true); write && localStorage.setItem("theme", "dark"); } } const theme = localStorage.getItem("theme") ?? (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"); if (theme === "dark") { toggleTheme(); } // 长按重置功能 let pressTimer = null; let isLongPress = false; const button = document.getElementById("themeButton"); function startPress() { isLongPress = false; // 800ms后触发长按 pressTimer = setTimeout(() => { isLongPress = true; // 清除用户偏好,恢复自动模式 localStorage.removeItem("theme"); // 立即同步系统主题状态 const systemIsDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const currentlyDark = document.documentElement.getAttribute("data-theme") === "dark"; if (systemIsDark !== currentlyDark) { toggleTheme(); } // 显示成功提示 showMessage({ content: i18n({ "en": "Theme has been restored to auto mode", "zh-cn": "主题已恢复自动跟随系统" }), type: "success", duration: 2000 }); }, 800); } function endPress() { clearTimeout(pressTimer); // 短按才执行切换 if (!isLongPress) { toggleTheme(true); } } function cancelPress() { clearTimeout(pressTimer); } // 鼠标事件 button.addEventListener('mousedown', startPress); button.addEventListener('mouseup', endPress); button.addEventListener('mouseleave', cancelPress); // 触摸事件(移动设备) button.addEventListener('touchstart', (e) => { e.preventDefault(); // 防止触发点击 startPress(); }); button.addEventListener('touchmove', cancelPress); button.addEventListener('touchend', endPress); button.addEventListener('touchcancel', cancelPress); // 系统主题变化监听器 // 仅在自动模式下响应(即用户未手动设置偏好时) window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", (e) => { if (!localStorage.getItem("theme")) { // 只有在没有用户偏好时才自动切换 const shouldBeDark = e.matches; const currentlyDark = document.documentElement.getAttribute("data-theme") === "dark"; if (shouldBeDark !== currentlyDark) { toggleTheme(); } } }); ================================================ FILE: static/tooltips.js ================================================ class Tooltip { constructor(element, triggers) { this.$element = element; this.$tooltip = null; this.originalTitle = ''; this._bindEvents(triggers); } _createTooltipElement(options) { const title = options.title || this.$element.dataset.title || this.originalTitle; if (!title) { return; } const useHtml = options.hasOwnProperty('html') ? options.html : this.$element.dataset.html === 'true'; let placement = options.placement || this.$element.dataset.placement || 'auto'; if (placement === 'auto') { const rect = this.$element.getBoundingClientRect(); const viewportWidth = window.innerWidth || document.documentElement.clientWidth; const viewportHeight = window.innerHeight || document.documentElement.clientHeight; const space = { top: rect.top, bottom: viewportHeight - rect.bottom, left: rect.left, right: viewportWidth - rect.right }; placement = Object.keys(space).reduce((a, b) => space[a] > space[b] ? a : b); } this.$tooltip = html2Element(` `) if (useHtml) { this.$tooltip.querySelector('.tooltip-inner').innerHTML = title } else { this.$tooltip.querySelector('.tooltip-inner').textContent = title } } _updatePosition() { const elRect = this.$element.getBoundingClientRect() const bodyRect = document.body.getBoundingClientRect() const tooltipRect = this.$tooltip.getBoundingClientRect() const placement = this.$tooltip.getAttribute('x-placement') let left, top; switch(placement) { case 'top': left = elRect.left + (elRect.width - tooltipRect.width) / 2 top = elRect.top - tooltipRect.height - 8 break case 'bottom': left = elRect.left + (elRect.width - tooltipRect.width) / 2 top = elRect.bottom + 8 break case 'left': left = elRect.left - tooltipRect.width - 8 top = elRect.top + (elRect.height - tooltipRect.height) / 2 break case 'right': left = elRect.right + 8 top = elRect.top + (elRect.height - tooltipRect.height) / 2 break } // 考虑滚动条的影响 left = left - bodyRect.left top = top - bodyRect.top this.$tooltip.style.left = `${left}px` this.$tooltip.style.top = `${top}px` } async show(options = {}) { if (this.$tooltip) { this.$tooltip.remove(); } if (this.$element.title) { this.originalTitle = this.$element.title; this.$element.title = ''; } this._createTooltipElement(options); if (!this.$tooltip) { return; } document.body.appendChild(this.$tooltip); await delay(0); if (!this.$tooltip) { return; } this._updatePosition(); this.$tooltip.classList.add('show'); } async hide() { if (this.originalTitle && !this.$element.title) { this.$element.title = this.originalTitle; } if (!this.$tooltip) { return; } this.$tooltip.classList.remove('show'); await delay(200); if (!this.$tooltip) { return; } this.$tooltip.remove(); this.$tooltip = null; } _bindEvents(triggers) { let state = 0; const _enter = () => { state += 1; this.show(); }; const _leave = () => { state -= 1; if (state <= 0) { this.hide(); } }; if (!triggers) { triggers = (this.$element.dataset.trigger || 'hover focus').split(' '); } triggers.forEach(trigger => { switch(trigger) { case 'hover': this.$element.addEventListener('mouseenter', _enter); this.$element.addEventListener('mouseleave', _leave); break; case 'focus': this.$element.addEventListener('focusin', _enter); this.$element.addEventListener('focusout', _leave); break; case 'click': this.$element.addEventListener('click', () => { if (this.$tooltip) { this.hide(); } else { this.show(); } }); break; case 'manual': break; default: console.warn(`Unknown trigger: ${trigger}`); } }); } } // 初始化所有带data-tooltip属性的元素 const initTooltips = () => { window.tooltips = {}; document.querySelectorAll('[data-toggle="tooltip"]').forEach(element => { let key = element.dataset.tooltipKey || element.id; if (!key) { key = crypto.randomUUID(); element.dataset.tooltipKey = key; } window.tooltips[key] = new Tooltip(element); }); }; // 页面加载完成后初始化 document.addEventListener('DOMContentLoaded', initTooltips); ================================================ FILE: static/utils.js ================================================ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) const html2Element = (htmlString) => { const doc = new DOMParser().parseFromString(htmlString, 'text/html') return doc.body.firstElementChild } // 在页面顶部显示一行消息,并在若干秒后自动消失 const showMessage = async (msgObj) => { // 填充默认值 msgObj = Object.assign({ type: 'info', content: '', html: false, duration: 3000 }, msgObj) // 当前是否有消息容器 let $container = document.getElementById('msg-container') if (!$container) { // 创建消息容器 $container = html2Element('
') document.body.appendChild($container) } // 创建消息元素 const $msg = html2Element('
') // 创建两个span,用于显示消息的图标和内容 const $content = html2Element('') // 填充内容,根据html属性决定使用text还是html if (msgObj.html) { $content.innerHTML = msgObj.content } else { $content.textContent = msgObj.content } // 根据消息类型设置图标 $msg.innerHTML = `${SVG_CODE[msgObj.type]}` $msg.appendChild($content) $container.appendChild($msg) // 确保动画生效 await delay(0) $msg.classList.remove('msg-fade') // 等待动画结束 await delay(200) // 销毁函数 const destroy = async () => { // 增加消失动画 $msg.classList.add('msg-fade') // 动画结束后移除元素 await delay(200) $msg.remove() // 如果容器中没有消息了,移除容器 if (!$container.children.length) { $container.remove() } } // 如果duration为0,则不自动消失 if (msgObj.duration === 0) { return destroy } // 自动消失计时器 let timer = setTimeout(destroy, msgObj.duration) // 注册鼠标事件,鼠标移入时取消自动消失 $msg.addEventListener('mouseenter', () => { clearTimeout(timer) }) // 鼠标移出时重新计时 $msg.addEventListener('mouseleave', () => { timer = setTimeout(destroy, msgObj.duration) }) return destroy } const request = { baseURL: './', parse: async function(resp) { const text = await resp.text() try { return JSON.parse(text) } catch (e) { return text } }, stringify: function(dict) { const result = [] for (let key in dict) { if (!dict.hasOwnProperty(key)) { continue } // 所有空值将被删除 if (String(dict[key])) { result.push(`${key}=${encodeURIComponent(dict[key])}`) } } return result.join('&') }, get: async function(path, data, parseFunc) { const response = await fetch(`${this.baseURL}${path}?${this.stringify(data)}`) if (response.redirected) { window.location.href = response.url } return await (parseFunc||this.parse)(response) }, post: async function(path, data, parseFunc) { if (typeof data === 'object') { data = JSON.stringify(data) } const response = await fetch(`${this.baseURL}${path}`, { method: 'POST', body: data }) if (response.redirected) { window.location.href = response.url } return await (parseFunc||this.parse)(response) } } ================================================ FILE: util/aliyun_signer.go ================================================ package util import ( "crypto/hmac" "crypto/md5" "crypto/sha1" "crypto/sha256" "encoding/base64" "fmt" "hash" "io" "net/url" ) // https://github.com/rosbit/aliyun-sign/blob/master/aliyun-sign.go var ( signMethodMap = map[string]func() hash.Hash{ "HMAC-SHA1": sha1.New, "HMAC-SHA256": sha256.New, "HMAC-MD5": md5.New, } ) func HmacSign(signMethod string, httpMethod string, appKeySecret string, vals url.Values) (signature []byte) { key := []byte(appKeySecret + "&") var h hash.Hash if method, ok := signMethodMap[signMethod]; ok { h = hmac.New(method, key) } else { h = hmac.New(sha1.New, key) } makeDataToSign(h, httpMethod, vals) return h.Sum(nil) } func HmacSignToB64(signMethod string, httpMethod string, appKeySecret string, vals url.Values) (signature string) { return base64.StdEncoding.EncodeToString(HmacSign(signMethod, httpMethod, appKeySecret, vals)) } type strToEnc struct { s string e bool } func makeDataToSign(w io.Writer, httpMethod string, vals url.Values) { in := make(chan *strToEnc) go func() { in <- &strToEnc{s: httpMethod} in <- &strToEnc{s: "&"} in <- &strToEnc{s: "/", e: true} in <- &strToEnc{s: "&"} in <- &strToEnc{s: vals.Encode(), e: true} close(in) }() specialUrlEncode(in, w) } var ( encTilde = "%7E" // '~' -> "%7E" encBlank = []byte("%20") // ' ' -> "%20" tilde = []byte("~") ) func specialUrlEncode(in <-chan *strToEnc, w io.Writer) { for s := range in { if !s.e { io.WriteString(w, s.s) continue } l := len(s.s) for i := 0; i < l; { ch := s.s[i] switch ch { case '%': if encTilde == s.s[i:i+3] { w.Write(tilde) i += 3 continue } fallthrough case '*', '/', '&', '=': fmt.Fprintf(w, "%%%02X", ch) case '+': w.Write(encBlank) default: fmt.Fprintf(w, "%c", ch) } i += 1 } } } ================================================ FILE: util/aliyun_signer_util.go ================================================ package util import ( "net/url" "strconv" "time" ) // AliyunSigner AliyunSigner func AliyunSigner(accessKeyID, accessSecret string, params *url.Values, httpMethod string, apiVersion string) { // 公共参数 params.Set("SignatureMethod", "HMAC-SHA1") params.Set("SignatureNonce", strconv.FormatInt(time.Now().UnixNano(), 10)) params.Set("AccessKeyId", accessKeyID) params.Set("SignatureVersion", "1.0") params.Set("Timestamp", time.Now().UTC().Format("2006-01-02T15:04:05Z")) params.Set("Format", "JSON") params.Set("Version", apiVersion) params.Set("Signature", HmacSignToB64("HMAC-SHA1", httpMethod, accessSecret, *params)) } ================================================ FILE: util/andriod_time.go ================================================ package util import ( "os/exec" "strings" "time" ) func FixTimezone() { out, err := exec.Command("/system/bin/getprop", "persist.sys.timezone").Output() if err != nil { return } timeZone, err := time.LoadLocation(strings.TrimSpace(string(out))) if err != nil { return } time.Local = timeZone } ================================================ FILE: util/baidu_signer.go ================================================ package util import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "strings" "time" ) // https://cloud.baidu.com/doc/Reference/s/Njwvz1wot const ( BaiduDateFormat = "2006-01-02T15:04:05Z" expirationPeriod = "1800" ) func HmacSha256Hex(secret, message string) string { key := []byte(secret) h := hmac.New(sha256.New, key) h.Write([]byte(message)) sha := hex.EncodeToString(h.Sum(nil)) return sha } func BaiduCanonicalURI(r *http.Request) string { patterns := strings.Split(r.URL.Path, "/") var uri []string for _, v := range patterns { uri = append(uri, escape(v)) } urlpath := strings.Join(uri, "/") if len(urlpath) == 0 || urlpath[len(urlpath)-1] != '/' { urlpath = urlpath + "/" } return urlpath[0 : len(urlpath)-1] } // BaiduSigner set Authorization header func BaiduSigner(accessKeyID, accessSecret string, r *http.Request) { //format: bce-auth-v1/{accessKeyId}/{timestamp}/{expirationPeriodInSeconds} authStringPrefix := "bce-auth-v1/" + accessKeyID + "/" + time.Now().UTC().Format(BaiduDateFormat) + "/" + expirationPeriod baiduCanonicalURL := BaiduCanonicalURI(r) //format: HTTP Method + "\n" + CanonicalURI + "\n" + CanonicalQueryString + "\n" + CanonicalHeaders //由于仅仅调用三个POST接口且不会更改,这里CanonicalQueryString和CanonicalHeaders直接写死 CanonicalReq := fmt.Sprintf("%s\n%s\n%s\n%s", r.Method, baiduCanonicalURL, "", "host:bcd.baidubce.com") signingKey := HmacSha256Hex(accessSecret, authStringPrefix) signature := HmacSha256Hex(signingKey, CanonicalReq) //format: authStringPrefix/{signedHeaders}/{signature} authString := authStringPrefix + "/host/" + signature r.Header.Set(HeaderAuthorization, authString) } ================================================ FILE: util/bcrypt.go ================================================ package util import ( "golang.org/x/crypto/bcrypt" ) // HashPassword 密码哈希 func HashPassword(password string) (string, error) { hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) if err != nil { return "", err } return string(hashedPassword), nil } // PasswordOK 检查密码 func PasswordOK(hashedPassword, password string) bool { err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) return err == nil } // IsHashedPassword 是否是哈希密码 func IsHashedPassword(password string) bool { _, err := bcrypt.Cost([]byte(password)) return err == nil } ================================================ FILE: util/copy_url_params.go ================================================ package util import "net/url" func CopyUrlParams(src url.Values, dest url.Values, keys []string) { if keys == nil || len(keys) == 0 { for key := range src { dest.Set(key, src.Get(key)) } } else { for _, key := range keys { val := src.Get(key) if val != "" { dest.Set(key, val) } } } } ================================================ FILE: util/docker_util.go ================================================ package util import "os" // DockerEnvFile Docker容器中包含的文件 const DockerEnvFile string = "/.dockerenv" // IsRunInDocker 是否在docker中运行 func IsRunInDocker() bool { _, err := os.Stat(DockerEnvFile) return err == nil } ================================================ FILE: util/escape.go ================================================ // based on https://github.com/golang/go/blob/master/src/net/url/url.go // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. package util func shouldEscape(c byte) bool { if 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '_' || c == '-' || c == '~' || c == '.' { return false } return true } func escape(s string) string { hexCount := 0 for i := 0; i < len(s); i++ { c := s[i] if shouldEscape(c) { hexCount++ } } if hexCount == 0 { return s } t := make([]byte, len(s)+2*hexCount) j := 0 for i := 0; i < len(s); i++ { switch c := s[i]; { case shouldEscape(c): t[j] = '%' t[j+1] = "0123456789ABCDEF"[c>>4] t[j+2] = "0123456789ABCDEF"[c&15] j += 3 default: t[j] = s[i] j++ } } return string(t) } ================================================ FILE: util/http_client_util.go ================================================ package util import ( "context" "crypto/tls" "fmt" "net" "net/http" "time" ) var dialer = &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, } var defaultTransport = &http.Transport{ // from http.DefaultTransport Proxy: http.ProxyFromEnvironment, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { return dialer.DialContext(ctx, network, address) }, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } // insecureSkipVerify 全局TLS验证跳过标志 var insecureSkipVerify bool // CreateHTTPClient Create Default HTTP Client func CreateHTTPClient() *http.Client { return &http.Client{ Timeout: 30 * time.Second, Transport: defaultTransport, } } // GetLocalAddrFromInterface 根据网卡名称获取本地IP地址 func GetLocalAddrFromInterface(ifaceName string) (string, error) { iface, err := net.InterfaceByName(ifaceName) if err != nil { return "", fmt.Errorf("找不到网卡 %s: %v", ifaceName, err) } addrs, err := iface.Addrs() if err != nil { return "", fmt.Errorf("获取网卡 %s 地址失败: %v", ifaceName, err) } for _, addr := range addrs { if ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.IsGlobalUnicast() { return ipNet.IP.String(), nil } } return "", fmt.Errorf("网卡 %s 没有可用的单播地址", ifaceName) } // CreateHTTPClientWithInterface 创建绑定指定网卡的HTTP客户端 func CreateHTTPClientWithInterface(ifaceName string) *http.Client { if ifaceName == "" { return CreateHTTPClient() } localIP, err := GetLocalAddrFromInterface(ifaceName) if err != nil { Log("绑定网卡失败, 将使用默认网卡. 网卡: %s, 错误: %s", ifaceName, err) return CreateHTTPClient() } localAddr := &net.TCPAddr{IP: net.ParseIP(localIP)} boundDialer := &net.Dialer{ Timeout: 30 * time.Second, KeepAlive: 30 * time.Second, LocalAddr: localAddr, } transport := &http.Transport{ Proxy: http.ProxyFromEnvironment, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { return boundDialer.DialContext(ctx, network, address) }, ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } if insecureSkipVerify { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } return &http.Client{ Timeout: 30 * time.Second, Transport: transport, } } var noProxyTcp4Transport = &http.Transport{ // no proxy // DisableKeepAlives DisableKeepAlives: true, // tcp4 DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { return dialer.DialContext(ctx, "tcp4", address) }, // from http.DefaultTransport ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } var noProxyTcp6Transport = &http.Transport{ // no proxy // DisableKeepAlives DisableKeepAlives: true, // tcp6 DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { return dialer.DialContext(ctx, "tcp6", address) }, // from http.DefaultTransport ForceAttemptHTTP2: true, MaxIdleConns: 100, IdleConnTimeout: 90 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } // CreateNoProxyHTTPClient Create NoProxy HTTP Client func CreateNoProxyHTTPClient(network string) *http.Client { if network == "tcp6" { return &http.Client{ Timeout: 30 * time.Second, Transport: noProxyTcp6Transport, } } return &http.Client{ Timeout: 30 * time.Second, Transport: noProxyTcp4Transport, } } // SetInsecureSkipVerify 将所有 http.Transport 的 InsecureSkipVerify 设置为 true func SetInsecureSkipVerify() { insecureSkipVerify = true transports := []*http.Transport{defaultTransport, noProxyTcp4Transport, noProxyTcp6Transport} for _, transport := range transports { transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} } } ================================================ FILE: util/http_util.go ================================================ package util import ( "encoding/json" "fmt" "io" "net/http" ) // GetHTTPResponse 处理HTTP结果,返回序列化的json func GetHTTPResponse(resp *http.Response, err error, result interface{}) error { body, err := GetHTTPResponseOrg(resp, err) if err == nil { // log.Println(string(body)) if len(body) != 0 { err = json.Unmarshal(body, &result) } } return err } // GetHTTPResponseOrg 处理HTTP结果,返回byte func GetHTTPResponseOrg(resp *http.Response, err error) ([]byte, error) { if err != nil { return nil, err } defer resp.Body.Close() lr := io.LimitReader(resp.Body, 1024000) body, err := io.ReadAll(lr) if err != nil { return nil, err } // 300及以上状态码都算异常 if resp.StatusCode >= 300 { err = fmt.Errorf("%s", LogStr("返回内容: %s ,返回状态码: %d", string(body), resp.StatusCode)) } return body, err } ================================================ FILE: util/huawei_signer.go ================================================ // HWS API Gateway Signature // based on https://github.com/datastream/aws/blob/master/signv4.go // Copyright (c) 2014, Xianjie package util import ( "bytes" "crypto/hmac" "crypto/sha256" "fmt" "io" "net/http" "sort" "strings" "time" ) const ( BasicDateFormat = "20060102T150405Z" Algorithm = "SDK-HMAC-SHA256" HeaderXDate = "X-Sdk-Date" HeaderHost = "host" HeaderAuthorization = "Authorization" HeaderContentSha256 = "X-Sdk-Content-Sha256" ) func hmacsha256(key []byte, data string) ([]byte, error) { h := hmac.New(sha256.New, []byte(key)) if _, err := h.Write([]byte(data)); err != nil { return nil, err } return h.Sum(nil), nil } // Build a CanonicalRequest from a regular request string // // CanonicalRequest = // // HTTPRequestMethod + '\n' + // CanonicalURI + '\n' + // CanonicalQueryString + '\n' + // CanonicalHeaders + '\n' + // SignedHeaders + '\n' + // HexEncode(Hash(RequestPayload)) func CanonicalRequest(r *http.Request, signedHeaders []string) (string, error) { var hexencode string var err error if hex := r.Header.Get(HeaderContentSha256); hex != "" { hexencode = hex } else { data, err := RequestPayload(r) if err != nil { return "", err } hexencode, err = HexEncodeSHA256Hash(data) if err != nil { return "", err } } 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 } // CanonicalURI returns request uri func CanonicalURI(r *http.Request) string { patterns := strings.Split(r.URL.Path, "/") var uri []string for _, v := range patterns { uri = append(uri, escape(v)) } urlpath := strings.Join(uri, "/") if len(urlpath) == 0 || urlpath[len(urlpath)-1] != '/' { urlpath = urlpath + "/" } return urlpath } // CanonicalQueryString func CanonicalQueryString(r *http.Request) string { var keys []string query := r.URL.Query() for key := range query { keys = append(keys, key) } sort.Strings(keys) var a []string for _, key := range keys { k := escape(key) sort.Strings(query[key]) for _, v := range query[key] { kv := fmt.Sprintf("%s=%s", k, escape(v)) a = append(a, kv) } } queryStr := strings.Join(a, "&") r.URL.RawQuery = queryStr return queryStr } // CanonicalHeaders func CanonicalHeaders(r *http.Request, signerHeaders []string) string { var a []string header := make(map[string][]string) for k, v := range r.Header { header[strings.ToLower(k)] = v } for _, key := range signerHeaders { value := header[key] if strings.EqualFold(key, HeaderHost) { value = []string{r.Host} } sort.Strings(value) for _, v := range value { a = append(a, key+":"+strings.TrimSpace(v)) } } return fmt.Sprintf("%s\n", strings.Join(a, "\n")) } // SignedHeaders func SignedHeaders(r *http.Request) []string { var a []string for key := range r.Header { a = append(a, strings.ToLower(key)) } sort.Strings(a) return a } // RequestPayload func RequestPayload(r *http.Request) ([]byte, error) { if r.Body == nil { return []byte(""), nil } b, err := io.ReadAll(r.Body) if err != nil { return []byte(""), err } r.Body = io.NopCloser(bytes.NewBuffer(b)) return b, err } // Create a "String to Sign". func StringToSign(canonicalRequest string, t time.Time) (string, error) { hash := sha256.New() _, err := hash.Write([]byte(canonicalRequest)) if err != nil { return "", err } return fmt.Sprintf("%s\n%s\n%x", Algorithm, t.UTC().Format(BasicDateFormat), hash.Sum(nil)), nil } // Create the HWS Signature. func SignStringToSign(stringToSign string, signingKey []byte) (string, error) { hm, err := hmacsha256(signingKey, stringToSign) return fmt.Sprintf("%x", hm), err } // HexEncodeSHA256Hash returns hexcode of sha256 func HexEncodeSHA256Hash(body []byte) (string, error) { hash := sha256.New() if body == nil { body = []byte("") } _, err := hash.Write(body) return fmt.Sprintf("%x", hash.Sum(nil)), err } // Get the finalized value for the "Authorization" header. The signature parameter is the output from SignStringToSign func AuthHeaderValue(signature, accessKey string, signedHeaders []string) string { return fmt.Sprintf("%s Access=%s, SignedHeaders=%s, Signature=%s", Algorithm, accessKey, strings.Join(signedHeaders, ";"), signature) } // Signature HWS meta type Signer struct { Key string Secret string } // SignRequest set Authorization header func (s *Signer) Sign(r *http.Request) error { var t time.Time var err error var dt string if dt = r.Header.Get(HeaderXDate); dt != "" { t, err = time.Parse(BasicDateFormat, dt) } if err != nil || dt == "" { t = time.Now() r.Header.Set(HeaderXDate, t.UTC().Format(BasicDateFormat)) } signedHeaders := SignedHeaders(r) canonicalRequest, err := CanonicalRequest(r, signedHeaders) if err != nil { return err } stringToSign, err := StringToSign(canonicalRequest, t) if err != nil { return err } signature, err := SignStringToSign(stringToSign, []byte(s.Secret)) if err != nil { return err } authValue := AuthHeaderValue(signature, s.Key, signedHeaders) r.Header.Set(HeaderAuthorization, authValue) return nil } ================================================ FILE: util/ip_cache.go ================================================ package util import ( "os" "strconv" ) const IPCacheTimesENV = "DDNS_IP_CACHE_TIMES" // IpCache 上次IP缓存 type IpCache struct { Addr string // 缓存地址 Times int // 剩余次数 TimesFailedIP int // 获取ip失败的次数 } var ForceCompareGlobal = true func (d *IpCache) Check(newAddr string) bool { if newAddr == "" { return true } // 地址改变 或 达到剩余次数 if d.Addr != newAddr || d.Times <= 1 { IPCacheTimes, err := strconv.Atoi(os.Getenv(IPCacheTimesENV)) if err != nil { IPCacheTimes = 5 } d.Addr = newAddr d.Times = IPCacheTimes + 1 return true } d.Addr = newAddr d.Times-- return false } ================================================ FILE: util/messages.go ================================================ package util import ( "log" "strings" "golang.org/x/text/language" "golang.org/x/text/message" ) var logLang = language.English var logPrinter = message.NewPrinter(logLang) func init() { message.SetString(language.English, "可使用 .\\ddns-go.exe -s install 安装服务运行", "You can use '.\\ddns-go.exe -s install' to install service") message.SetString(language.English, "可使用 sudo ./ddns-go -s install 安装服务运行", "You can use 'sudo ./ddns-go -s install' to install service") message.SetString(language.English, "监听 %s", "Listening on %s") message.SetString(language.English, "配置文件已保存在: %s", "Config file has been saved to: %s") message.SetString(language.English, "你的IP %s 没有变化, 域名 %s", "Your's IP %s has not changed! Domain: %s") message.SetString(language.English, "新增域名解析 %s 成功! IP: %s", "Added domain %s successfully! IP: %s") message.SetString(language.English, "新增域名解析 %s 失败! 异常信息: %s", "Failed to add domain %s! Result: %s") message.SetString(language.English, "更新域名解析 %s 成功! IP: %s", "Updated domain %s successfully! IP: %s") message.SetString(language.English, "更新域名解析 %s 失败! 异常信息: %s", "Failed to updated domain %s! Result: %s") message.SetString(language.English, "你的IPv4未变化, 未触发 %s 请求", "Your's IPv4 has not changed, %s request has not been triggered") message.SetString(language.English, "你的IPv6未变化, 未触发 %s 请求", "Your's IPv6 has not changed, %s request has not been triggered") message.SetString(language.English, "Namecheap 不支持更新 IPv6", "Namecheap does not support IPv6") message.SetString(language.English, "dynadot仅支持单域名配置,多个域名请添加更多配置", "dynadot only supports single domain configuration, please add more configurations") // http_util message.SetString(language.English, "异常信息: %s", "Exception: %s") message.SetString(language.English, "查询域名信息发生异常! %s", "Failed to query domain info! %s") message.SetString(language.English, "返回内容: %s ,返回状态码: %d", "Response body: %s ,Response status code: %d") message.SetString(language.English, "通过接口获取IPv4失败! 接口地址: %s", "Failed to get IPv4 from %s") message.SetString(language.English, "通过接口获取IPv6失败! 接口地址: %s", "Failed to get IPv6 from %s") message.SetString(language.English, "将不会触发Webhook, 仅在第 3 次失败时触发一次Webhook, 当前失败次数:%d", "Webhook will not be triggered, only trigger once when the third failure, current failure times: %d") message.SetString(language.English, "在DNS服务商中未找到根域名: %s", "Root domain not found in DNS provider: %s") // webhook message.SetString(language.English, "Webhook配置中的URL不正确", "Webhook url is incorrect") message.SetString(language.English, "Webhook中的 RequestBody JSON 无效", "Webhook RequestBody JSON is invalid") message.SetString(language.English, "Webhook调用成功! 返回数据:%s", "Successfully called Webhook! Response body: %s") message.SetString(language.English, "Webhook调用失败! 异常信息:%s", "Failed to call Webhook! Exception: %s") message.SetString(language.English, "Webhook Header不正确: %s", "Webhook header is invalid: %s") message.SetString(language.English, "请输入Webhook的URL", "Please enter the Webhook url") // callback message.SetString(language.English, "Callback的URL不正确", "Callback url is incorrect") message.SetString(language.English, "Callback调用成功, 域名: %s, IP: %s, 返回数据: %s", "Successfully called Callback! Domain: %s, IP: %s, Response body: %s") message.SetString(language.English, "Callback调用失败, 异常信息: %s", "Failed to call Callback! Exception: %s") // save message.SetString(language.English, "必须输入用户名/密码", "Username/Password is required") message.SetString(language.English, "密码不安全!尝试使用更复杂的密码", "Password is not secure! Try using a more complex password") message.SetString(language.English, "数据解析失败, 请刷新页面重试", "Data parsing failed, please refresh the page and try again") message.SetString(language.English, "第 %s 个配置未填写域名", "The %s config does not fill in the domain") // config message.SetString(language.English, "从网卡获得IPv4失败", "Failed to get IPv4 from network card") message.SetString(language.English, "从网卡中获得IPv4失败! 网卡名: %s", "Failed to get IPv4 from network card! Network card name: %s") message.SetString(language.English, "获取IPv4结果失败! 接口: %s ,返回值: %s", "Failed to get IPv4 result! Interface: %s ,Result: %s") message.SetString(language.English, "获取%s结果失败! 未能成功执行命令:%s, 错误:%q, 退出状态码:%s", "Failed to get %s result! Command: %s, Error: %q, Exit status code: %s") message.SetString(language.English, "获取%s结果失败! 命令: %s, 标准输出: %q", "Failed to get %s result! Command: %s, Stdout: %q") message.SetString(language.English, "从网卡获得IPv6失败", "Failed to get IPv6 from network card") message.SetString(language.English, "从网卡中获得IPv6失败! 网卡名: %s", "Failed to get IPv6 from network card! Network card name: %s") message.SetString(language.English, "获取IPv6结果失败! 接口: %s ,返回值: %s", "Failed to get IPv6 result! Interface: %s ,Result: %s") message.SetString(language.English, "未找到第 %d 个IPv6地址! 将使用第一个IPv6地址", "%dth IPv6 address not found! Will use the first IPv6 address") message.SetString(language.English, "IPv6匹配表达式 %s 不正确! 最小从1开始", "IPv6 match expression %s is incorrect! Minimum start from 1") message.SetString(language.English, "IPv6将使用正则表达式 %s 进行匹配", "IPv6 will use regular expression %s for matching") message.SetString(language.English, "匹配成功! 匹配到地址: %s", "Match successfully! Matched address: %s") message.SetString(language.English, "没有匹配到任何一个IPv6地址, 将使用第一个地址", "No IPv6 address matched, will use the first address") message.SetString(language.English, "未能获取IPv4地址, 将不会更新", "Failed to get IPv4 address, will not update") message.SetString(language.English, "未能获取IPv6地址, 将不会更新", "Failed to get IPv6 address, will not update") // domains message.SetString(language.English, "域名: %s 不正确", "The domain %s is incorrect") message.SetString(language.English, "域名: %s 解析失败", "The domain %s resolution failed") message.SetString(language.English, "域名 %s 解析未找到,且因添加了参数 %s=%s 导致无法创建。本次更新已被忽略", "DNS resolution for domain %s was not found, and the creation failed due to the added parameter %s=%s. This update has been ignored.") message.SetString(language.English, "IPv6未改变, 将等待 %d 次后与DNS服务商进行比对", "IPv6 has not changed, will wait %d times to compare with DNS provider") message.SetString(language.English, "IPv4未改变, 将等待 %d 次后与DNS服务商进行比对", "IPv4 has not changed, will wait %d times to compare with DNS provider") message.SetString(language.English, "本机DNS异常! 将默认使用 %s, 可参考文档通过 -dns 自定义 DNS 服务器", "Local DNS exception! Will use %s by default, you can use -dns to customize DNS server") message.SetString(language.English, "等待网络连接: %s", "Waiting for network connection: %s") message.SetString(language.English, "%s 后重试...", "Retry after %s") message.SetString(language.English, "网络已连接", "The network is connected") // main message.SetString(language.English, "监听端口发生异常, 请检查端口是否被占用! %s", "Port listening failed, please check if the port is occupied! %s") message.SetString(language.English, "ddns-go 服务卸载成功", "ddns-go service uninstalled successfully") message.SetString(language.English, "ddns-go 服务卸载失败, 异常信息: %s", "ddns-go service uninstallation failed, Exception: %s") message.SetString(language.English, "安装 ddns-go 服务成功! 请打开浏览器并进行配置", "Installed ddns-go service successfully! Please open the browser and configure it") message.SetString(language.English, "安装 ddns-go 服务失败, 异常信息: %s", "Failed to install ddns-go service, Exception: %s") message.SetString(language.English, "ddns-go 服务已安装, 无需再次安装", "ddns-go service has been installed, no need to install again") message.SetString(language.English, "重启 ddns-go 服务成功", "restarted ddns-go service successfully") message.SetString(language.English, "启动 ddns-go 服务成功", "started ddns-go service successfully") message.SetString(language.English, "ddns-go 服务未安装, 请先安装服务", "ddns-go service is not installed, please install the service first") // webhook通知 message.SetString(language.English, "未改变", "no changed") message.SetString(language.English, "失败", "failed") message.SetString(language.English, "成功", "success") // Login message.SetString(language.English, "%q 配置文件为空, 超过3小时禁止从公网访问", "%q configuration file is empty, public network access is prohibited for more than 3 hours") message.SetString(language.English, "%q 被禁止从公网访问", "%q is prohibited from accessing the public network") message.SetString(language.English, "%q 帐号密码不正确", "%q username or password is incorrect") message.SetString(language.English, "%q 登录成功", "%q login successfully") message.SetString(language.English, "用户名或密码错误", "Username or password is incorrect") message.SetString(language.English, "登录失败次数过多,请稍后再试", "Too many login failures, please try again later") message.SetString(language.English, "用户名 %s 的密码已重置成功! 请重启ddns-go", "The password of username %s has been reset successfully! Please restart ddns-go") message.SetString(language.English, "需在 %s 之前完成用户名密码设置,请重启ddns-go", "Need to complete the username and password setting before %s, please restart ddns-go") message.SetString(language.English, "配置文件 %s 不存在, 可通过-c指定配置文件", "Config file %s does not exist, you can specify the configuration file through -c") } func Log(key string, args ...interface{}) { log.Println(LogStr(key, args...)) } func LogStr(key string, args ...interface{}) string { return logPrinter.Sprintf(key, args...) } func InitLogLang(lang string) string { newLang := language.English if strings.HasPrefix(lang, "zh") { newLang = language.Chinese } if newLang != logLang { logLang = newLang logPrinter = message.NewPrinter(logLang) } return logLang.String() } ================================================ FILE: util/net.go ================================================ package util import ( "net" "net/http" "strings" ) // IsPrivateNetwork 是否为私有地址 // https://en.wikipedia.org/wiki/Private_network func IsPrivateNetwork(remoteAddr string) bool { // removing optional port from remoteAddr if strings.HasPrefix(remoteAddr, "[") { // ipv6 if index := strings.LastIndex(remoteAddr, "]"); index != -1 { remoteAddr = remoteAddr[1:index] } else { return false } } else { // ipv4 if index := strings.LastIndex(remoteAddr, ":"); index != -1 { remoteAddr = remoteAddr[:index] } } if ip := net.ParseIP(remoteAddr); ip != nil { return ip.IsLoopback() || // 127/8, ::1 ip.IsPrivate() || // 10/8, 172.16/12, 192.168/16, fc00::/7 ip.IsLinkLocalUnicast() // 169.254/16, fe80::/10 } return false } // GetRequestIPStr get IP string from request func GetRequestIPStr(r *http.Request) (addr string) { addr = "Remote: " + r.RemoteAddr if r.Header.Get("X-Real-IP") != "" { addr = addr + " ,Real-IP: " + r.Header.Get("X-Real-IP") } if r.Header.Get("X-Forwarded-For") != "" { addr = addr + " ,Forwarded-For: " + r.Header.Get("X-Forwarded-For") } return addr } ================================================ FILE: util/net_resolver.go ================================================ package util import ( "context" "net" "net/url" "strings" "golang.org/x/text/language" ) // BackupDNS will be used if DNS error occurs. var BackupDNS = []string{"1.1.1.1", "8.8.8.8", "9.9.9.9", "223.5.5.5"} func InitBackupDNS(customDNS, lang string) { if customDNS != "" { BackupDNS = []string{customDNS} return } if lang == language.Chinese.String() { BackupDNS = []string{"223.5.5.5", "114.114.114.114", "119.29.29.29"} } } // SetDNS sets the dialer.Resolver to use the given DNS server. func SetDNS(dns string) { if !strings.Contains(dns, "://") { dns = "udp://" + dns } svrParse, _ := url.Parse(dns) var network string switch strings.ToLower(svrParse.Scheme) { case "tcp": network = "tcp" default: network = "udp" } if svrParse.Port() == "" { dns = net.JoinHostPort(svrParse.Host, "53") } else { dns = svrParse.Host } dialer.Resolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, _, address string) (net.Conn, error) { return net.Dial(network, dns) }, } } // LookupHost looks up the host based on the given URL using the dialer.Resolver. // A wrapper for [net.Resolver.LookupHost]. func LookupHost(url string) error { name := toHostname(url) _, err := dialer.Resolver.LookupHost(context.Background(), name) return err } ================================================ FILE: util/net_resolver_test.go ================================================ package util import "testing" const ( testDNS = "1.1.1.1" testURL = "https://cloudflare.com" ) func TestSetDNS(t *testing.T) { SetDNS(testDNS) if dialer.Resolver == nil { t.Error("Failed to set dialer.Resolver") } } func TestLookupHost(t *testing.T) { t.Run("Valid URL", func(t *testing.T) { if err := LookupHost(testURL); err != nil { t.Errorf("Expected nil error, got %v", err) } }) t.Run("Invalid URL", func(t *testing.T) { if err := LookupHost("invalidurl"); err == nil { t.Error("Expected error, got nil") } }) t.Run("After SetDNS", func(t *testing.T) { SetDNS(testDNS) if err := LookupHost(testURL); err != nil { t.Errorf("Expected nil error, got %v", err) } }) } ================================================ FILE: util/net_test.go ================================================ package util import ( "net/http" "testing" ) // TestIsPrivateNetwork 测试是否为私有地址 func TestIsPrivateNetwork(t *testing.T) { data := map[string]bool{ "127.0.0.1": true, // listen on default port "127.0.0.1:9876": true, "[::1]": true, "[::1]:9876": true, "192.168.1.18:9876": true, "172.16.1.18:9876": true, "10.1.1.18:9876": true, "[fe80::1]:9876": true, "[fd00::1]:9876": true, "100.0.0.1": false, "100.0.0.1:9876": false, "[2409::1]": false, "[2409::1]:9876": false, "223.5.5.5:9876": false, } for key, value := range data { if IsPrivateNetwork(key) != value { t.Errorf("%s 校验失败\n", key) } } } // test get request IP string from request func TestGetRequestIPStr(t *testing.T) { req := http.Request{RemoteAddr: "192.168.1.1", Header: http.Header{}} req.Header.Set("X-Real-IP", "10.0.0.1") req.Header.Set("X-Forwarded-For", "10.0.0.2") if GetRequestIPStr(&req) != "Remote: 192.168.1.1 ,Real-IP: 10.0.0.1 ,Forwarded-For: 10.0.0.2" { t.Errorf("GetRequestIPStr failed") } } ================================================ FILE: util/ordinal.go ================================================ package util import ( "strconv" "golang.org/x/text/language" ) // Ordinal returns the ordinal format of the given number. // // See also: https://github.com/dustin/go-humanize/blob/master/ordinals.go func Ordinal(x int, lang string) string { s := strconv.Itoa(x) // Chinese doesn't require an ordinal if lang == language.Chinese.String() { return s } suffix := "th" switch x % 10 { case 1: if x%100 != 11 { suffix = "st" } case 2: if x%100 != 12 { suffix = "nd" } case 3: if x%100 != 13 { suffix = "rd" } } return s + suffix } ================================================ FILE: util/ordinal_test.go ================================================ package util import "testing" func TestOrdinal(t *testing.T) { lang := "en" tests := []struct { name string got string want string }{ {"0", Ordinal(0, lang), "0th"}, {"1", Ordinal(1, lang), "1st"}, {"2", Ordinal(2, lang), "2nd"}, {"3", Ordinal(3, lang), "3rd"}, {"4", Ordinal(4, lang), "4th"}, {"10", Ordinal(10, lang), "10th"}, {"11", Ordinal(11, lang), "11th"}, {"12", Ordinal(12, lang), "12th"}, {"13", Ordinal(13, lang), "13th"}, {"21", Ordinal(21, lang), "21st"}, {"32", Ordinal(32, lang), "32nd"}, {"43", Ordinal(43, lang), "43rd"}, {"101", Ordinal(101, lang), "101st"}, {"102", Ordinal(102, lang), "102nd"}, {"103", Ordinal(103, lang), "103rd"}, {"211", Ordinal(211, lang), "211th"}, {"212", Ordinal(212, lang), "212th"}, {"213", Ordinal(213, lang), "213th"}, } for _, tt := range tests { if tt.got != tt.want { t.Errorf("On %s, Expected %s, but got %s", tt.name, tt.want, tt.got) } } } ================================================ FILE: util/osutil/daemon_unix.go ================================================ //go:build !windows package osutil import ( "os" "syscall" ) // StartDetachedProcess starts a process detached from terminal on Unix-like systems. func StartDetachedProcess(exe string, args []string, nullFile *os.File) (*os.Process, error) { attr := &os.ProcAttr{ Env: append(os.Environ(), "DDNS_GO_DAEMON=1"), Files: []*os.File{nullFile, nullFile, nullFile}, Sys: &syscall.SysProcAttr{Setsid: true}, } return os.StartProcess(exe, args, attr) } ================================================ FILE: util/osutil/daemon_win32.go ================================================ //go:build windows package osutil import ( "os" "syscall" ) // StartDetachedProcess starts a process detached from console on Windows. func StartDetachedProcess(exe string, args []string, nullFile *os.File) (*os.Process, error) { const ( DETACHED_PROCESS = 0x00000008 CREATE_NEW_PROCESS_GROUP = 0x00000200 CREATE_NO_WINDOW = 0x08000000 ) attr := &os.ProcAttr{ Env: append(os.Environ(), "DDNS_GO_DAEMON=1"), Files: []*os.File{nullFile, nullFile, nullFile}, Sys: &syscall.SysProcAttr{CreationFlags: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW, HideWindow: true}, } return os.StartProcess(exe, args, attr) } ================================================ FILE: util/semver/version.go ================================================ // Based on https://github.com/Masterminds/semver/blob/v3.2.1/version.go package semver import ( "bytes" "fmt" "regexp" "strconv" "strings" ) // 在 init() 中创建的正则表达式的编译版本被缓存在这里,这样 // 它只需要被创建一次。 var versionRegex *regexp.Regexp // semVerRegex 是用于解析语义化版本的正则表达式。 const semVerRegex string = `v?([0-9]+)(\.[0-9]+)?(\.[0-9]+)?` + `(-([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` + `(\+([0-9A-Za-z\-]+(\.[0-9A-Za-z\-]+)*))?` // Version 表示单独的语义化版本。 type Version struct { major, minor, patch uint64 } func init() { versionRegex = regexp.MustCompile("^" + semVerRegex + "$") } // NewVersion 解析给定的版本并返回 Version 实例,如果 // 无法解析该版本则返回错误。如果版本是类似于 SemVer 的版本,则会 // 尝试将其转换为 SemVer。 func NewVersion(v string) (*Version, error) { m := versionRegex.FindStringSubmatch(v) if m == nil { return nil, fmt.Errorf("the %s, it's not a semantic version", v) } sv := &Version{} var err error sv.major, err = strconv.ParseUint(m[1], 10, 64) if err != nil { return nil, fmt.Errorf("解析版本号时出错:%s", err) } if m[2] != "" { sv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], "."), 10, 64) if err != nil { return nil, fmt.Errorf("解析版本号时出错:%s", err) } } else { sv.minor = 0 } if m[3] != "" { sv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], "."), 10, 64) if err != nil { return nil, fmt.Errorf("解析版本号时出错:%s", err) } } else { sv.patch = 0 } return sv, nil } // String 将 Version 对象转换为字符串。 // 注意,如果原始版本包含前缀 v,则转换后的版本将不包含 v。 // 根据规范,语义版本不包含前缀 v,而在实现上则是可选的。 func (v Version) String() string { var buf bytes.Buffer fmt.Fprintf(&buf, "%d.%d.%d", v.major, v.minor, v.patch) return buf.String() } // GreaterThan 测试一个版本是否大于另一个版本。 func (v *Version) GreaterThan(o *Version) bool { return v.compare(o) > 0 } // GreaterThanOrEqual 测试一个版本是否大于或等于另一个版本。 func (v *Version) GreaterThanOrEqual(o *Version) bool { return v.compare(o) >= 0 } // compare 比较当前版本与另一个版本。如果当前版本小于另一个版本则返回 -1;如果两个版本相等则返回 0;如果当前版本大于另一个版本,则返回 1。 // // 版本比较是基于 X.Y.Z 格式进行的。 func (v *Version) compare(o *Version) int { // 比较主版本号、次版本号和修订号。如果 // 发现差异则返回比较结果。 if d := compareSegment(v.major, o.major); d != 0 { return d } if d := compareSegment(v.minor, o.minor); d != 0 { return d } if d := compareSegment(v.patch, o.patch); d != 0 { return d } return 0 } func compareSegment(v, o uint64) int { if v < o { return -1 } if v > o { return 1 } return 0 } ================================================ FILE: util/semver/version_test.go ================================================ // Based on https://github.com/Masterminds/semver/blob/v3.2.1/version_test.go package semver import "testing" func TestNewVersion(t *testing.T) { tests := []struct { version string err bool }{ {"1.2.3", false}, {"1.2.3+test.01", false}, {"1.2.3-alpha.-1", false}, {"v1.2.3", false}, {"1.0", false}, {"v1.0", false}, {"1", false}, {"v1", false}, {"1.2.beta", true}, {"v1.2.beta", true}, {"foo", true}, {"1.2-5", false}, {"v1.2-5", false}, {"1.2-beta.5", false}, {"v1.2-beta.5", false}, {"\n1.2", true}, {"\nv1.2", true}, {"1.2.0-x.Y.0+metadata", false}, {"v1.2.0-x.Y.0+metadata", false}, {"1.2.0-x.Y.0+metadata-width-hyphen", false}, {"v1.2.0-x.Y.0+metadata-width-hyphen", false}, {"1.2.3-rc1-with-hyphen", false}, {"v1.2.3-rc1-with-hyphen", false}, {"1.2.3.4", true}, {"v1.2.3.4", true}, {"1.2.2147483648", false}, {"1.2147483648.3", false}, {"2147483648.3.0", false}, // Due to having 4 parts these should produce an error. See // https://github.com/Masterminds/semver/issues/185 for the reason for // these tests. {"12.3.4.1234", true}, {"12.23.4.1234", true}, {"12.3.34.1234", true}, // The SemVer spec in a pre-release expects to allow [0-9A-Za-z-]. {"20221209-update-renovatejson-v4", false}, } for _, tc := range tests { _, err := NewVersion(tc.version) if tc.err && err == nil { t.Fatalf("expected error for version: %s", tc.version) } else if !tc.err && err != nil { t.Fatalf("error for version %s: %s", tc.version, err) } } } func TestParts(t *testing.T) { v, err := NewVersion("1.2.3") if err != nil { t.Error("Error parsing version 1.2.3") } if v.major != 1 { t.Error("major returning wrong value") } if v.minor != 2 { t.Error("minor returning wrong value") } if v.patch != 3 { t.Error("patch returning wrong value") } } func TestCoerceString(t *testing.T) { tests := []struct { version string expected string }{ {"1.2.3", "1.2.3"}, {"v1.2.3", "1.2.3"}, {"1.0", "1.0.0"}, {"v1.0", "1.0.0"}, {"1", "1.0.0"}, {"v1", "1.0.0"}, } for _, tc := range tests { v, err := NewVersion(tc.version) if err != nil { t.Errorf("Error parsing version %s", tc) } s := v.String() if s != tc.expected { t.Errorf("Error generating string. Expected '%s' but got '%s'", tc.expected, s) } } } func TestCompare(t *testing.T) { tests := []struct { v1 string v2 string expected int }{ {"1.2.3", "1.5.1", -1}, {"2.2.3", "1.5.1", 1}, {"2.2.3", "2.2.2", 1}, } for _, tc := range tests { v1, err := NewVersion(tc.v1) if err != nil { t.Errorf("Error parsing version: %s", err) } v2, err := NewVersion(tc.v2) if err != nil { t.Errorf("Error parsing version: %s", err) } a := v1.compare(v2) e := tc.expected if a != e { t.Errorf( "Comparison of '%s' and '%s' failed. Expected '%d', got '%d'", tc.v1, tc.v2, e, a, ) } } } func TestGreaterThan(t *testing.T) { tests := []struct { v1 string v2 string expected bool }{ {"1.2.3", "1.5.1", false}, {"2.2.3", "1.5.1", true}, {"3.2-beta", "3.2-beta", false}, {"3.2.0-beta.1", "3.2.0-beta.5", false}, {"7.43.0-SNAPSHOT.99", "7.43.0-SNAPSHOT.103", false}, {"7.43.0-SNAPSHOT.99", "7.43.0-SNAPSHOT.BAR", false}, } for _, tc := range tests { v1, err := NewVersion(tc.v1) if err != nil { t.Errorf("Error parsing version: %s", err) } v2, err := NewVersion(tc.v2) if err != nil { t.Errorf("Error parsing version: %s", err) } a := v1.GreaterThan(v2) e := tc.expected if a != e { t.Errorf( "Comparison of '%s' and '%s' failed. Expected '%t', got '%t'", tc.v1, tc.v2, e, a, ) } } } func TestGreaterThanOrEqual(t *testing.T) { tests := []struct { v1 string v2 string expected bool }{ {"1.2.3", "1.5.1", false}, {"2.2.3", "1.5.1", true}, {"3.2-beta", "3.2-beta", true}, {"3.2-beta.4", "3.2-beta.2", true}, {"7.43.0-SNAPSHOT.FOO", "7.43.0-SNAPSHOT.103", true}, } for _, tc := range tests { v1, err := NewVersion(tc.v1) if err != nil { t.Errorf("Error parsing version: %s", err) } v2, err := NewVersion(tc.v2) if err != nil { t.Errorf("Error parsing version: %s", err) } a := v1.GreaterThanOrEqual(v2) e := tc.expected if a != e { t.Errorf( "Comparison of '%s' and '%s' failed. Expected '%t', got '%t'", tc.v1, tc.v2, e, a, ) } } } ================================================ FILE: util/string.go ================================================ package util import ( "net/url" "strings" ) // WriteString creates a new string using [strings.Builder]. func WriteString(strs ...string) string { var b strings.Builder for _, str := range strs { b.WriteString(str) } return b.String() } // toHostname normalizes a URL with a https scheme to just its hostname. // // See also: // // - https://github.com/moby/moby/blob/v25.0.3/registry/auth.go#L132 func toHostname(url string) string { stripped := url stripped = strings.TrimPrefix(stripped, "https://") return strings.Split(stripped, "/")[0] } // SplitLines splits a string into lines by '\r\n' or '\n'. func SplitLines(s string) []string { if strings.Contains(s, "\r\n") { return strings.Split(s, "\r\n") } return strings.Split(s, "\n") } func PercentEncode(value string) string { if value == "" { return "" } // 使用Go标准库进行URL编码 encoded := url.QueryEscape(value) // 按照RFC3986规则调整编码 encoded = strings.ReplaceAll(encoded, "+", "%20") encoded = strings.ReplaceAll(encoded, "*", "%2A") encoded = strings.ReplaceAll(encoded, "%7E", "~") return encoded } ================================================ FILE: util/string_test.go ================================================ package util import "testing" func TestWriteString(t *testing.T) { tests := []struct { input []string expected string }{ {[]string{"hello", "world"}, "helloworld"}, {[]string{"", "test"}, "test"}, {[]string{"hello", " ", "world"}, "hello world"}, {[]string{""}, ""}, } for _, tt := range tests { result := WriteString(tt.input...) if result != tt.expected { t.Errorf("Expected %s, but got %s", tt.expected, result) } } } func TestToHostname(t *testing.T) { tests := []struct { name string input string expected string }{ {"With https scheme", "https://www.example.com", "www.example.com"}, {"With path", "www.example.com/path", "www.example.com"}, {"With https scheme and path", "https://www.example.com/path", "www.example.com"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := toHostname(tt.input) if result != tt.expected { t.Errorf("Expected %s, but got %s", tt.expected, result) } }) } } ================================================ FILE: util/tencent_cloud_signer.go ================================================ package util import ( "crypto/hmac" "crypto/sha256" "encoding/hex" "net/http" "strconv" "strings" "time" ) func sha256hex(s string) string { b := sha256.Sum256([]byte(s)) return hex.EncodeToString(b[:]) } func tencentCloudHmacsha256(s, key string) string { hashed := hmac.New(sha256.New, []byte(key)) hashed.Write([]byte(s)) return string(hashed.Sum(nil)) } const ( DnsPod = "dnspod" EdgeOne = "teo" ) // TencentCloudSigner 腾讯云签名方法 v3 https://cloud.tencent.com/document/api/1427/56189#Golang func TencentCloudSigner(secretId string, secretKey string, r *http.Request, action string, payload string, service string) { algorithm := "TC3-HMAC-SHA256" host := WriteString(service, ".tencentcloudapi.com") timestamp := time.Now().Unix() timestampStr := strconv.FormatInt(timestamp, 10) // step 1: build canonical request string canonicalHeaders := WriteString("content-type:application/json\nhost:", host, "\nx-tc-action:", strings.ToLower(action), "\n") signedHeaders := "content-type;host;x-tc-action" hashedRequestPayload := sha256hex(payload) canonicalRequest := WriteString("POST\n/\n\n", canonicalHeaders, "\n", signedHeaders, "\n", hashedRequestPayload) // step 2: build string to sign date := time.Unix(timestamp, 0).UTC().Format("2006-01-02") credentialScope := WriteString(date, "/", service, "/tc3_request") hashedCanonicalRequest := sha256hex(canonicalRequest) string2sign := WriteString(algorithm, "\n", timestampStr, "\n", credentialScope, "\n", hashedCanonicalRequest) // step 3: sign string secretDate := tencentCloudHmacsha256(date, WriteString("TC3", secretKey)) secretService := tencentCloudHmacsha256(service, secretDate) secretSigning := tencentCloudHmacsha256("tc3_request", secretService) signature := hex.EncodeToString([]byte(tencentCloudHmacsha256(string2sign, secretSigning))) // step 4: build authorization authorization := WriteString(algorithm, " Credential=", secretId, "/", credentialScope, ", SignedHeaders=", signedHeaders, ", Signature=", signature) r.Header.Add("Authorization", authorization) r.Header.Set("Host", host) r.Header.Set("X-TC-Action", action) r.Header.Add("X-TC-Timestamp", timestampStr) } ================================================ FILE: util/termux.go ================================================ package util import "os" // isTermux 是否在 Termux 中运行 // // https://wiki.termux.com/wiki/Getting_started func isTermux() bool { return os.Getenv("PREFIX") == "/data/data/com.termux/files/usr" } ================================================ FILE: util/termux_test.go ================================================ package util import ( "os" "testing" ) // TestIsTermux 测试在或不在 Termux 中运行都能正确判断 func TestIsTermux(t *testing.T) { // 模拟在 Termux 中运行 os.Setenv("PREFIX", "/data/data/com.termux/files/usr") if !isTermux() { t.Error("期待 isTermux 返回 true,但得到 false。") } // 清除 PREFIX 变量,模拟不在 Termux 中运行 os.Unsetenv("PREFIX") if isTermux() { t.Error("期待 isTermux 返回 false,但得到 true。") } } ================================================ FILE: util/token.go ================================================ package util import ( "crypto/hmac" "crypto/sha256" "encoding/base64" "fmt" "math/rand" "time" ) // GenerateToken 生成Token func GenerateToken(username string) string { key := []byte(generateRandomKey()) h := hmac.New(sha256.New, key) msg := fmt.Sprintf("%s%d", username, time.Now().Unix()) h.Write([]byte(msg)) return base64.StdEncoding.EncodeToString(h.Sum(nil)) } // generateRandomKey 生成随机密钥 func generateRandomKey() string { // 设置随机种子 source := rand.NewSource(time.Now().UnixNano()) random := rand.New(source) // 生成随机的64位整数 randomNumber := random.Uint64() return fmt.Sprint(randomNumber) } ================================================ FILE: util/traffic_route_signer.go ================================================ package util import ( "bytes" "crypto/hmac" "crypto/sha256" "encoding/hex" "fmt" "net/http" "net/url" "strings" "time" ) const Version = "2018-08-01" const Service = "DNS" const Region = "cn-north-1" const Host = "open.volcengineapi.com" // 第一步:准备辅助函数。 // sha256非对称加密 func hmacSHA256(key []byte, content string) []byte { mac := hmac.New(sha256.New, key) mac.Write([]byte(content)) return mac.Sum(nil) } // sha256 hash算法 func hashSHA256(content []byte) string { h := sha256.New() h.Write(content) return hex.EncodeToString(h.Sum(nil)) } // 第二步:准备需要用到的结构体定义。 // 签算请求结构体 type RequestParam struct { Body []byte Method string Date time.Time Path string Host string QueryList url.Values } // 身份证明结构体 type Credentials struct { AccessKeyID string SecretAccessKey string Service string Region string } // 签算结果结构体 type SignRequest struct { XDate string Host string ContentType string XContentSha256 string Authorization string } // 第三步:创建一个 DNS 的 API 请求函数。签名计算的过程包含在该函数中。 func TrafficRouteSigner(method string, query map[string][]string, header map[string]string, ak string, sk string, action string, body []byte) (*http.Request, error) { // 第四步:在requestDNS中,创建一个 HTTP 请求实例。 // 创建 HTTP 请求实例。该实例会在后续用到。 request, _ := http.NewRequest(method, "https://"+Host+"/", bytes.NewReader(body)) urlVales := url.Values{} for k, v := range query { urlVales[k] = v } urlVales["Action"] = []string{action} urlVales["Version"] = []string{Version} request.URL.RawQuery = urlVales.Encode() for k, v := range header { request.Header.Set(k, v) } // 第五步:创建身份证明。其中的 Service 和 Region 字段是固定的。ak 和 sk 分别代表 AccessKeyID 和 SecretAccessKey。同时需要初始化签名结构体。一些签名计算时需要的属性也在这里处理。 // 初始化身份证明 credential := Credentials{ AccessKeyID: ak, SecretAccessKey: sk, Service: Service, Region: Region, } // 初始化签名结构体 requestParam := RequestParam{ Body: body, Host: request.Host, Path: "/", Method: request.Method, Date: time.Now().UTC(), QueryList: request.URL.Query(), } // 第六步:接下来开始计算签名。在计算签名前,先准备好用于接收签算结果的 signResult 变量,并设置一些参数。 // 初始化签名结果的结构体 xDate := requestParam.Date.Format("20060102T150405Z") shortXDate := xDate[:8] XContentSha256 := hashSHA256(requestParam.Body) contentType := "application/json" signResult := SignRequest{ Host: requestParam.Host, // 设置Host XContentSha256: XContentSha256, // 加密body XDate: xDate, // 设置标准化时间 ContentType: contentType, // 设置Content-Type 为 application/json } // 第七步:计算 Signature 签名。 signedHeadersStr := strings.Join([]string{"content-type", "host", "x-content-sha256", "x-date"}, ";") canonicalRequestStr := strings.Join([]string{ requestParam.Method, requestParam.Path, request.URL.RawQuery, strings.Join([]string{"content-type:" + contentType, "host:" + requestParam.Host, "x-content-sha256:" + XContentSha256, "x-date:" + xDate}, "\n"), "", signedHeadersStr, XContentSha256, }, "\n") hashedCanonicalRequest := hashSHA256([]byte(canonicalRequestStr)) credentialScope := strings.Join([]string{shortXDate, credential.Region, credential.Service, "request"}, "/") stringToSign := strings.Join([]string{ "HMAC-SHA256", xDate, credentialScope, hashedCanonicalRequest, }, "\n") kDate := hmacSHA256([]byte(credential.SecretAccessKey), shortXDate) kRegion := hmacSHA256(kDate, credential.Region) kService := hmacSHA256(kRegion, credential.Service) kSigning := hmacSHA256(kService, "request") signature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign)) signResult.Authorization = fmt.Sprintf("HMAC-SHA256 Credential=%s, SignedHeaders=%s, Signature=%s", credential.AccessKeyID+"/"+credentialScope, signedHeadersStr, signature) // 第八步:将 Signature 签名写入HTTP Header 中,并发送 HTTP 请求。 // 设置经过签名的5个HTTP Header request.Header.Set("Host", signResult.Host) request.Header.Set("Content-Type", signResult.ContentType) request.Header.Set("X-Date", signResult.XDate) request.Header.Set("X-Content-Sha256", signResult.XContentSha256) request.Header.Set("Authorization", signResult.Authorization) return request, nil } ================================================ FILE: util/update/apply.go ================================================ // Based on https://github.com/inconshreveable/go-update/blob/7a872911e5b39953310f0a04161f0d50c7e63755/apply.go package update import ( "bytes" "fmt" "io" "os" "os/exec" "path/filepath" "runtime" ) // apply 使用给定的 io.Reader 的内容来更新 targetPath 的可执行文件。 // // apply 执行以下操作以确保安全的跨平台更新: // // 1. 创建新文件 /path/to/target.new,并将更新文件的内容写入其中 // // 2. 将 /path/to/target 重命名为 /path/to/target.old // // 3. 将 /path/to/target.new 重命名为 /path/to/target // // 4.如果最终的重命名成功,删除 /path/to/target.old 并返回无错误。 // // 5. 如果最终重命名失败,尝试通过将 /path/to/target.old 重命名会 // /path/to/target 进行回滚。 // // 如果回滚操作失败,文件系统将处于不一致状态(第 4 步和第 5 步之间), // 既没有新的可执行文件,并且旧的可执行文件无法移动回其原始位置。在这种情况下, // 应该通知用户这个坏消息,并要求他们手动恢复。 func apply(update io.Reader, targetPath string) error { newBytes, err := io.ReadAll(update) if err != nil { return err } // 获取可执行文件所在的目录 updateDir := filepath.Dir(targetPath) filename := filepath.Base(targetPath) // 将新二进制的内容复制到新可执行文件中。 newPath := filepath.Join(updateDir, fmt.Sprintf("%s.new", filename)) fp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY, 0755) if err != nil { return err } defer fp.Close() _, err = io.Copy(fp, bytes.NewReader(newBytes)) if err != nil { return err } // 如果我们不调用 fp.Close(),Windows 将不允许我们移动新可执行文件。 // 因为文件仍处于 "in use"(使用中)状态。 fp.Close() // 这是我们将要移动可执行文件的位置,以便可以将更新的文件替代进来 oldPath := filepath.Join(updateDir, fmt.Sprintf("%s.old", filename)) // 删除任何现有的就执行文件 - 这在 Windows 上是必要的,原因有两个: // 1. 成功更新后,Windows 无法删除 .old 文件,因为进程仍在运行 // 2. 如果目标文件已存在,Windows 重命名操作将失败 _ = os.Remove(oldPath) // 将现有的可执行文件移到同一目录下的新文件中 err = os.Rename(targetPath, oldPath) if err != nil { return err } // 将新可执行文件移到目标位置 err = os.Rename(newPath, targetPath) if err != nil { // 移动失败 // // 文件系统现在处于不良状态。我们已成功将现有的二进制文件移动到新位置, // 但无法将新二进制文件移动到原来的位置。这意味着当前可执行文件的位置上没有文件! // 尝试通过将旧二进制文件恢复到原始路径来回滚。 rerr := os.Rename(oldPath, targetPath) if rerr != nil { return err } return err } // 移动成功,删除旧的二进制文件 err = os.Remove(oldPath) if err != nil { if runtime.GOOS == "windows" { // Windows 无法删除 .old 文件,因为进程仍在运行。删除会提示 "Access is denied"。 // 因此,启动外部进程来删除旧的二进制文件。 // 外部进程会等待一会以确保进程已退出。 // // https://stackoverflow.com/a/73585620 exec.Command("cmd.exe", "/c", "ping 127.0.0.1 -n 2 > NUL & del "+oldPath).Start() return nil } return err } return nil } ================================================ FILE: util/update/apply_test.go ================================================ // Based on https://github.com/inconshreveable/go-update/blob/7a872911e5b39953310f0a04161f0d50c7e63755/apply_test.go package update import ( "bytes" "fmt" "os" "testing" ) var ( oldFile = []byte{0xDE, 0xAD, 0xBE, 0xEF} newFile = []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06} ) func cleanup(path string) { os.Remove(path) os.Remove(fmt.Sprintf("%s.new", path)) } // we write with a separate name for each test so that we can run them in parallel func writeOldFile(path string, t *testing.T) { if err := os.WriteFile(path, oldFile, 0777); err != nil { t.Fatalf("Failed to write file for testing preparation: %v", err) } if _, err := os.Stat(path); err != nil { t.Fatalf("Failed to stat file for testing preparation: %v", err) } } func validateUpdate(path string, err error, t *testing.T) { if err != nil { t.Fatalf("Failed to update: %v", err) } buf, err := os.ReadFile(path) if err != nil { t.Fatalf("Failed to read file post-update: %v", err) } if !bytes.Equal(buf, newFile) { t.Fatalf("File was not updated! Bytes read: %v, Bytes expected: %v", buf, newFile) } } func TestApply(t *testing.T) { t.Parallel() fName := "TestApply" defer cleanup(fName) writeOldFile(fName, t) err := apply(bytes.NewReader(newFile), fName) validateUpdate(fName, err, t) } ================================================ FILE: util/update/arch.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/arch.go package update import ( "fmt" "runtime" ) const ( minARM = 5 maxARM = 7 ) // generateAdditionalArch 可以根据 CPU 类型使用 func generateAdditionalArch() []string { if runtime.GOARCH == "arm" && goarm >= minARM && goarm <= maxARM { additionalArch := make([]string, 0, maxARM-minARM) for v := goarm; v >= minARM; v-- { additionalArch = append(additionalArch, fmt.Sprintf("armv%d", v)) } return additionalArch } if runtime.GOARCH == "amd64" { return []string{"x86_64"} } if runtime.GOARCH == "riscv64" { return []string{"riscv64"} } return []string{} } ================================================ FILE: util/update/arm.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/arm.go package update import ( // unsafe 用于从 runtime 包中获取私有变量 _ "unsafe" ) //go:linkname goarm runtime.goarm var goarm uint8 ================================================ FILE: util/update/decompress.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/decompress.go package update import ( "archive/tar" "archive/zip" "bytes" "compress/gzip" "errors" "fmt" "io" "log" "path/filepath" "strings" ) var fileTypes = map[string]func(src io.Reader, cmd string) (io.Reader, error){ ".zip": unzip, ".tar.gz": untar, } // decompressCommand 解压缩给定源。从 'url' 参数中自动检测存档和压缩格式,'url' 参数表示 asset 的 URL, // 或简单的文件名(带扩展名)。 // 返回 reader,用于读取解压缩后与 'cmd' 相应的命令。支持 '.zip' 和 '.tar.gz' // // 可能返回以下封装过的错误: // - errCannotDecompressFile // - errExecutableNotFoundInArchive func decompressCommand(src io.Reader, url, cmd string) (io.Reader, error) { for ext, decompress := range fileTypes { if strings.HasSuffix(url, ext) { return decompress(src, cmd) } } log.Print("It's not a compressed file, skip decompressing") return src, nil } func unzip(src io.Reader, cmd string) (io.Reader, error) { // 解压 Zip 格式时需要文件大小。 // 因此我们需要先将 HTTP 响应读取到缓冲区中。 buf, err := io.ReadAll(src) if err != nil { return nil, fmt.Errorf("%w zip 文件: %v", errCannotDecompressFile, err) } r := bytes.NewReader(buf) z, err := zip.NewReader(r, r.Size()) if err != nil { return nil, fmt.Errorf("%w zip 文件: %s", errCannotDecompressFile, err) } for _, file := range z.File { _, name := filepath.Split(file.Name) if !file.FileInfo().IsDir() && matchExecutableName(cmd, name) { return file.Open() } } return nil, fmt.Errorf("在 zip 文件中%w:%q", errExecutableNotFoundInArchive, cmd) } func untar(src io.Reader, cmd string) (io.Reader, error) { gz, err := gzip.NewReader(src) if err != nil { return nil, fmt.Errorf("%w tar.gz 文件: %s", errCannotDecompressFile, err) } t := tar.NewReader(gz) for { h, err := t.Next() if errors.Is(err, io.EOF) { break } if err != nil { return nil, fmt.Errorf("%w tar.gz 文件:%s", errCannotDecompressFile, err) } _, name := filepath.Split(h.Name) if matchExecutableName(cmd, name) { return t, nil } } return nil, fmt.Errorf("在 tar.gz 文件中%w:%q", errExecutableNotFoundInArchive, cmd) } func matchExecutableName(cmd, target string) bool { return cmd == target || cmd+".exe" == target } ================================================ FILE: util/update/decompress_test.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/decompress_test.go package update import ( "bytes" "io" "strings" "testing" ) var buf = []byte{'a', 'b', 'c'} func TestCompressionNotRequired(t *testing.T) { want := bytes.NewReader(buf) r, err := decompressCommand(want, "https://github.com/foo/bar/releases/download/v1.2.3/foo", "foo") if err != nil { t.Fatal(err) } have, err := io.ReadAll(r) if err != nil { t.Fatal(err) } if !bytes.Equal(buf, have) { t.Errorf("expected %v, got %v", buf, have) } } func TestMatchExecutableName(t *testing.T) { testData := []struct { cmd string target string found bool }{ {"gostuff", "gostuff", true}, {"gostuff", "gostuff_linux_x86_64", false}, {"gostuff", "gostuff_darwin_amd64", false}, {"gostuff", "gostuff.exe", true}, {"gostuff", "gostuff_windows_amd64.exe", false}, } for _, testItem := range testData { t.Run(testItem.target, func(t *testing.T) { if matchExecutableName(testItem.cmd, testItem.target) != testItem.found { t.Errorf("Expected '%t' but got '%t'", testItem.found, matchExecutableName(testItem.cmd, testItem.target)) } }) } } func TestErrorFromReader(t *testing.T) { extensions := []string{ "zip", "tar.gz", } for _, extension := range extensions { t.Run(extension, func(t *testing.T) { reader, err := decompressCommand(bytes.NewReader(buf), "foo."+extension, "foo."+extension) if err != nil { if !strings.Contains(err.Error(), errCannotDecompressFile.Error()) { t.Fatalf("Expected error: EOF, got: %v", err) } } else { _, err = io.ReadAll(reader) if err == nil { t.Fatalf("An error is expected but got nil.") } } }) } } ================================================ FILE: util/update/detect.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/detect.go package update import ( "fmt" "log" "runtime" "strings" "github.com/jeessy2/ddns-go/v6/util/semver" ) // detectLatest 尝试从源提供者获取版本信息。 func detectLatest(repo string) (latest *Latest, found bool, err error) { rel, err := getLatest(repo) if err != nil { return nil, false, err } asset, ver, found := findAsset(rel) if !found { return nil, false, nil } return newLatest(asset, ver), true, nil } // findAsset 返回最新的 asset func findAsset(rel *Release) (*Asset, *semver.Version, bool) { // 将检测到的架构放在列表的末尾,对于 ARM 来说这是可以的。 // 因为附加的架构比通用架构更准确 for _, arch := range append(generateAdditionalArch(), runtime.GOARCH) { asset, version, found := findAssetForArch(arch, rel) if found { return asset, version, found } } return nil, nil, false } func findAssetForArch(arch string, rel *Release, ) (asset *Asset, version *semver.Version, found bool) { var release *Release // 从 release 列表中查找最新的版本。 // GitHub API 返回的列表按照创建日期的顺序排列。 if a, v, ok := findAssetFromRelease(rel, getSuffixes(arch)); ok { version = v asset = a release = rel } if release == nil { log.Printf("Cannot find any release for %s/%s", runtime.GOOS, runtime.GOARCH) return nil, nil, false } return asset, version, true } func findAssetFromRelease(rel *Release, suffixes []string) (*Asset, *semver.Version, bool) { if rel == nil { log.Print("There is no source release information") return nil, nil, false } // 如果无法解析版本文本,则表示该文本不符合语义化版本规范,应该跳过。 ver, err := semver.NewVersion(rel.tagName) if err != nil { log.Printf("Cannot parse semantic version: %s", rel.tagName) return nil, nil, false } for _, asset := range rel.assets { if assetMatchSuffixes(asset.name, suffixes) { return &asset, ver, true } } log.Printf("Can't find suitable asset in release %s", rel.tagName) return nil, nil, false } func assetMatchSuffixes(name string, suffixes []string) bool { for _, suffix := range suffixes { if strings.HasSuffix(name, suffix) { // 需要版本、架构等 // 假设唯一的构件被匹配(或者第一个匹配将足够) return true } } return false } // getSuffixes 返回所有要与 asset 进行检查的候选后缀 // // TODO: 由于缺失获取 MIPS 架构 float 的方法,所以目前无法正确获取 MIPS 架构的后缀。 func getSuffixes(arch string) []string { suffixes := make([]string, 0) for _, ext := range []string{".zip", ".tar.gz"} { suffix := fmt.Sprintf("%s_%s%s", runtime.GOOS, arch, ext) suffixes = append(suffixes, suffix) } return suffixes } ================================================ FILE: util/update/errors.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/errors.go package update import "errors" var ( errCannotDecompressFile = errors.New("failed to decompress") errExecutableNotFoundInArchive = errors.New("executable not found") ) ================================================ FILE: util/update/latest.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/release.go package update import "github.com/jeessy2/ddns-go/v6/util/semver" // Latest 表示当前操作系统和架构的最新 release asset。 type Latest struct { //Name 是 asset 的文件名 Name string // URL 是 release 上传文件的 URL URL string // version 是解析后的 *Version Version *semver.Version } func newLatest(asset *Asset, ver *semver.Version) *Latest { latest := &Latest{ Name: asset.name, URL: asset.url, Version: ver, } return latest } ================================================ FILE: util/update/package.go ================================================ package update import ( "fmt" "io" "log" "os" "runtime" "github.com/jeessy2/ddns-go/v6/util" "github.com/jeessy2/ddns-go/v6/util/semver" ) // Self 更新 ddns-go 到最新版本(如果可用)。 func Self(version string) { // 如果不为语义化版本立即退出 v, err := semver.NewVersion(version) if err != nil { log.Printf("Cannot update because: %v", err) return } latest, found, err := detectLatest("jeessy2/ddns-go") if err != nil { log.Printf("Error happened when detecting latest version: %v", err) return } if !found { log.Printf("Cannot find any release for %s/%s", runtime.GOOS, runtime.GOARCH) return } if v.GreaterThanOrEqual(latest.Version) { log.Printf("Current version (%s) is the latest", version) return } exe, err := os.Executable() if err != nil { log.Printf("Cannot find executable path: %v", err) return } if err = to(latest.URL, latest.Name, exe); err != nil { log.Printf("Error happened when updating binary: %v", err) return } log.Printf("Success update to v%s", latest.Version.String()) } // to 从 assetURL 下载可执行文件,并用下载的文件替换当前的可执行文件。 // 这个函数是用于更新二进制文件的低级 API。因为它不使用源提供者,而是直接通过 HTTP 从 URL 下载 asset 。 // 所以这个函数不能用于更新私有仓库的 release。 // cmdPath 是命令可执行文件的文件路径。 func to(assetURL, assetFileName, cmdPath string) error { src, err := downloadAssetFromURL(assetURL) if err != nil { return err } defer src.Close() return decompressAndUpdate(src, assetFileName, cmdPath) } func downloadAssetFromURL(url string) (rc io.ReadCloser, err error) { client := util.CreateHTTPClient() resp, err := client.Get(url) if err != nil { return nil, fmt.Errorf("could not download release from %s: %v", url, err) } if resp.StatusCode >= 300 { resp.Body.Close() return nil, fmt.Errorf("could not download release from %s. Response code: %d", url, resp.StatusCode) } return resp.Body, nil } ================================================ FILE: util/update/release.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/github_release.go // and https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/github_source.go package update import ( "fmt" "github.com/jeessy2/ddns-go/v6/util" ) type Release struct { tagName string assets []Asset } type Asset struct { name string url string } // ReleaseResp 表示仓库中的 GitHub release 和 asset。 type ReleaseResp struct { TagName string `json:"tag_name,omitempty"` Assets []struct { Name string `json:"name,omitempty"` BrowserDownloadURL string `json:"browser_download_url,omitempty"` } `json:"assets,omitempty"` } // getLatest 列出仓库的最新 release 并返回包装过的 Release // // GitHub API 文档:https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release func getLatest(repo string) (*Release, error) { u := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", repo) client := util.CreateHTTPClient() resp, err := client.Get(u) if err != nil { return nil, err } var result ReleaseResp err = util.GetHTTPResponse(resp, err, &result) if err != nil { util.Log("异常信息: %s", err) return nil, err } return newRelease(&result), err } func newRelease(from *ReleaseResp) *Release { release := &Release{ tagName: from.TagName, assets: make([]Asset, len(from.Assets)), } for i, fromAsset := range from.Assets { release.assets[i] = Asset{ name: fromAsset.Name, url: fromAsset.BrowserDownloadURL, } } return release } ================================================ FILE: util/update/update.go ================================================ // Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/update.go package update import ( "io" "path/filepath" ) func decompressAndUpdate(src io.Reader, assetName, cmdPath string) error { _, cmd := filepath.Split(cmdPath) asset, err := decompressCommand(src, assetName, cmd) if err != nil { return err } return apply(asset, cmdPath) } ================================================ FILE: util/user.go ================================================ package util import ( "os" ) const ConfigFilePathENV = "DDNS_CONFIG_FILE_PATH" // GetConfigFilePath 获得配置文件路径 func GetConfigFilePath() string { configFilePath := os.Getenv(ConfigFilePathENV) if configFilePath != "" { return configFilePath } return GetConfigFilePathDefault() } // GetConfigFilePathDefault 获得默认的配置文件路径 func GetConfigFilePathDefault() string { dir, err := os.UserHomeDir() if err != nil { // log.Println("Getting Home directory failed: ", err) return "../.ddns_go_config.yaml" } return dir + string(os.PathSeparator) + ".ddns_go_config.yaml" } ================================================ FILE: util/wait_internet.go ================================================ package util import ( "strings" "time" ) // Wait blocks until the Internet is connected. // // See also: // // - https://stackoverflow.com/a/50058255 // - https://github.com/ddev/ddev/blob/v1.22.7/pkg/globalconfig/global_config.go#L776 func WaitInternet(addresses []string) { delay := time.Second * 5 retryTimes := 0 failed := false for { for _, addr := range addresses { err := LookupHost(addr) // Internet is connected. if err == nil { if failed { Log("网络已连接") } return } failed = true Log("等待网络连接: %s", err) Log("%s 后重试...", delay) if isDNSErr(err) || retryTimes > 0 { dns := BackupDNS[retryTimes%len(BackupDNS)] Log("本机DNS异常! 将默认使用 %s, 可参考文档通过 -dns 自定义 DNS 服务器", dns) SetDNS(dns) retryTimes = retryTimes + 1 } time.Sleep(delay) } } } // isDNSErr checks if the error is caused by DNS. func isDNSErr(e error) bool { return strings.Contains(e.Error(), "[::1]:53: read: connection refused") } ================================================ FILE: web/auth.go ================================================ package web import ( "net/http" "time" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) // ViewFunc func type ViewFunc func(http.ResponseWriter, *http.Request) // Auth 验证Token是否已经通过 func Auth(f ViewFunc) ViewFunc { return func(w http.ResponseWriter, r *http.Request) { cookieInWeb, err := r.Cookie(cookieName) if err != nil { http.Redirect(w, r, "./login", http.StatusTemporaryRedirect) return } conf, _ := config.GetConfigCached() // 禁止公网访问 if conf.NotAllowWanAccess { if !util.IsPrivateNetwork(r.RemoteAddr) { w.WriteHeader(http.StatusForbidden) util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r)) return } } // 验证token if cookieInSystem.Value != "" && cookieInSystem.Value == cookieInWeb.Value && cookieInSystem.Expires.After(time.Now()) { f(w, r) // 执行被装饰的函数 return } http.Redirect(w, r, "./login", http.StatusTemporaryRedirect) } } // AuthAssert 保护静态等文件不被公网访问 func AuthAssert(f ViewFunc) ViewFunc { return func(w http.ResponseWriter, r *http.Request) { conf, err := config.GetConfigCached() // 配置文件为空, 启动时间超过3小时禁止从公网访问 if err != nil && time.Since(startTime) > time.Duration(3*time.Hour) && !util.IsPrivateNetwork(r.RemoteAddr) { w.WriteHeader(http.StatusForbidden) util.Log("%q 配置文件为空, 超过3小时禁止从公网访问", util.GetRequestIPStr(r)) return } // 禁止公网访问 if conf.NotAllowWanAccess { if !util.IsPrivateNetwork(r.RemoteAddr) { w.WriteHeader(http.StatusForbidden) util.Log("%q 被禁止从公网访问", util.GetRequestIPStr(r)) return } } f(w, r) // 执行被装饰的函数 } } ================================================ FILE: web/login.go ================================================ package web import ( "embed" "encoding/json" "fmt" "html/template" "net/http" "net/url" "time" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) //go:embed login.html var loginEmbedFile embed.FS // CookieName cookie name const cookieName = "token" // CookieInSystem only one cookie var cookieInSystem = &http.Cookie{} // 服务启动时间 var startTime = time.Now() // 保存限制时间 const saveLimit = time.Duration(30) * time.Minute // 登录失败锁定时间 const loginFailLockDuration = time.Duration(30) * time.Minute // 登录检测 type loginDetect struct { failedTimes uint32 // 失败次数 ticker *time.Ticker // 定时器 } var ld = &loginDetect{ticker: time.NewTicker(5 * time.Minute)} // Login login page func Login(writer http.ResponseWriter, request *http.Request) { tmpl, err := template.ParseFS(loginEmbedFile, "login.html") if err != nil { fmt.Println("Error happened..") fmt.Println(err) return } conf, _ := config.GetConfigCached() err = tmpl.Execute(writer, struct { EmptyUser bool // 未填写用户名和密码 }{ EmptyUser: conf.Username == "" && conf.Password == "", }) if err != nil { fmt.Println("Error happened..") fmt.Println(err) } } // LoginFunc login func func LoginFunc(w http.ResponseWriter, r *http.Request) { accept := r.Header.Get("Accept-Language") util.InitLogLang(accept) if ld.failedTimes >= 5 { loginUnlock() returnError(w, util.LogStr("登录失败次数过多,请稍后再试")) return } // 从请求中读取 JSON 数据 var data struct { Username string `json:"Username"` Password string `json:"Password"` } err := json.NewDecoder(r.Body).Decode(&data) if err != nil { returnError(w, err.Error()) return } // 用户名密码不能为空 if data.Username == "" || data.Password == "" { returnError(w, util.LogStr("必须输入用户名/密码")) return } conf, _ := config.GetConfigCached() // 初始化用户名密码 if conf.Username == "" && conf.Password == "" { if time.Since(startTime) > saveLimit { returnError(w, util.LogStr("需在 %s 之前完成用户名密码设置,请重启ddns-go", startTime.Add(saveLimit).Format("2006-01-02 15:04:05"))) return } conf.NotAllowWanAccess = true u, err := url.Parse(r.Header.Get("referer")) if err == nil && !util.IsPrivateNetwork(u.Host) { conf.NotAllowWanAccess = false } conf.Username = data.Username hashedPwd, err := conf.CheckPassword(data.Password) if err != nil { returnError(w, err.Error()) return } conf.Password = hashedPwd conf.SaveConfig() } // 登录 if data.Username == conf.Username && util.PasswordOK(conf.Password, data.Password) { ld.ticker.Stop() ld.failedTimes = 0 // 设置cookie过期时间为1天 timeoutDays := 1 if conf.NotAllowWanAccess { // 内网访问cookie过期时间为30天 timeoutDays = 30 } // 覆盖cookie cookieInSystem = &http.Cookie{ Name: cookieName, Value: util.GenerateToken(data.Username), // 生成token Path: "/", Expires: time.Now().AddDate(0, 0, timeoutDays), // 设置过期时间 HttpOnly: true, } // 写入cookie http.SetCookie(w, cookieInSystem) util.Log("%q 登录成功", util.GetRequestIPStr(r)) returnOK(w, util.LogStr("登录成功"), cookieInSystem.Value) return } ld.failedTimes = ld.failedTimes + 1 util.Log("%q 帐号密码不正确", util.GetRequestIPStr(r)) returnError(w, util.LogStr("用户名或密码错误")) } // loginUnlock login unlock, reset failed login attempts func loginUnlock() { ld.failedTimes = ld.failedTimes + 1 ld.ticker.Reset(loginFailLockDuration) go func(ticker *time.Ticker) { for range ticker.C { ld.failedTimes = 4 ticker.Stop() } }(ld.ticker) } ================================================ FILE: web/login.html ================================================ DDNS-GO
Login
================================================ FILE: web/logout.go ================================================ package web import ( "net/http" "time" ) func Logout(w http.ResponseWriter, r *http.Request) { // 覆盖cookieInSystem cookieInSystem = &http.Cookie{ Name: cookieName, Value: "", Path: "/", Expires: time.Unix(0, 0), // 设置为过期时间 MaxAge: -1, // 立即删除该 Cookie HttpOnly: true, } // 设置过期的 Cookie http.SetCookie(w, cookieInSystem) // 重定向用户到登录页面 http.Redirect(w, r, "./login", http.StatusFound) } ================================================ FILE: web/logs.go ================================================ package web import ( "encoding/json" "io" "log" "net/http" "os" ) // MemoryLogs 内存中的日志 type MemoryLogs struct { MaxNum int // 保存最大条数 Logs []string // 日志 } func (mlogs *MemoryLogs) Write(p []byte) (n int, err error) { mlogs.Logs = append(mlogs.Logs, string(p)) // 处理日志数量 if len(mlogs.Logs) > mlogs.MaxNum { mlogs.Logs = mlogs.Logs[len(mlogs.Logs)-mlogs.MaxNum:] } return len(p), nil } var mlogs = &MemoryLogs{MaxNum: 50} // 初始化日志 func init() { log.SetOutput(io.MultiWriter(mlogs, os.Stdout)) // log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile) } // Logs web func Logs(writer http.ResponseWriter, request *http.Request) { // mlogs.Logs数组转为json logs, _ := json.Marshal(mlogs.Logs) writer.Write(logs) } // ClearLog func ClearLog(writer http.ResponseWriter, request *http.Request) { mlogs.Logs = mlogs.Logs[:0] } ================================================ FILE: web/return_json.go ================================================ package web import ( "encoding/json" "net/http" ) // Result Result type Result struct { Code int // 状态 Msg string // 消息 Data interface{} // 数据 } // returnError 返回错误信息 func returnError(w http.ResponseWriter, msg string) { result := &Result{} result.Code = http.StatusInternalServerError result.Msg = msg json.NewEncoder(w).Encode(result) } // returnOK 返回成功信息 func returnOK(w http.ResponseWriter, msg string, data interface{}) { result := &Result{} result.Code = http.StatusOK result.Msg = msg result.Data = data json.NewEncoder(w).Encode(result) } ================================================ FILE: web/save.go ================================================ package web import ( "encoding/json" "net/http" "strings" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/dns" "github.com/jeessy2/ddns-go/v6/util" ) // Save 保存 func Save(writer http.ResponseWriter, request *http.Request) { result := checkAndSave(request) dnsConfJsonStr := "[]" if result == "ok" { conf, _ := config.GetConfigCached() dnsConfJsonStr = getDnsConfStr(conf.DnsConf) } byt, _ := json.Marshal(map[string]string{"result": result, "dnsConf": dnsConfJsonStr}) writer.Write(byt) } func checkAndSave(request *http.Request) string { conf, _ := config.GetConfigCached() // 从请求中读取 JSON 数据 var data struct { Username string `json:"Username"` Password string `json:"Password"` NotAllowWanAccess bool `json:"NotAllowWanAccess"` WebhookURL string `json:"WebhookURL"` WebhookRequestBody string `json:"WebhookRequestBody"` WebhookHeaders string `json:"WebhookHeaders"` DnsConf []dnsConf4JS `json:"DnsConf"` } // 解析请求中的 JSON 数据 err := json.NewDecoder(request.Body).Decode(&data) if err != nil { return util.LogStr("数据解析失败, 请刷新页面重试") } usernameNew := strings.TrimSpace(data.Username) passwordNew := data.Password // 国际化 accept := request.Header.Get("Accept-Language") conf.Lang = util.InitLogLang(accept) conf.NotAllowWanAccess = data.NotAllowWanAccess conf.WebhookURL = strings.TrimSpace(data.WebhookURL) conf.WebhookRequestBody = strings.TrimSpace(data.WebhookRequestBody) conf.WebhookHeaders = strings.TrimSpace(data.WebhookHeaders) // 如果新密码不为空则检查是否够强, 内/外网要求强度不同 conf.Username = usernameNew if passwordNew != "" { hashedPwd, err := conf.CheckPassword(passwordNew) if err != nil { return err.Error() } conf.Password = hashedPwd } // 帐号密码不能为空 if conf.Username == "" || conf.Password == "" { return util.LogStr("必须输入用户名/密码") } dnsConfFromJS := data.DnsConf var dnsConfArray []config.DnsConfig empty := dnsConf4JS{} for k, v := range dnsConfFromJS { if v == empty { continue } dnsConf := config.DnsConfig{Name: v.Name, TTL: v.TTL} // 覆盖以前的配置 dnsConf.DNS.Name = v.DnsName dnsConf.DNS.ID = strings.TrimSpace(v.DnsID) dnsConf.DNS.Secret = strings.TrimSpace(v.DnsSecret) dnsConf.DNS.ExtParam = strings.TrimSpace(v.DnsExtParam) if v.Ipv4Domains == "" && v.Ipv6Domains == "" { util.Log("第 %s 个配置未填写域名", util.Ordinal(k+1, conf.Lang)) } dnsConf.Ipv4.Enable = v.Ipv4Enable dnsConf.Ipv4.GetType = v.Ipv4GetType dnsConf.Ipv4.URL = strings.TrimSpace(v.Ipv4Url) dnsConf.Ipv4.NetInterface = v.Ipv4NetInterface dnsConf.Ipv4.Cmd = strings.TrimSpace(v.Ipv4Cmd) dnsConf.Ipv4.Domains = util.SplitLines(v.Ipv4Domains) dnsConf.Ipv6.Enable = v.Ipv6Enable dnsConf.Ipv6.GetType = v.Ipv6GetType dnsConf.Ipv6.URL = strings.TrimSpace(v.Ipv6Url) dnsConf.Ipv6.NetInterface = v.Ipv6NetInterface dnsConf.Ipv6.Cmd = strings.TrimSpace(v.Ipv6Cmd) dnsConf.Ipv6.Ipv6Reg = strings.TrimSpace(v.Ipv6Reg) dnsConf.Ipv6.Domains = util.SplitLines(v.Ipv6Domains) dnsConf.HttpInterface = strings.TrimSpace(v.HttpInterface) if k < len(conf.DnsConf) { c := &conf.DnsConf[k] idHide, secretHide := getHideIDSecret(c) if dnsConf.DNS.ID == idHide { dnsConf.DNS.ID = c.DNS.ID } if dnsConf.DNS.Secret == secretHide { dnsConf.DNS.Secret = c.DNS.Secret } } dnsConfArray = append(dnsConfArray, dnsConf) } conf.DnsConf = dnsConfArray // 保存到用户目录 err = conf.SaveConfig() // 只运行一次 util.ForceCompareGlobal = true go dns.RunOnce() // 回写错误信息 if err != nil { return err.Error() } return "ok" } ================================================ FILE: web/webhookTest.go ================================================ package web import ( "encoding/json" "net/http" "github.com/jeessy2/ddns-go/v6/config" "github.com/jeessy2/ddns-go/v6/util" ) func WebhookTest(writer http.ResponseWriter, request *http.Request) { var data struct { URL string `json:"URL"` RequestBody string `json:"RequestBody"` Headers string `json:"Headers"` } err := json.NewDecoder(request.Body).Decode(&data) if err != nil { util.Log("数据解析失败, 请刷新页面重试") return } url := data.URL requestBody := data.RequestBody headers := data.Headers if url == "" { util.Log("请输入Webhook的URL") return } var domains = make([]*config.Domain, 1) domains[0] = &config.Domain{} domains[0].DomainName = "example.com" domains[0].SubDomain = "test" domains[0].UpdateStatus = config.UpdatedSuccess fakeDomains := &config.Domains{ Ipv4Addr: "127.0.0.1", Ipv4Domains: domains, Ipv6Addr: "::1", Ipv6Domains: domains, } fakeConfig := &config.Config{ Webhook: config.Webhook{ WebhookURL: url, WebhookRequestBody: requestBody, WebhookHeaders: headers, }, } config.ExecWebhook(fakeDomains, fakeConfig) } ================================================ FILE: web/writing.go ================================================ package web import ( "embed" "encoding/json" "fmt" "html/template" "net/http" "os" "strings" "github.com/jeessy2/ddns-go/v6/config" ) //go:embed writing.html var writingEmbedFile embed.FS const VersionEnv = "DDNS_GO_VERSION" // js中的dns配置 type dnsConf4JS struct { Name string DnsName string DnsID string DnsSecret string DnsExtParam string TTL string Ipv4Enable bool Ipv4GetType string Ipv4Url string Ipv4NetInterface string Ipv4Cmd string Ipv4Domains string Ipv6Enable bool Ipv6GetType string Ipv6Url string Ipv6NetInterface string Ipv6Cmd string Ipv6Reg string Ipv6Domains string HttpInterface string } // Writing 填写信息 func Writing(writer http.ResponseWriter, request *http.Request) { tmpl, err := template.ParseFS(writingEmbedFile, "writing.html") if err != nil { fmt.Println("Error happened..") fmt.Println(err) return } conf, err := config.GetConfigCached() // 默认禁止公网访问 if err != nil { conf.NotAllowWanAccess = true } ipv4, ipv6, _ := config.GetNetInterface() // 获取所有网卡(去重合并IPv4和IPv6网卡名称) allIfaceNames := map[string]bool{} for _, iface := range ipv4 { allIfaceNames[iface.Name] = true } for _, iface := range ipv6 { allIfaceNames[iface.Name] = true } allInterfaces := []config.NetInterface{} for name := range allIfaceNames { allInterfaces = append(allInterfaces, config.NetInterface{Name: name}) } err = tmpl.Execute(writer, struct { DnsConf template.JS NotAllowWanAccess bool Username string config.Webhook Version string Ipv4 []config.NetInterface Ipv6 []config.NetInterface AllInterfaces []config.NetInterface }{ DnsConf: template.JS(getDnsConfStr(conf.DnsConf)), NotAllowWanAccess: conf.NotAllowWanAccess, Username: conf.User.Username, Webhook: conf.Webhook, Version: os.Getenv(VersionEnv), Ipv4: ipv4, Ipv6: ipv6, AllInterfaces: allInterfaces, }) if err != nil { fmt.Println("Error happened..") fmt.Println(err) } } func getDnsConfStr(dnsConf []config.DnsConfig) string { dnsConfArray := []dnsConf4JS{} for _, conf := range dnsConf { // 已存在配置文件,隐藏真实的ID、Secret idHide, secretHide := getHideIDSecret(&conf) dnsConfArray = append(dnsConfArray, dnsConf4JS{ Name: conf.Name, DnsName: conf.DNS.Name, DnsID: idHide, DnsSecret: secretHide, DnsExtParam: conf.DNS.ExtParam, TTL: conf.TTL, Ipv4Enable: conf.Ipv4.Enable, Ipv4GetType: conf.Ipv4.GetType, Ipv4Url: conf.Ipv4.URL, Ipv4NetInterface: conf.Ipv4.NetInterface, Ipv4Cmd: conf.Ipv4.Cmd, Ipv4Domains: strings.Join(conf.Ipv4.Domains, "\r\n"), Ipv6Enable: conf.Ipv6.Enable, Ipv6GetType: conf.Ipv6.GetType, Ipv6Url: conf.Ipv6.URL, Ipv6NetInterface: conf.Ipv6.NetInterface, Ipv6Cmd: conf.Ipv6.Cmd, Ipv6Reg: conf.Ipv6.Ipv6Reg, Ipv6Domains: strings.Join(conf.Ipv6.Domains, "\r\n"), HttpInterface: conf.HttpInterface, }) } byt, _ := json.Marshal(dnsConfArray) return string(byt) } // 显示的数量 const displayCount int = 3 // hideIDSecret 隐藏真实的ID、Secret func getHideIDSecret(conf *config.DnsConfig) (idHide string, secretHide string) { if len(conf.DNS.ID) > displayCount && conf.DNS.Name != "callback" { idHide = conf.DNS.ID[:displayCount] + strings.Repeat("*", len(conf.DNS.ID)-displayCount) } else { idHide = conf.DNS.ID } if len(conf.DNS.Secret) > displayCount && conf.DNS.Name != "callback" { secretHide = conf.DNS.Secret[:displayCount] + strings.Repeat("*", len(conf.DNS.Secret)-displayCount) } else { secretHide = conf.DNS.Secret } return } ================================================ FILE: web/writing.html ================================================ DDNS-GO
DNS Provider
IPv4
IPv6
Others
Webhook