[
  {
    "path": ".editorconfig",
    "content": "# http://editorconfig.org\n\nroot = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.go]\nindent_style = tab\nindent_size = 2\n\n[Dockerfile]\nindent_style = tab\nindent_size = 4\n\n[Makefile]\nindent_style = tab\nindent_size = 4\n\n[.travis.yml]\nindent_style = space\nindent_size = 2\n\n[*.json]\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: Bug\ndescription: Report a bug in ddns-go\nlabels: ['bug']\n\nbody:\n  - type: textarea\n    attributes:\n      label: Description\n      description: A clear and concise description of what the bug is\n    validations:\n      required: true\n\n  - type: dropdown\n    attributes:\n      label: DNS Provider\n      description: The DNS provider you are using\n      multiple: true\n      options:\n        - 阿里云\n        - 腾讯云\n        - DnsPod\n        - Cloudflare\n        - 华为云\n        - Callback\n        - 百度云\n        - Porkbun\n        - GoDaddy\n        - Namecheap\n        - NameSilo\n        - Vercel\n        - Dynadot\n        - Others\n\n  - type: dropdown\n    attributes:\n      label: Did you search for similar issues before submitting this one?\n      options:\n        - No, I didn't\n        - Yes, I did, but I didn't find anything useful\n    validations:\n      required: true\n\n  - type: dropdown\n    attributes:\n      label: Operating System\n      description: The operating system you are running ddns-go on\n      options:\n        - Linux\n        - Windows\n        - macOS (Darwin)\n        - FreeBSD\n    validations:\n      required: true\n\n  - type: dropdown\n    attributes:\n      label: Architecture\n      description: The architecture you are running ddns-go on\n      options:\n        - i386\n        - x86_64\n        - armv5\n        - armv6\n        - armv7\n        - arm64\n        - mips\n        - mipsle\n        - mips64\n        - mips64le\n    validations:\n      required: true\n\n  - type: input\n    attributes:\n      label: Version\n      description: The version of ddns-go you are using\n      placeholder: v0.0.1\n    validations:\n      required: true\n\n  - type: dropdown\n    attributes:\n      label: How are you running ddns-go?\n      options:\n        - Docker\n        - Service\n        - Other\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Any other information\n      description: |\n        Please provide the steps to reproduce the bug.\n        Or any other screenshots or logs that might help us understand the issue better."
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: false\ncontact_links:\n  - name: None of the above?\n    url: https://github.com/jeessy2/ddns-go/discussions\n    about: If you have any other questions, please visit our Discussions page\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature Request\ndescription: Feature request for ddns-go\nlabels: ['enhancement']\n\nbody:\n  - type: textarea\n    attributes:\n      label: Description\n      description: A clear and concise description of what the feature is\n    validations:\n      required: true\n\n  - type: textarea\n    attributes:\n      label: Problem\n      description: Describe the problem you are facing\n\n  - type: textarea\n    attributes:\n      label: Other Description\n      description: Any other information you would like to provide\n\n  - type: checkboxes\n    attributes:\n      label: Checklist\n      description: Please check the following before submitting your feature request\n      options:\n        - label: I am using the latest version and have confirmed that the feature is not yet implemented in the latest version\n          required: true\n        - label: I have searched for similar feature requests before submitting this one\n          required: true\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "# To get started with Dependabot version updates, you'll need to specify which\n# package ecosystems to update and where the package manifests are located.\n# Please see the documentation for all configuration options:\n# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates\n\nversion: 2\nupdates:\n  - package-ecosystem: \"gomod\" # Golang\n    directory: \"/\" # Location of package manifests\n    schedule:\n      interval: \"daily\"\n\n  - package-ecosystem: \"github-actions\" # GitHub Actions\n    directory: \"/\"\n    schedule:\n      interval: \"daily\"\n"
  },
  {
    "path": ".github/pull_request_template.md",
    "content": "# What does this PR do?\n\n# Motivation\n\n# Additional Notes\n"
  },
  {
    "path": ".github/workflows/dockerhub-description.yml",
    "content": "name: Update Docker Hub Description\non:\n  push:\n    branches:\n      - master\n    paths:\n      - README.md\n      - .github/workflows/dockerhub-description.yml\n\njobs:\n  dockerHubDescription:\n    runs-on: ubuntu-latest\n    if: github.repository == 'jeessy2/ddns-go'\n    steps:\n    - name: Checkout\n      uses: actions/checkout@v6\n\n    - name: Docker Hub Description\n      uses: peter-evans/dockerhub-description@v5\n      with:\n        username: ${{ secrets.DOCKER_USERNAME }}\n        password: ${{ secrets.DOCKER_PASSWORD }}\n        repository: ${{ secrets.DOCKER_USERNAME }}/ddns-go\n        short-description: ${{ github.event.repository.description }}\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: release\n\non:\n  push:\n    # Sequence of patterns matched against refs/tags\n    tags:\n      - 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10\n\npermissions:\n  contents: write\n  packages: write\n\njobs:\n  goreleaser:\n    name: Build\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n        with:\n          fetch-depth: 0\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: 'go.mod'\n\n      - name: Set up QEMU\n        uses: docker/setup-qemu-action@v4\n        \n      - name: Set up Docker Buildx\n        uses: docker/setup-buildx-action@v4\n        \n      - name: Login to Docker Hub\n        uses: docker/login-action@v4\n        with:\n          username: ${{ secrets.DOCKER_USERNAME }}\n          password: ${{ secrets.DOCKER_PASSWORD }}\n          \n      - name: Login to GitHub Container Registry\n        uses: docker/login-action@v4\n        with:\n          registry: ghcr.io\n          username: ${{ github.actor }}\n          password: ${{ secrets.GITHUB_TOKEN }}\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v7\n        if: startsWith(github.ref, 'refs/tags/')\n        with:\n          distribution: goreleaser\n          version: latest\n          args: release --clean\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: Close stale issues and PRs\n\non:\n  schedule:\n  - cron: \"30 1 * * *\"\n\njobs:\n  stale:\n    permissions:\n      issues: write  # for actions/stale to close stale issues\n      pull-requests: write  # for actions/stale to close stale PRs\n    runs-on: ubuntu-latest\n    steps:\n    - name: Stale\n      uses: actions/stale@v10\n      with:\n        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.'\n        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.'\n        close-issue-message: 'This issue was closed because it has been stalled for 5 days with no activity.'\n        close-pr-message: 'This PR was closed because it has been stalled for 5 days with no activity.'\n        exempt-issue-labels: 'bug,help wanted,question,documentation,keep'\n        exempt-pr-labels: 'bug,help wanted,question,documentation,keep'\n        days-before-stale: 30\n        days-before-close: 5\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "name: test\n\non:\n  push:\n  pull_request:\n\njobs:\n  test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        goarch: [amd64, arm64, riscv64]\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v6\n\n      - name: Set up Go\n        uses: actions/setup-go@v6\n        with:\n          go-version-file: 'go.mod'\n\n      - name: Test\n        run: |\n          # Run tests only when GOARCH is amd64, otherwize run builds only.\n          if [ \"${{ matrix.goarch }}\" = \"amd64\" ]; then\n            make build test\n          else\n            GOARCH=${{ matrix.goarch }} make build\n          fi\n      - name: Upload artifact\n        uses: actions/upload-artifact@v7\n        with:\n          name: ddns-go_${{ matrix.goarch }}\n          path: ddns-go\n"
  },
  {
    "path": ".gitignore",
    "content": "# Compiled Object files, Static and Dynamic libs (Shared Objects)\n*.o\n*.a\n*.so\n/ddns-go\n__*\n\n# Folders\n_obj\n_test\n.vagrant\nreleases\ntmp\n/.idea/\nvendor/\n/dist\n\n# Architecture specific extensions/prefixes\ntrace.out\n*.out\n.DS_Store\n_testmain.go\n\n*.exe\n*.test\n*.prof\nprofile.cov\ncoverage.html\n/go.sum\n\n# Emacs backup files\n*~\n.*~\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "# This is an example goreleaser.yaml file with some sane defaults.\n# Make sure to check the documentation at http://goreleaser.com\n\nversion: 2\n\nbefore:\n  hooks:\n    # You may remove this if you don't use go modules.\n    - go mod download\n    # you may remove this if you don't need go generate\n    - go generate ./...\nbuilds:\n  - env:\n      - CGO_ENABLED=0\n    flags:\n      - -trimpath\n    goos:\n      - android\n      - linux\n      - windows\n      - darwin\n      - freebsd\n    goarch:\n      - '386'\n      - amd64\n      - arm\n      - arm64\n      - mips\n      - mipsle\n      - mips64\n      - mips64le\n      - riscv64\n    goarm:\n      - '5'\n      - '6'\n      - '7'\n    gomips:\n      - hardfloat\n      - softfloat\n    ignore:\n      # we only need the arm64 build on android\n      - goos: android\n        goarch: arm\n      - goos: android\n        goarch: '386'\n      - goos: android\n        goarch: amd64\n    ldflags:\n      - -s -w -X main.version={{.Tag}} -X main.buildTime={{.Date}}\n    hooks:\n      post:\n        - sh -c 'test -d zoneinfo || cp -r /usr/share/zoneinfo .'\n\narchives:\n  # use zip for windows archives\n  - format_overrides:\n      - goos: windows\n        format: zip\n    # this name template makes the OS and Arch compatible with the results of uname.\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- .Version }}_\n      {{- .Os }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n      {{- if .Mips }}_{{ .Mips }}{{ end }}\n      {{- if .Arm }}v{{ .Arm }}{{ end }}\n\nchecksum:\n  name_template: 'checksums.txt'\nsnapshot:\n  version_template: \"{{ incpatch .Version }}-devel\"\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n      - '^docs:'\n      - '^test:'\n\ndockers:\n  - image_templates:\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64\"\n    use: buildx\n    extra_files:\n      - zoneinfo\n    build_flag_templates:\n      - \"--platform=linux/amd64\"\n      - \"--label=org.opencontainers.image.created={{.Date}}\"\n      - \"--label=org.opencontainers.image.title={{.ProjectName}}\"\n      - \"--label=org.opencontainers.image.revision={{.FullCommit}}\"\n      - \"--label=org.opencontainers.image.version={{.Version}}\"\n\n  - image_templates:\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64\"\n    use: buildx\n    extra_files:\n      - zoneinfo\n    build_flag_templates:\n      - \"--platform=linux/arm64\"\n      - \"--label=org.opencontainers.image.created={{.Date}}\"\n      - \"--label=org.opencontainers.image.title={{.ProjectName}}\"\n      - \"--label=org.opencontainers.image.revision={{.FullCommit}}\"\n      - \"--label=org.opencontainers.image.version={{.Version}}\"\n    goarch: arm64\n\n  - image_templates:\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7\"\n    use: buildx\n    extra_files:\n      - zoneinfo\n    build_flag_templates:\n      - \"--platform=linux/arm/v7\"\n      - \"--label=org.opencontainers.image.created={{.Date}}\"\n      - \"--label=org.opencontainers.image.title={{.ProjectName}}\"\n      - \"--label=org.opencontainers.image.revision={{.FullCommit}}\"\n      - \"--label=org.opencontainers.image.version={{.Version}}\"\n    goarch: arm\n    goarm: 7\n\n  - image_templates:\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-riscv64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-riscv64\"\n    use: buildx\n    extra_files:\n      - zoneinfo\n    build_flag_templates:\n      - \"--platform=linux/riscv64\"\n      - \"--label=org.opencontainers.image.created={{.Date}}\"\n      - \"--label=org.opencontainers.image.title={{.ProjectName}}\"\n      - \"--label=org.opencontainers.image.revision={{.FullCommit}}\"\n      - \"--label=org.opencontainers.image.version={{.Version}}\"\n    goarch: riscv64\n\ndocker_manifests:\n  - name_template: \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}\"\n    image_templates:\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64\"\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64\"\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7\"\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-riscv64\"\n\n  - name_template: \"{{ .Env.DOCKER_USERNAME }}/ddns-go:latest\"\n    image_templates:\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-amd64\"\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-arm64\"\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-armv7\"\n      - \"{{ .Env.DOCKER_USERNAME }}/ddns-go:{{ .Tag }}-riscv64\"\n\n  - name_template: \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}\"\n    image_templates:\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-riscv64\"\n\n  - name_template: \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:latest\"\n    image_templates:\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-amd64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-arm64\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-armv7\"\n      - \"ghcr.io/{{ tolower .Env.GITHUB_REPOSITORY }}:{{ .Tag }}-riscv64\"\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "{\n    // Use IntelliSense to learn about possible attributes.\n    // Hover to view descriptions of existing attributes.\n    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n    \"version\": \"0.2.0\",\n    \"configurations\": [\n        {\n            \"name\": \"Launch\",\n            \"type\": \"go\",\n            \"request\": \"launch\",\n            \"mode\": \"auto\",\n            \"program\": \"${workspaceFolder}/main.go\",\n            \"env\": {},\n            \"args\": []\n        }\n    ]\n}"
  },
  {
    "path": "Dockerfile",
    "content": "FROM alpine\nLABEL name=ddns-go\nLABEL url=https://github.com/jeessy2/ddns-go\nRUN apk add --no-cache curl grep\n\nWORKDIR /app\nCOPY ddns-go /app/\nCOPY zoneinfo /usr/share/zoneinfo\nENV TZ=Asia/Shanghai\nEXPOSE 9876\nENTRYPOINT [\"/app/ddns-go\"]\nCMD [\"-l\", \":9876\", \"-f\", \"300\"] "
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 jeessy\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "Makefile",
    "content": ".PHONY: build clean test test-race\n\n# 如果找不到 tag 则使用 HEAD commit\nVERSION=$(shell git describe --tags `git rev-list --tags --max-count=1` 2>/dev/null || git rev-parse --short HEAD)\nBUILD_TIME=$(shell date -u +\"%Y-%m-%dT%H:%M:%SZ\")\nBIN=ddns-go\nDIR_SRC=.\nDOCKER_ENV=DOCKER_BUILDKIT=1\nDOCKER=$(DOCKER_ENV) docker\n\nGO_ENV=CGO_ENABLED=0\nGO_FLAGS=-ldflags=\"-X main.version=$(VERSION) -X 'main.buildTime=$(BUILD_TIME)' -extldflags -static -s -w\" -trimpath\nGO=$(GO_ENV) $(shell which go)\nGOROOT=$(shell `which go` env GOROOT)\nGOPATH=$(shell `which go` env GOPATH)\n\nbuild: $(DIR_SRC)/main.go\n\t@$(GO) build $(GO_FLAGS) -o $(BIN) $(DIR_SRC)\n\nbuild_docker_image:\n\t@$(DOCKER) build -f ./Dockerfile -t ddns-go:$(VERSION) .\n\ntest:\n\t@$(GO) test ./...\n\ntest-race:\n\t@$(GO) test -race ./...\n\n# clean all build result\nclean:\n\t@$(GO) clean ./...\n\t@rm -f $(BIN)\n\t@rm -rf ./dist/*"
  },
  {
    "path": "README.md",
    "content": "# DDNS-GO\n\n[![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)\n\n中文 | [English](https://github.com/jeessy2/ddns-go/blob/master/README_EN.md)\n\n自动获得你的公网 IPv4 或 IPv6 地址，并解析到对应的域名服务。\n\n- [特性](#特性)\n- [系统中使用](#系统中使用)\n- [Docker中使用](#docker中使用)\n- [使用IPv6](#使用ipv6)\n- [Webhook](#webhook)\n- [Callback](#callback)\n- [界面](#界面)\n- [开发&自行编译](#开发自行编译)\n\n## 特性\n\n- 支持Mac、Windows、Linux系统，支持ARM、x86、RISC-V架构\n- 支持的域名服务商 `阿里云` `阿里云 ESA` `腾讯云` `Dnspod` `Cloudflare` `华为云` `Callback` `百度云` `Porkbun` `GoDaddy` `Namecheap` `NameSilo` `Dynadot` `DNSLA` `时代互联` `Eranet` `Gcore` `IBM NS1 Connect` `雨云`\n- 支持接口/网卡/[命令](https://github.com/jeessy2/ddns-go/wiki/通过命令获取IP参考)获取IP\n- 支持以服务的方式运行\n- 默认间隔5分钟同步一次\n- 支持同时配置多个DNS服务商\n- 支持多个域名同时解析\n- 支持多级域名\n- 网页中配置，简单又方便，默认勾选`禁止从公网访问`\n- 网页中方便快速查看最近50条日志\n- 支持Webhook通知\n- 支持TTL\n- 支持部分DNS服务商[传递自定义参数](https://github.com/jeessy2/ddns-go/wiki/传递自定义参数)，实现地域解析/多IP等功能\n\n> [!NOTE]\n> 建议在启用公网访问时，使用 Nginx 等反向代理软件启用 HTTPS 访问，以保证安全性。[FAQ](https://github.com/jeessy2/ddns-go/wiki/FAQ)\n\n## 系统中使用\n\n- 从 [Releases](https://github.com/jeessy2/ddns-go/releases) 下载并解压 ddns-go\n- 安装服务\n  - Mac/Linux: `sudo ./ddns-go -s install`\n  - Win(以管理员打开cmd): `.\\ddns-go.exe -s install`\n- 配置\n  - 打开浏览器并访问`http://localhost:9876`进行初始化配置\n- [可选] 服务卸载\n  - Mac/Linux: `sudo ./ddns-go -s uninstall`\n  - Win(以管理员打开cmd): `.\\ddns-go.exe -s uninstall`\n- [可选] 支持安装带参数\n  - `-l` 监听地址\n  - `-f` 同步间隔时间(秒)\n  - `-cacheTimes` 间隔N次与服务商比对\n  - `-c` 自定义配置文件路径\n  - `-noweb` 不启动web服务\n  - `-skipVerify` 跳过证书验证\n  - `-dns` 自定义 DNS 服务器\n  - `-resetPassword` 重置密码\n- [可选] 参考示例\n  - 10分钟同步一次, 并指定了配置文件地址\n    ```bash\n    ./ddns-go -s install -f 600 -c /Users/name/.ddns_go_config.yaml\n    ```\n  - 每 10 秒检查一次本地 IP 变化, 每 30 分钟对比一下 IP 变化, 实现 IP 变化即时触发更新且不会被服务商限流, 如果使用接口获取IP, 需要注意接口限流\n    ```bash\n    ./ddns-go -s install -f 10 -cacheTimes 180\n    ```\n  - 重置密码\n    ```bash\n    ./ddns-go -resetPassword 123456\n    ./ddns-go -resetPassword 123456 -c /Users/name/.ddns_go_config.yaml\n    ```\n\n## Docker中使用\n\n- 挂载主机目录, 使用docker host模式。可把 `/opt/ddns-go` 替换为你主机任意目录, 配置文件为隐藏文件\n\n  ```bash\n  docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go\n  ```\n\n- 打开浏览器并访问`http://Docker主机IP:9876`进行初始化配置\n\n- [可选] 使用 `ghcr.io` 镜像\n\n  ```bash\n  docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root ghcr.io/jeessy2/ddns-go\n  ```\n\n- [可选] 支持启动带参数 `-l`监听地址 `-f`间隔时间(秒)\n\n  ```bash\n  docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go -l :9877 -f 600\n  ```\n\n- [可选] 不使用docker host模式\n\n  ```bash\n  docker run -d --name ddns-go --restart=always -p 9876:9876 -v /opt/ddns-go:/root jeessy/ddns-go\n  ```\n\n- [可选] 重置密码\n\n  ```bash\n  docker exec ddns-go ./ddns-go -resetPassword 123456\n  docker restart ddns-go\n  ```\n\n## 使用IPv6\n\n- 前提：你的电脑或终端能正常获取IPv6，并能正常访问IPv6\n- Windows/Mac：推荐 [系统中使用](#系统中使用)，Windows/Mac桌面版的docker不支持`--net=host`\n- 群晖：\n  - 套件中心下载docker并打开\n  - 注册表中搜索`ddns-go`并下载\n  - 映像 -> 选择`jeessy/ddns-go` -> 启动 -> 高级设置 -> 网络中勾选`使用与 Docker Host 相同的网络`，高级设置中勾选`启动自动重新启动`\n  - 在浏览器中打开`http://群晖IP:9876`，修改你的配置，成功\n- Linux的x86或arm架构，推荐使用Docker的`--net=host`模式。参考 [Docker中使用](#Docker中使用)\n- 虚拟机中使用有可能正常获取IPv6，但不能正常访问IPv6\n\n## Webhook\n\n- 支持webhook, 域名更新成功或不成功时, 会回调填写的URL\n- 支持的变量\n\n  |  变量名   | 说明  |\n  |  ----  | ----  |\n  | #{ipv4Addr}  | 新的IPv4地址 |\n  | #{ipv4Result}  | IPv4地址更新结果: `未改变` `失败` `成功`|\n  | #{ipv4Domains}  | IPv4的域名，多个以`,`分割 |\n  | #{ipv6Addr}  | 新的IPv6地址 |\n  | #{ipv6Result}  | IPv6地址更新结果: `未改变` `失败` `成功`|\n  | #{ipv6Domains}  | IPv6的域名，多个以`,`分割 |\n\n- 如 RequestBody 为空则为 GET 请求，否则为 POST 请求\n- <details><summary>Server酱</summary>\n\n  ```\n  https://sctapi.ftqq.com/[SendKey].send?title=你的公网IP变了&desp=主人IPv4变了#{ipv4Addr},域名更新结果:#{ipv4Result}\n  ```\n- <details><summary>Bark</summary>\n\n  ```\n  https://api.day.app/[YOUR_KEY]/主人IPv4变了#{ipv4Addr},域名更新结果:#{ipv4Result}\n  ```\n  </details>\n- <details><summary>钉钉</summary>\n\n  - 钉钉电脑端 -> 群设置 -> 智能群助手 -> 添加机器人 -> 自定义\n  - 只勾选 `自定义关键词`, 输入的关键字必须包含在RequestBody的content中, 如：`你的公网IP变了`\n  - URL中输入钉钉给你的 `Webhook地址`\n  - RequestBody中输入\n    ```json\n    {\n        \"msgtype\": \"markdown\",\n        \"markdown\": {\n            \"title\": \"你的公网IP变了\",\n            \"text\": \"#### 你的公网IP变了 \\n - IPv4地址：#{ipv4Addr} \\n - 域名更新结果：#{ipv4Result} \\n\"\n        }\n    }\n    ```\n  </details>\n- <details><summary>飞书</summary>\n\n  - 飞书电脑端 -> 群设置 -> 添加机器人 -> 自定义机器人\n  - 安全设置只勾选 `自定义关键词`, 输入的关键字必须包含在RequestBody的content中, 如：`你的公网IP变了`\n  - URL中输入飞书给你的 `Webhook地址`\n  - RequestBody中输入\n    ```json\n    {\n        \"msg_type\": \"post\",\n        \"content\": {\n            \"post\": {\n                \"zh_cn\": {\n                    \"title\": \"你的公网IP变了\",\n                    \"content\": [\n                        [\n                            {\n                                \"tag\": \"text\",\n                                \"text\": \"IPv4地址：#{ipv4Addr}\"\n                            }\n                        ],\n                        [\n                            {\n                                \"tag\": \"text\",\n                                \"text\": \"域名更新结果：#{ipv4Result}\"\n                            }\n                        ]\n                    ]\n                }\n            }\n        }\n    }\n    ```\n  </details>\n- <details><summary>Telegram</summary>\n\n  [ddns-telegram-bot](https://github.com/WingLim/ddns-telegram-bot)\n  </details>\n- <details><summary>plusplus 推送加</summary>\n\n  - [获取token](https://www.pushplus.plus/push1.html)\n  - URL中输入 `https://www.pushplus.plus/send`\n  - RequestBody中输入\n    ```json\n    {\n        \"token\": \"your token\",\n        \"title\": \"你的公网IP变了\",\n        \"content\": \"你的公网IP变了 \\n - IPv4地址：#{ipv4Addr} \\n - 域名更新结果：#{ipv4Result} \\n\"\n    }\n    ```\n  </details>\n- <details><summary>Discord</summary>\n\n  - Discord任意客户端 -> 伺服器 -> 频道设置 -> 整合 -> 查看Webhook -> 新Webhook -> 复制Webhook网址\n  - URL中输入Discord复制的 `Webhook网址`\n  - RequestBody中输入\n    ```json\n    {\n        \"content\": \"域名 #{ipv4Domains} 动态解析 #{ipv4Result}.\",\n        \"embeds\": [\n            {\n                \"description\": \"#{ipv4Domains} 的动态解析 #{ipv4Result}, IP: #{ipv4Addr}\",\n                \"color\": 15258703,\n                \"author\": {\n                    \"name\": \"DDNS\"\n                },\n                \"footer\": {\n                    \"text\": \"DDNS #{ipv4Result}\"\n                }\n            }\n        ]\n    }\n    ```\n  </details>\n\n- [查看更多Webhook配置参考](https://github.com/jeessy2/ddns-go/issues/327)\n\n## Callback\n\n- 通过自定义回调可支持更多的第三方DNS服务商\n- 配置的域名有几行, 就会回调几次\n- 支持的变量\n\n  |  变量名   | 说明  |\n  |  ----  | ----  |\n  | #{ip}  | 新的IPv4/IPv6地址 |\n  | #{domain}  | 当前域名 |\n  | #{recordType}  | 记录类型 `A`或`AAAA` |\n  | #{ttl}  | TTL |\n- 如 RequestBody 为空则为 GET 请求，否则为 POST 请求\n- [Callback配置参考](https://github.com/jeessy2/ddns-go/wiki/Callback配置参考)\n\n## 界面\n\n![screenshots](https://raw.githubusercontent.com/jeessy2/ddns-go/master/ddns-web.png)\n\n## 开发&自行编译\n\n- 如果喜欢从源代码编译自己的版本，可以使用本项目提供的 Makefile 构建\n- 使用 `make build` 生成本地编译后的 `ddns-go` 可执行文件\n- 使用 `make build_docker_image` 自行编译 Docker 镜像\n"
  },
  {
    "path": "README_EN.md",
    "content": "# DDNS-GO\n\n[![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)\n\n[中文](https://github.com/jeessy2/ddns-go/blob/master/README.md) | English\n\nAutomatically obtain your public IPv4 or IPv6 address and resolve it to the corresponding domain name service.\n\n- [Features](#Features)\n- [Use in system](#Use-in-system)\n- [Use in docker](#Use-in-docker)\n- [Webhook](#webhook)\n- [Callback](#callback)\n- [Web interfaces](#Web-interfaces)\n\n## Features\n\n- Support Mac, Windows, Linux system, support ARM, x86, RISC-V architecture\n- 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`\n- Support interface / netcard / command to get IP\n- Support running as a service\n- Default interval is 5 minutes\n- Support configuring multiple DNS service providers at the same time\n- Support multiple domain name resolution at the same time\n- Support multi-level domain name\n- Configured on the web page, simple and convenient\n- In the web page, you can quickly view the latest 50 logs\n- Support Webhook notification\n- Support TTL\n- Support for some domain service providers to pass [custom parameters](https://github.com/jeessy2/ddns-go/wiki/传递自定义参数) to achieve multi-IP and other functions\n\n> [!NOTE]\n> If you enable public network access, it is recommended to use Nginx and other reverse proxy software to enable HTTPS access to ensure security.\n\n## Use in system\n\n- Download and unzip ddns-go from [Releases](https://github.com/jeessy2/ddns-go/releases)\n- Run in service mode\n  - Mac/Linux: `sudo ./ddns-go -s install`\n  - Win(Run as administrator): `.\\ddns-go.exe -s install`\n- Config\n  - Please open the browser and visit `http://localhost:9876` for initial configuration\n- [Optional] Uninstall service\n  - Mac/Linux: `sudo ./ddns-go -s uninstall`\n  - Win(Run as administrator): `.\\ddns-go.exe -s uninstall`\n- [Optional] Support installation with parameters\n  - `-l` listen address\n  - `-f` sync frequency(seconds)\n  - `-cacheTimes` interval N times compared with service providers\n  - `-c` custom configuration file path\n  - `-noweb` does not start web service\n  - `-skipVerify` skip certificate verification\n  - `-dns` custom DNS server\n  - `-resetPassword` reset password\n- [Optional] Examples\n  - 10 minutes to synchronize once, and the configuration file address is specified\n    ```bash\n    ./ddns-go -s install -f 600 -c /Users/name/.ddns_go_config.yaml\n    ```\n  - 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\n    ```bash\n    ./ddns-go -s install -f 10 -cacheTimes 180\n    ```\n  - reset password\n    ```bash\n    ./ddns-go -resetPassword 123456\n    ```\n\n## Use in docker\n\n- 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\n\n  ```bash\n  docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go\n  ```\n\n- Please open the browser and visit `http://DOCKER_IP:9876` for initial configuration\n\n- [Optional] Use `ghcr.io` mirror\n\n  ```bash\n  docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root ghcr.io/jeessy2/ddns-go\n  ```\n\n- [Optional] Support startup with parameters `-l`listen address `-f`Sync frequency(seconds)\n\n  ```bash\n  docker run -d --name ddns-go --restart=always --net=host -v /opt/ddns-go:/root jeessy/ddns-go -l :9877 -f 600\n  ```\n\n- [Optional] Without using docker host mode\n\n  ```bash\n  docker run -d --name ddns-go --restart=always -p 9876:9876 -v /opt/ddns-go:/root jeessy/ddns-go\n  ```\n\n- [Optional] Reset password\n\n  ```bash\n  docker exec ddns-go ./ddns-go -resetPassword 123456\n  docker restart ddns-go\n  ```\n\n## Webhook\n\n- Support webhook, when the domain name is updated successfully or not, the URL filled in will be called back\n- Support variables\n\n  |  Variable name   | Comments  |\n  |  ----  | ----  |\n  | #{ipv4Addr}  | The new IPv4 |\n  | #{ipv4Result}  | IPv4 update result: `no changed` `success` `failed`|\n  | #{ipv4Domains}  | IPv4 domains，Split by `,` |\n  | #{ipv6Addr}  | The new IPv6 |\n  | #{ipv6Result}  | IPv6 update result: `no changed` `success` `failed`|\n  | #{ipv6Domains}  | IPv6 domains，Split by `,` |\n\n- If RequestBody is empty, it is a `GET` request, otherwise it is a `POST` request\n\n- <details><summary>Telegram</summary>\n\n  [ddns-telegram-bot](https://github.com/WingLim/ddns-telegram-bot)\n  </details>\n- <details><summary>Discord</summary>\n\n  - Discord client -> Server -> Channel Settings -> Integration -> View Webhook -> New Webhook -> Copy Webhook URL\n  - Input the `Webhook URL` copied from Discord in the URL\n  - Input in RequestBody\n    ```json\n    {\n        \"content\": \"The domain name #{ipv4Domains} dynamically resolves to #{ipv4Result}.\",\n        \"embeds\": [\n            {\n                \"description\": \"Domains: #{ipv4Domains}, Result: #{ipv4Result}, IP: #{ipv4Addr}\",\n                \"color\": 15258703,\n                \"author\": {\n                    \"name\": \"DDNS\"\n                },\n                \"footer\": {\n                    \"text\": \"DDNS #{ipv4Result}\"\n                }\n            }\n        ]\n    }\n    ```\n    </details>\n\n- [More webhook configuration reference](https://github.com/jeessy2/ddns-go/issues/327)\n\n## Callback\n\n- Support more third-party DNS service providers through custom callback\n- Callback will be called as many times as there are lines in the configured domain name\n- Support variables\n\n  |  Variable name   | Comments  |\n  |  ----  | ----  |\n  | #{ip}  | The new IPv4/IPv6 address|\n  | #{domain}  | Current domain |\n  | #{recordType}  | Record type `A` or `AAAA` |\n  | #{ttl}  | TTL |\n- If RequestBody is empty, it is a `GET` request, otherwise it is a `POST` request\n\n## Web interfaces\n\n![screenshots](https://raw.githubusercontent.com/jeessy2/ddns-go/master/ddns-web.png)\n"
  },
  {
    "path": "config/config.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\tpasswordvalidator \"github.com/wagslane/go-password-validator\"\n\t\"gopkg.in/yaml.v3\"\n)\n\n// Ipv4Reg IPv4正则\nvar 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])`)\n\n// Ipv6Reg IPv6正则\nvar 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}))|:)))`)\n\n// DnsConfig 配置\ntype DnsConfig struct {\n\tName string\n\tIpv4 struct {\n\t\tEnable bool\n\t\t// 获取IP类型 url/netInterface\n\t\tGetType      string\n\t\tURL          string\n\t\tNetInterface string\n\t\tCmd          string\n\t\tDomains      []string\n\t}\n\tIpv6 struct {\n\t\tEnable bool\n\t\t// 获取IP类型 url/netInterface\n\t\tGetType      string\n\t\tURL          string\n\t\tNetInterface string\n\t\tCmd          string\n\t\tIpv6Reg      string // ipv6匹配正则表达式\n\t\tDomains      []string\n\t}\n\tDNS DNS\n\tTTL string\n\t// 发送HTTP请求时使用的网卡名称，为空则使用默认网卡\n\tHttpInterface string\n}\n\n// DNS DNS配置\ntype DNS struct {\n\t// 名称。如：alidns,webhook\n\tName   string\n\tID     string\n\tSecret string\n\t// ExtParam 扩展参数，用于某些DNS提供商的特殊需求（如Vercel的teamId）\n\tExtParam string\n}\n\ntype Config struct {\n\tDnsConf []DnsConfig\n\tUser\n\tWebhook\n\t// 禁止公网访问\n\tNotAllowWanAccess bool\n\t// 语言\n\tLang string\n}\n\n// ConfigCache ConfigCache\ntype cacheType struct {\n\tConfigSingle *Config\n\tErr          error\n\tLock         sync.Mutex\n}\n\nvar cache = &cacheType{}\n\n// GetConfigCached 获得缓存的配置\nfunc GetConfigCached() (conf Config, err error) {\n\tcache.Lock.Lock()\n\tdefer cache.Lock.Unlock()\n\n\tif cache.ConfigSingle != nil {\n\t\treturn *cache.ConfigSingle, cache.Err\n\t}\n\n\t// init config\n\tcache.ConfigSingle = &Config{}\n\n\tconfigFilePath := util.GetConfigFilePath()\n\t_, err = os.Stat(configFilePath)\n\tif err != nil {\n\t\tcache.Err = err\n\t\treturn *cache.ConfigSingle, err\n\t}\n\n\tbyt, err := os.ReadFile(configFilePath)\n\tif err != nil {\n\t\tutil.Log(\"异常信息: %s\", err)\n\t\tcache.Err = err\n\t\treturn *cache.ConfigSingle, err\n\t}\n\n\terr = yaml.Unmarshal(byt, cache.ConfigSingle)\n\tif err != nil {\n\t\tutil.Log(\"异常信息: %s\", err)\n\t\tcache.Err = err\n\t\treturn *cache.ConfigSingle, err\n\t}\n\n\t// 未填写登录信息, 确保不能从公网访问\n\tif cache.ConfigSingle.Username == \"\" && cache.ConfigSingle.Password == \"\" {\n\t\tcache.ConfigSingle.NotAllowWanAccess = true\n\t}\n\n\t// remove err\n\tcache.Err = nil\n\treturn *cache.ConfigSingle, err\n}\n\n// CompatibleConfig 兼容之前的配置文件\nfunc (conf *Config) CompatibleConfig() {\n\n\t// 如果之前密码不为空且不是bcrypt加密后的密码, 把密码加密并保存\n\tif conf.Password != \"\" && !util.IsHashedPassword(conf.Password) {\n\t\thashedPwd, err := util.HashPassword(conf.Password)\n\t\tif err == nil {\n\t\t\tconf.Password = hashedPwd\n\t\t\tconf.SaveConfig()\n\t\t}\n\t}\n\n\t// 兼容v5.0.0之前的配置文件\n\tif len(conf.DnsConf) > 0 {\n\t\treturn\n\t}\n\n\tconfigFilePath := util.GetConfigFilePath()\n\t_, err := os.Stat(configFilePath)\n\tif err != nil {\n\t\treturn\n\t}\n\tbyt, err := os.ReadFile(configFilePath)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tdnsConf := &DnsConfig{}\n\terr = yaml.Unmarshal(byt, dnsConf)\n\tif err != nil {\n\t\treturn\n\t}\n\tif len(dnsConf.DNS.Name) > 0 {\n\t\tcache.Lock.Lock()\n\t\tdefer cache.Lock.Unlock()\n\t\tconf.DnsConf = append(conf.DnsConf, *dnsConf)\n\t\tcache.ConfigSingle = conf\n\t}\n}\n\n// SaveConfig 保存配置\nfunc (conf *Config) SaveConfig() (err error) {\n\tcache.Lock.Lock()\n\tdefer cache.Lock.Unlock()\n\n\tbyt, err := yaml.Marshal(conf)\n\tif err != nil {\n\t\tlog.Println(err)\n\t\treturn err\n\t}\n\n\tconfigFilePath := util.GetConfigFilePath()\n\terr = os.WriteFile(configFilePath, byt, 0600)\n\tif err != nil {\n\t\tlog.Println(err)\n\t\treturn\n\t}\n\n\tutil.Log(\"配置文件已保存在: %s\", configFilePath)\n\n\t// 清空配置缓存\n\tcache.ConfigSingle = nil\n\n\treturn\n}\n\n// 重置密码\nfunc (conf *Config) ResetPassword(newPassword string) {\n\t// 初始化语言\n\tutil.InitLogLang(conf.Lang)\n\n\t// 先检查密码是否安全\n\thashedPwd, err := conf.CheckPassword(newPassword)\n\tif err != nil {\n\t\tutil.Log(err.Error())\n\t\treturn\n\t}\n\n\t// 保存配置\n\tconf.Password = hashedPwd\n\tconf.SaveConfig()\n\tutil.Log(\"用户名 %s 的密码已重置成功! 请重启ddns-go\", conf.Username)\n}\n\n// CheckPassword 检查密码\nfunc (conf *Config) CheckPassword(newPassword string) (hashedPwd string, err error) {\n\tvar minEntropyBits float64 = 30\n\tif conf.NotAllowWanAccess {\n\t\tminEntropyBits = 25\n\t}\n\terr = passwordvalidator.Validate(newPassword, minEntropyBits)\n\tif err != nil {\n\t\treturn \"\", errors.New(util.LogStr(\"密码不安全！尝试使用更复杂的密码\"))\n\t}\n\n\t// 加密密码\n\thashedPwd, err = util.HashPassword(newPassword)\n\tif err != nil {\n\t\treturn \"\", errors.New(util.LogStr(\"异常信息: %s\", err.Error()))\n\t}\n\treturn\n}\n\nfunc (conf *DnsConfig) getIpv4AddrFromInterface() string {\n\tipv4, _, err := GetNetInterface()\n\tif err != nil {\n\t\tutil.Log(\"从网卡获得IPv4失败\")\n\t\treturn \"\"\n\t}\n\n\tfor _, netInterface := range ipv4 {\n\t\tif netInterface.Name == conf.Ipv4.NetInterface && len(netInterface.Address) > 0 {\n\t\t\treturn netInterface.Address[0]\n\t\t}\n\t}\n\n\tutil.Log(\"从网卡中获得IPv4失败! 网卡名: %s\", conf.Ipv4.NetInterface)\n\treturn \"\"\n}\n\nfunc (conf *DnsConfig) getIpv4AddrFromUrl() string {\n\tclient := util.CreateNoProxyHTTPClient(\"tcp4\")\n\turls := strings.Split(conf.Ipv4.URL, \",\")\n\tfor _, url := range urls {\n\t\turl = strings.TrimSpace(url)\n\t\tresp, err := client.Get(url)\n\t\tif err != nil {\n\t\t\tutil.Log(\"通过接口获取IPv4失败! 接口地址: %s\", url)\n\t\t\tutil.Log(\"异常信息: %s\", err)\n\t\t\tcontinue\n\t\t}\n\t\tdefer resp.Body.Close()\n\t\tlr := io.LimitReader(resp.Body, 1024000)\n\t\tbody, err := io.ReadAll(lr)\n\t\tif err != nil {\n\t\t\tutil.Log(\"异常信息: %s\", err)\n\t\t\tcontinue\n\t\t}\n\t\tresult := Ipv4Reg.FindString(string(body))\n\t\tif result == \"\" {\n\t\t\tutil.Log(\"获取IPv4结果失败! 接口: %s ,返回值: %s\", url, string(body))\n\t\t}\n\t\treturn result\n\t}\n\treturn \"\"\n}\n\nfunc (conf *DnsConfig) getAddrFromCmd(addrType string) string {\n\tvar cmd string\n\tvar comp *regexp.Regexp\n\tif addrType == \"IPv4\" {\n\t\tcmd = conf.Ipv4.Cmd\n\t\tcomp = Ipv4Reg\n\t} else {\n\t\tcmd = conf.Ipv6.Cmd\n\t\tcomp = Ipv6Reg\n\t}\n\t// cmd is empty\n\tif cmd == \"\" {\n\t\treturn \"\"\n\t}\n\t// run cmd with proper shell\n\tvar execCmd *exec.Cmd\n\tif runtime.GOOS == \"windows\" {\n\t\texecCmd = exec.Command(\"powershell\", \"-Command\", cmd)\n\t} else {\n\t\t// If Bash does not exist, use sh\n\t\t_, err := exec.LookPath(\"bash\")\n\t\tif err != nil {\n\t\t\texecCmd = exec.Command(\"sh\", \"-c\", cmd)\n\t\t} else {\n\t\t\texecCmd = exec.Command(\"bash\", \"-c\", cmd)\n\t\t}\n\t}\n\t// run cmd\n\tout, err := execCmd.CombinedOutput()\n\tif err != nil {\n\t\tutil.Log(\"获取%s结果失败! 未能成功执行命令：%s, 错误：%q, 退出状态码：%s\", addrType, execCmd.String(), out, err)\n\t\treturn \"\"\n\t}\n\tstr := string(out)\n\t// get result\n\tresult := comp.FindString(str)\n\tif result == \"\" {\n\t\tutil.Log(\"获取%s结果失败! 命令: %s, 标准输出: %q\", addrType, execCmd.String(), str)\n\t}\n\treturn result\n}\n\n// GetIpv4Addr 获得IPv4地址\nfunc (conf *DnsConfig) GetIpv4Addr() string {\n\t// 判断从哪里获取IP\n\tswitch conf.Ipv4.GetType {\n\tcase \"netInterface\":\n\t\t// 从网卡获取 IP\n\t\treturn conf.getIpv4AddrFromInterface()\n\tcase \"url\":\n\t\t// 从 URL 获取 IP\n\t\treturn conf.getIpv4AddrFromUrl()\n\tcase \"cmd\":\n\t\t// 从命令行获取 IP\n\t\treturn conf.getAddrFromCmd(\"IPv4\")\n\tdefault:\n\t\tlog.Println(\"IPv4's get IP method is unknown\")\n\t\treturn \"\" // unknown type\n\t}\n}\n\nfunc (conf *DnsConfig) getIpv6AddrFromInterface() string {\n\t_, ipv6, err := GetNetInterface()\n\tif err != nil {\n\t\tutil.Log(\"从网卡获得IPv6失败\")\n\t\treturn \"\"\n\t}\n\n\tfor _, netInterface := range ipv6 {\n\t\tif netInterface.Name == conf.Ipv6.NetInterface && len(netInterface.Address) > 0 {\n\t\t\tif conf.Ipv6.Ipv6Reg != \"\" {\n\t\t\t\t// 匹配第几个IPv6\n\t\t\t\tif match, err := regexp.MatchString(\"@\\\\d\", conf.Ipv6.Ipv6Reg); err == nil && match {\n\t\t\t\t\tnum, err := strconv.Atoi(conf.Ipv6.Ipv6Reg[1:])\n\t\t\t\t\tif err == nil {\n\t\t\t\t\t\tif num > 0 {\n\t\t\t\t\t\t\tif num <= len(netInterface.Address) {\n\t\t\t\t\t\t\t\treturn netInterface.Address[num-1]\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tutil.Log(\"未找到第 %d 个IPv6地址! 将使用第一个IPv6地址\", num)\n\t\t\t\t\t\t\treturn netInterface.Address[0]\n\t\t\t\t\t\t}\n\t\t\t\t\t\tutil.Log(\"IPv6匹配表达式 %s 不正确! 最小从1开始\", conf.Ipv6.Ipv6Reg)\n\t\t\t\t\t\treturn \"\"\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\t// 正则表达式匹配\n\t\t\t\tutil.Log(\"IPv6将使用正则表达式 %s 进行匹配\", conf.Ipv6.Ipv6Reg)\n\t\t\t\tfor i := 0; i < len(netInterface.Address); i++ {\n\t\t\t\t\tmatched, err := regexp.MatchString(conf.Ipv6.Ipv6Reg, netInterface.Address[i])\n\t\t\t\t\tif matched && err == nil {\n\t\t\t\t\t\tutil.Log(\"匹配成功! 匹配到地址: %s\", netInterface.Address[i])\n\t\t\t\t\t\treturn netInterface.Address[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tutil.Log(\"没有匹配到任何一个IPv6地址, 将使用第一个地址\")\n\t\t\t}\n\t\t\treturn netInterface.Address[0]\n\t\t}\n\t}\n\n\tutil.Log(\"从网卡中获得IPv6失败! 网卡名: %s\", conf.Ipv6.NetInterface)\n\treturn \"\"\n}\n\nfunc (conf *DnsConfig) getIpv6AddrFromUrl() string {\n\tclient := util.CreateNoProxyHTTPClient(\"tcp6\")\n\turls := strings.Split(conf.Ipv6.URL, \",\")\n\tfor _, url := range urls {\n\t\turl = strings.TrimSpace(url)\n\t\tresp, err := client.Get(url)\n\t\tif err != nil {\n\t\t\tutil.Log(\"通过接口获取IPv6失败! 接口地址: %s\", url)\n\t\t\tutil.Log(\"异常信息: %s\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tdefer resp.Body.Close()\n\t\tlr := io.LimitReader(resp.Body, 1024000)\n\t\tbody, err := io.ReadAll(lr)\n\t\tif err != nil {\n\t\t\tutil.Log(\"异常信息: %s\", err)\n\t\t\tcontinue\n\t\t}\n\t\tresult := Ipv6Reg.FindString(string(body))\n\t\tif result == \"\" {\n\t\t\tutil.Log(\"获取IPv6结果失败! 接口: %s ,返回值: %s\", url, result)\n\t\t}\n\t\treturn result\n\t}\n\treturn \"\"\n}\n\n// GetIpv6Addr 获得IPv6地址\nfunc (conf *DnsConfig) GetIpv6Addr() (result string) {\n\t// 判断从哪里获取IP\n\tswitch conf.Ipv6.GetType {\n\tcase \"netInterface\":\n\t\t// 从网卡获取 IP\n\t\treturn conf.getIpv6AddrFromInterface()\n\tcase \"url\":\n\t\t// 从 URL 获取 IP\n\t\treturn conf.getIpv6AddrFromUrl()\n\tcase \"cmd\":\n\t\t// 从命令行获取 IP\n\t\treturn conf.getAddrFromCmd(\"IPv6\")\n\tdefault:\n\t\tlog.Println(\"IPv6's get IP method is unknown\")\n\t\treturn \"\" // unknown type\n\t}\n}\n\n// GetHTTPClient 获得HTTP客户端，如果配置了HttpInterface则绑定到指定网卡\nfunc (conf *DnsConfig) GetHTTPClient() *http.Client {\n\treturn util.CreateHTTPClientWithInterface(conf.HttpInterface)\n}\n"
  },
  {
    "path": "config/domains.go",
    "content": "package config\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"golang.org/x/net/idna\"\n\t\"golang.org/x/net/publicsuffix\"\n)\n\n// Domains Ipv4/Ipv6 domains\ntype Domains struct {\n\tIpv4Addr    string\n\tIpv4Cache   *util.IpCache\n\tIpv4Domains []*Domain\n\tIpv6Addr    string\n\tIpv6Cache   *util.IpCache\n\tIpv6Domains []*Domain\n}\n\n// Domain 域名实体\ntype Domain struct {\n\t// DomainName 根域名\n\tDomainName string\n\t// SubDomain 子域名\n\tSubDomain    string\n\tCustomParams string\n\tUpdateStatus updateStatusType // 更新状态\n}\n\n// DomainTuples 域名元组映射 key: Domain.String()\ntype DomainTuples map[string]*DomainTuple\n\n// DomainTuple 域名元组\ntype DomainTuple struct {\n\tRecordType string\n\t// Primary 首要域名 Domains[-1] = Primary\n\tPrimary  *Domain\n\tDomains  []*Domain\n\tIpAddrs  []string\n\tIpv4Addr string\n\tIpv6Addr string\n}\n\n// nontransitionalLookup implements the nontransitional processing as specified in\n// Unicode Technical Standard 46 with almost all checkings off to maximize user freedom.\n//\n// Copied from: https://github.com/cloudflare/cloudflare-go/blob/v0.97.0/dns.go#L95\nvar nontransitionalLookup = idna.New(\n\tidna.MapForLookup(),\n\tidna.StrictDomainName(false),\n\tidna.ValidateLabels(false),\n)\n\nfunc (d Domain) String() string {\n\tif d.SubDomain != \"\" {\n\t\treturn d.SubDomain + \".\" + d.DomainName\n\t}\n\treturn d.DomainName\n}\n\n// GetFullDomain 获得全部的，子域名\nfunc (d Domain) GetFullDomain() string {\n\tif d.SubDomain != \"\" {\n\t\treturn d.SubDomain + \".\" + d.DomainName\n\t}\n\treturn \"@\" + \".\" + d.DomainName\n}\n\n// GetSubDomain 获得子域名，为空返回@\n// 阿里云/腾讯云/dnspod/GoDaddy/namecheap 需要\nfunc (d Domain) GetSubDomain() string {\n\tif d.SubDomain != \"\" {\n\t\treturn d.SubDomain\n\t}\n\treturn \"@\"\n}\n\n// GetCustomParams not be nil\nfunc (d Domain) GetCustomParams() url.Values {\n\tif d.CustomParams != \"\" {\n\t\tq, err := url.ParseQuery(d.CustomParams)\n\t\tif err == nil {\n\t\t\treturn q\n\t\t}\n\t}\n\treturn url.Values{}\n}\n\n// ToASCII converts [Domain] to its ASCII form,\n// using non-transitional process specified in UTS 46.\n//\n// Note: conversion errors are silently discarded and partial conversion\n// results are used.\nfunc (d Domain) ToASCII() string {\n\tname, _ := nontransitionalLookup.ToASCII(d.String())\n\treturn name\n}\n\n// GetNewIp 接口/网卡/命令获得 ip 并校验用户输入的域名\nfunc (domains *Domains) GetNewIp(dnsConf *DnsConfig) {\n\tdomains.Ipv4Domains = checkParseDomains(dnsConf.Ipv4.Domains)\n\tdomains.Ipv6Domains = checkParseDomains(dnsConf.Ipv6.Domains)\n\n\t// IPv4\n\tif dnsConf.Ipv4.Enable && len(domains.Ipv4Domains) > 0 {\n\t\tipv4Addr := dnsConf.GetIpv4Addr()\n\t\tif ipv4Addr != \"\" {\n\t\t\tdomains.Ipv4Addr = ipv4Addr\n\t\t\tdomains.Ipv4Cache.TimesFailedIP = 0\n\t\t} else {\n\t\t\t// 启用IPv4 & 未获取到IP & 填写了域名 & 失败刚好3次，防止偶尔的网络连接失败，并且只发一次\n\t\t\tdomains.Ipv4Cache.TimesFailedIP++\n\t\t\tif domains.Ipv4Cache.TimesFailedIP == 3 {\n\t\t\t\tdomains.Ipv4Domains[0].UpdateStatus = UpdatedFailed\n\t\t\t}\n\t\t\tutil.Log(\"未能获取IPv4地址, 将不会更新\")\n\t\t}\n\t}\n\n\t// IPv6\n\tif dnsConf.Ipv6.Enable && len(domains.Ipv6Domains) > 0 {\n\t\tipv6Addr := dnsConf.GetIpv6Addr()\n\t\tif ipv6Addr != \"\" {\n\t\t\tdomains.Ipv6Addr = ipv6Addr\n\t\t\tdomains.Ipv6Cache.TimesFailedIP = 0\n\t\t} else {\n\t\t\t// 启用IPv6 & 未获取到IP & 填写了域名 & 失败刚好3次，防止偶尔的网络连接失败，并且只发一次\n\t\t\tdomains.Ipv6Cache.TimesFailedIP++\n\t\t\tif domains.Ipv6Cache.TimesFailedIP == 3 {\n\t\t\t\tdomains.Ipv6Domains[0].UpdateStatus = UpdatedFailed\n\t\t\t}\n\t\t\tutil.Log(\"未能获取IPv6地址, 将不会更新\")\n\t\t}\n\t}\n\n}\n\n// checkParseDomains 校验并解析用户输入的域名\nfunc checkParseDomains(domainArr []string) (domains []*Domain) {\n\tfor _, domainStr := range domainArr {\n\t\tdomainStr = strings.TrimSpace(domainStr)\n\t\tif domainStr == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tdomain := &Domain{}\n\n\t\t// qp(queryParts) 从域名中提取自定义参数，如 baidu.com?q=1 => [baidu.com, q=1]\n\t\tqp := strings.Split(domainStr, \"?\")\n\t\tdomainStr = qp[0]\n\n\t\t// dp(domainParts) 将域名（qp[0]）分割为子域名与根域名，如 www:example.cn.eu.org => [www, example.cn.eu.org]\n\t\tdp := strings.Split(domainStr, \":\")\n\n\t\tswitch len(dp) {\n\t\tcase 1: // 不使用冒号分割，自动识别域名\n\t\t\tdomainName, err := publicsuffix.EffectiveTLDPlusOne(domainStr)\n\t\t\tif err != nil {\n\t\t\t\tutil.Log(\"域名: %s 不正确\", domainStr)\n\t\t\t\tutil.Log(\"异常信息: %s\", err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdomain.DomainName = domainName\n\n\t\t\tdomainLen := len(domainStr) - len(domainName) - 1\n\t\t\tif domainLen > 0 {\n\t\t\t\tdomain.SubDomain = domainStr[:domainLen]\n\t\t\t}\n\t\tcase 2: // 使用冒号分隔，为 子域名:根域名 格式\n\t\t\tsp := strings.Split(dp[1], \".\")\n\t\t\tif len(sp) <= 1 {\n\t\t\t\tutil.Log(\"域名: %s 不正确\", domainStr)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdomain.DomainName = dp[1]\n\t\t\tdomain.SubDomain = dp[0]\n\t\tdefault:\n\t\t\tutil.Log(\"域名: %s 不正确\", domainStr)\n\t\t\tcontinue\n\t\t}\n\n\t\t// 参数条件\n\t\tif len(qp) == 2 {\n\t\t\tu, err := url.Parse(\"https://baidu.com?\" + qp[1])\n\t\t\tif err != nil {\n\t\t\t\tutil.Log(\"域名: %s 解析失败\", domainStr)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tdomain.CustomParams = u.Query().Encode()\n\t\t}\n\t\tdomains = append(domains, domain)\n\t}\n\treturn\n}\n\n// GetNewIpResult 获得GetNewIp结果\nfunc (domains *Domains) GetNewIpResult(recordType string) (ipAddr string, retDomains []*Domain) {\n\tif recordType == \"AAAA\" {\n\t\tif domains.Ipv6Cache.Check(domains.Ipv6Addr) {\n\t\t\treturn domains.Ipv6Addr, domains.Ipv6Domains\n\t\t} else {\n\t\t\tutil.Log(\"IPv6未改变, 将等待 %d 次后与DNS服务商进行比对\", domains.Ipv6Cache.Times)\n\t\t\treturn \"\", domains.Ipv6Domains\n\t\t}\n\t}\n\t// IPv4\n\tif domains.Ipv4Cache.Check(domains.Ipv4Addr) {\n\t\treturn domains.Ipv4Addr, domains.Ipv4Domains\n\t} else {\n\t\tutil.Log(\"IPv4未改变, 将等待 %d 次后与DNS服务商进行比对\", domains.Ipv4Cache.Times)\n\t\treturn \"\", domains.Ipv4Domains\n\t}\n}\n\n// GetAllNewIpResult 获得getNewIp结果\nfunc (domains *Domains) GetAllNewIpResult(multiRecordType string) (results DomainTuples) {\n\tipv4Addr, ipv4Domains := domains.GetNewIpResult(\"A\")\n\tipv6Addr, ipv6Domains := domains.GetNewIpResult(\"AAAA\")\n\tif ipv4Addr == \"\" && ipv6Addr == \"\" {\n\t\treturn\n\t}\n\tcap := 0\n\tif ipv4Addr != \"\" {\n\t\tcap += len(ipv4Domains)\n\t}\n\tif ipv6Addr != \"\" {\n\t\tcap += len(ipv6Domains)\n\t}\n\n\tresults = make(DomainTuples, cap)\n\tresults.append(ipv4Addr, ipv4Domains, multiRecordType, DomainTuple{RecordType: \"A\", Ipv4Addr: domains.Ipv4Addr, Ipv6Addr: domains.Ipv6Addr})\n\tresults.append(ipv6Addr, ipv6Domains, multiRecordType, DomainTuple{RecordType: \"AAAA\", Ipv4Addr: domains.Ipv4Addr, Ipv6Addr: domains.Ipv6Addr})\n\treturn\n}\n\n// append 添加域名到域名元组映射\nfunc (domains DomainTuples) append(ipAddr string, retDomains []*Domain, multiRecordType string, template DomainTuple) {\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range retDomains {\n\t\tdomainStr := domain.String()\n\t\tif tuple, ok := domains[domainStr]; ok {\n\t\t\tif tuple.RecordType != template.RecordType {\n\t\t\t\ttuple.RecordType = multiRecordType\n\t\t\t}\n\t\t\ttuple.Primary = domain\n\t\t\ttuple.Domains = append(tuple.Domains, domain)\n\t\t\ttuple.IpAddrs = append(tuple.IpAddrs, ipAddr)\n\t\t} else {\n\t\t\ttuple := template\n\t\t\tdomains[domainStr] = &tuple\n\t\t\ttuple.Primary = domain\n\t\t\ttuple.Domains = []*Domain{domain}\n\t\t\ttuple.IpAddrs = []string{ipAddr}\n\t\t}\n\t}\n}\n\n// SetUpdateStatus 设置更新状态\nfunc (d *DomainTuple) SetUpdateStatus(status updateStatusType) {\n\tif d.Primary.UpdateStatus == status {\n\t\treturn\n\t}\n\n\tfor _, domain := range d.Domains {\n\t\tdomain.UpdateStatus = status\n\t}\n}\n\n// GetIpAddrPool 设置更新状态\nfunc (d *DomainTuple) GetIpAddrPool(separator string) (result string) {\n\ts := d.Primary.GetCustomParams().Get(\"IpAddrPool\")\n\tif len(s) != 0 {\n\t\treturn strings.NewReplacer(\n\t\t\t\"{ipv4Addr}\", d.Ipv4Addr,\n\t\t\t\"{ipv6Addr}\", d.Ipv6Addr,\n\t\t).Replace(s)\n\t}\n\tswitch d.RecordType {\n\tcase \"A\":\n\t\treturn d.Ipv4Addr\n\tcase \"AAAA\":\n\t\treturn d.Ipv6Addr\n\tdefault:\n\t\treturn d.Ipv4Addr + separator + d.Ipv6Addr\n\t}\n}\n"
  },
  {
    "path": "config/domains_test.go",
    "content": "package config\n\nimport \"testing\"\n\n// TestToASCII test converts the name of [Domain] to its ASCII form.\n//\n// Copied from: https://github.com/cloudflare/cloudflare-go/blob/v0.97.0/dns_test.go#L15\nfunc TestToASCII(t *testing.T) {\n\ttests := map[string]struct {\n\t\tdomain   string\n\t\texpected string\n\t}{\n\t\t\"empty\": {\n\t\t\t\"\", \"\",\n\t\t},\n\t\t\"unicode get encoded\": {\n\t\t\t\"😺.com\", \"xn--138h.com\",\n\t\t},\n\t\t\"unicode gets mapped and encoded\": {\n\t\t\t\"ÖBB.at\", \"xn--bb-eka.at\",\n\t\t},\n\t\t\"punycode stays punycode\": {\n\t\t\t\"xn--138h.com\", \"xn--138h.com\",\n\t\t},\n\t\t\"hyphens are not checked\": {\n\t\t\t\"s3--s4.com\", \"s3--s4.com\",\n\t\t},\n\t\t\"STD3 rules are not enforced\": {\n\t\t\t\"℀.com\", \"a/c.com\",\n\t\t},\n\t\t\"bidi check is disabled\": {\n\t\t\t\"englishﻋﺮﺑﻲ.com\", \"xn--english-gqjzfwd1j.com\",\n\t\t},\n\t\t\"invalid joiners are allowed\": {\n\t\t\t\"a\\u200cb.com\", \"xn--ab-j1t.com\",\n\t\t},\n\t\t\"partial results are used despite errors\": {\n\t\t\t\"xn--:D.xn--.😺.com\", \"xn--:d..xn--138h.com\",\n\t\t},\n\t}\n\n\tfor name, tt := range tests {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\td := &Domain{DomainName: tt.domain}\n\t\t\tactual := d.ToASCII()\n\t\t\tif actual != tt.expected {\n\t\t\t\tt.Errorf(\"ToASCII() = %v, want %v\", actual, tt.expected)\n\t\t\t}\n\t\t})\n\t}\n}\n\n// TestParseDomainArr 测试 parseDomainArr\nfunc TestParseDomainArr(t *testing.T) {\n\tdomains := []string{\"mydomain.com\", \"test.mydomain.com\", \"test2.test.mydomain.com\", \"mydomain.com.mydomain.com\", \"mydomain.com.cn\",\n\t\t\"test.mydomain.com.cn\", \"test:mydomain.com.cn\",\n\t\t\"test.mydomain.com?Line=oversea&RecordId=123\", \"test.mydomain.com.cn?Line=oversea&RecordId=123\",\n\t\t\"test2:test.mydomain.com?Line=oversea&RecordId=123\"}\n\tresult := []Domain{\n\t\t{DomainName: \"mydomain.com\", SubDomain: \"\"},\n\t\t{DomainName: \"mydomain.com\", SubDomain: \"test\"},\n\t\t{DomainName: \"mydomain.com\", SubDomain: \"test2.test\"},\n\t\t{DomainName: \"mydomain.com\", SubDomain: \"mydomain.com\"},\n\t\t{DomainName: \"mydomain.com.cn\", SubDomain: \"\"},\n\t\t{DomainName: \"mydomain.com.cn\", SubDomain: \"test\"},\n\t\t{DomainName: \"mydomain.com.cn\", SubDomain: \"test\"},\n\t\t{DomainName: \"mydomain.com\", SubDomain: \"test\", CustomParams: \"Line=oversea&RecordId=123\"},\n\t\t{DomainName: \"mydomain.com.cn\", SubDomain: \"test\", CustomParams: \"Line=oversea&RecordId=123\"},\n\t\t{DomainName: \"test.mydomain.com\", SubDomain: \"test2\", CustomParams: \"Line=oversea&RecordId=123\"},\n\t}\n\n\tparsedDomains := checkParseDomains(domains)\n\tfor i := 0; i < len(parsedDomains); i++ {\n\t\tif parsedDomains[i].DomainName != result[i].DomainName ||\n\t\t\tparsedDomains[i].SubDomain != result[i].SubDomain ||\n\t\t\tparsedDomains[i].CustomParams != result[i].CustomParams {\n\t\t\tt.Errorf(\"解析 %s 失败：\\n期待 DomainName：%s，得到 DomainName：%s\\n期待 SubDomain：%s，得到 SubDomain：%s\\n期待 CustomParams：%s，得到 CustomParams：%s\",\n\t\t\t\tparsedDomains[i].String(),\n\t\t\t\tresult[i].DomainName, parsedDomains[i].DomainName,\n\t\t\t\tresult[i].SubDomain, parsedDomains[i].SubDomain,\n\t\t\t\tresult[i].CustomParams, parsedDomains[i].CustomParams)\n\t\t}\n\t}\n\n}\n"
  },
  {
    "path": "config/netInterface.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"net\"\n)\n\n// NetInterface 本机网络\ntype NetInterface struct {\n\tName    string\n\tAddress []string\n}\n\n// GetNetInterface 获得网卡地址\n// 返回ipv4, ipv6地址\nfunc GetNetInterface() (ipv4NetInterfaces []NetInterface, ipv6NetInterfaces []NetInterface, err error) {\n\tallNetInterfaces, err := net.Interfaces()\n\tif err != nil {\n\t\tfmt.Println(\"net.Interfaces failed, err:\", err.Error())\n\t\treturn ipv4NetInterfaces, ipv6NetInterfaces, err\n\t}\n\n\t// https://en.wikipedia.org/wiki/IPv6_address#General_allocation\n\t_, ipv6Unicast, _ := net.ParseCIDR(\"2000::/3\")\n\n\tfor i := 0; i < len(allNetInterfaces); i++ {\n\t\tif (allNetInterfaces[i].Flags & net.FlagUp) != 0 {\n\t\t\taddrs, _ := allNetInterfaces[i].Addrs()\n\t\t\tipv4 := []string{}\n\t\t\tipv6 := []string{}\n\n\t\t\tfor _, address := range addrs {\n\t\t\t\tif ipnet, ok := address.(*net.IPNet); ok && ipnet.IP.IsGlobalUnicast() {\n\t\t\t\t\t_, bits := ipnet.Mask.Size()\n\t\t\t\t\t// 需匹配全局单播地址\n\t\t\t\t\tif bits == 128 && ipv6Unicast.Contains(ipnet.IP) {\n\t\t\t\t\t\tipv6 = append(ipv6, ipnet.IP.String())\n\t\t\t\t\t}\n\t\t\t\t\tif bits == 32 {\n\t\t\t\t\t\tipv4 = append(ipv4, ipnet.IP.String())\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif len(ipv4) > 0 {\n\t\t\t\tipv4NetInterfaces = append(\n\t\t\t\t\tipv4NetInterfaces,\n\t\t\t\t\tNetInterface{\n\t\t\t\t\t\tName:    allNetInterfaces[i].Name,\n\t\t\t\t\t\tAddress: ipv4,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t\tif len(ipv6) > 0 {\n\t\t\t\tipv6NetInterfaces = append(\n\t\t\t\t\tipv6NetInterfaces,\n\t\t\t\t\tNetInterface{\n\t\t\t\t\t\tName:    allNetInterfaces[i].Name,\n\t\t\t\t\t\tAddress: ipv6,\n\t\t\t\t\t},\n\t\t\t\t)\n\t\t\t}\n\n\t\t}\n\t}\n\n\treturn ipv4NetInterfaces, ipv6NetInterfaces, nil\n}\n"
  },
  {
    "path": "config/netInterface_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n)\n\nfunc TestGetNetInterface(t *testing.T) {\n\tipv4NetInterfaces, ipv6NetInterfaces, err := GetNetInterface()\n\tif err != nil {\n\t\tt.Error(err)\n\t}\n\tt.Log(ipv4NetInterfaces, ipv6NetInterfaces)\n}\n"
  },
  {
    "path": "config/user.go",
    "content": "package config\n\n// User 登录用户\ntype User struct {\n\tUsername string\n\tPassword string\n}\n"
  },
  {
    "path": "config/webhook.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// Webhook Webhook\ntype Webhook struct {\n\tWebhookURL         string\n\tWebhookRequestBody string\n\tWebhookHeaders     string\n}\n\n// updateStatusType 更新状态\ntype updateStatusType string\n\nconst (\n\t// UpdatedNothing 未改变\n\tUpdatedNothing updateStatusType = \"未改变\"\n\t// UpdatedFailed 更新失败\n\tUpdatedFailed = \"失败\"\n\t// UpdatedSuccess 更新成功\n\tUpdatedSuccess = \"成功\"\n)\n\n// 更新失败次数\nvar updatedFailedTimes = 0\n\n// hasJSONPrefix returns true if the string starts with a JSON open brace.\nfunc hasJSONPrefix(s string) bool {\n\treturn strings.HasPrefix(s, \"{\") || strings.HasPrefix(s, \"[\")\n}\n\n// ExecWebhook 添加或更新IPv4/IPv6记录, 返回是否有更新失败的\nfunc ExecWebhook(domains *Domains, conf *Config) (v4Status updateStatusType, v6Status updateStatusType) {\n\tv4Status = getDomainsStatus(domains.Ipv4Domains)\n\tv6Status = getDomainsStatus(domains.Ipv6Domains)\n\n\tif conf.WebhookURL != \"\" && (v4Status != UpdatedNothing || v6Status != UpdatedNothing) {\n\t\t// 第3次失败才触发一次webhook\n\t\tif v4Status == UpdatedFailed || v6Status == UpdatedFailed {\n\t\t\tupdatedFailedTimes++\n\t\t\tif updatedFailedTimes != 3 {\n\t\t\t\tutil.Log(\"将不会触发Webhook, 仅在第 3 次失败时触发一次Webhook, 当前失败次数：%d\", updatedFailedTimes)\n\t\t\t\treturn\n\t\t\t}\n\t\t} else {\n\t\t\tupdatedFailedTimes = 0\n\t\t}\n\n\t\t// 成功和失败都要触发webhook\n\t\tmethod := \"GET\"\n\t\tpostPara := \"\"\n\t\tcontentType := \"application/x-www-form-urlencoded\"\n\t\tif conf.WebhookRequestBody != \"\" {\n\t\t\tmethod = \"POST\"\n\t\t\tpostPara = replacePara(domains, conf.WebhookRequestBody, v4Status, v6Status)\n\t\t\tif json.Valid([]byte(postPara)) {\n\t\t\t\tcontentType = \"application/json\"\n\t\t\t} else if hasJSONPrefix(postPara) {\n\t\t\t\t// 如果 RequestBody 的 JSON 无效但前缀为 JSON，提示无效\n\t\t\t\tutil.Log(\"Webhook中的 RequestBody JSON 无效\")\n\t\t\t}\n\t\t}\n\t\trequestURL := replacePara(domains, conf.WebhookURL, v4Status, v6Status)\n\t\tu, err := url.Parse(requestURL)\n\t\tif err != nil {\n\t\t\tutil.Log(\"Webhook配置中的URL不正确\")\n\t\t\treturn\n\t\t}\n\n\t\tq, _ := url.ParseQuery(u.RawQuery)\n\t\tu.RawQuery = q.Encode()\n\n\t\treq, err := http.NewRequest(method, u.String(), strings.NewReader(postPara))\n\t\tif err != nil {\n\t\t\tutil.Log(\"Webhook调用失败! 异常信息：%s\", err)\n\t\t\treturn\n\t\t}\n\n\t\theaders := extractHeaders(conf.WebhookHeaders)\n\t\tfor key, value := range headers {\n\t\t\treq.Header.Add(key, value)\n\t\t}\n\t\treq.Header.Add(\"content-type\", contentType)\n\n\t\tclt := util.CreateHTTPClient()\n\t\tresp, err := clt.Do(req)\n\t\tbody, err := util.GetHTTPResponseOrg(resp, err)\n\t\tif err == nil {\n\t\t\tutil.Log(\"Webhook调用成功! 返回数据：%s\", string(body))\n\t\t} else {\n\t\t\tutil.Log(\"Webhook调用失败! 异常信息：%s\", err)\n\t\t}\n\t}\n\treturn\n}\n\n// getDomainsStatus 获取域名状态\nfunc getDomainsStatus(domains []*Domain) updateStatusType {\n\tsuccessNum := 0\n\tfor _, v46 := range domains {\n\t\tswitch v46.UpdateStatus {\n\t\tcase UpdatedFailed:\n\t\t\t// 一个失败，全部失败\n\t\t\treturn UpdatedFailed\n\t\tcase UpdatedSuccess:\n\t\t\tsuccessNum++\n\t\t}\n\t}\n\n\tif successNum > 0 {\n\t\t// 迭代完成后一个成功，就成功\n\t\treturn UpdatedSuccess\n\t}\n\treturn UpdatedNothing\n}\n\n// replacePara 替换参数\nfunc replacePara(domains *Domains, orgPara string, ipv4Result updateStatusType, ipv6Result updateStatusType) string {\n\treturn strings.NewReplacer(\n\t\t\"#{ipv4Addr}\", domains.Ipv4Addr,\n\t\t\"#{ipv4Result}\", util.LogStr(string(ipv4Result)), // i18n\n\t\t\"#{ipv4Domains}\", getDomainsStr(domains.Ipv4Domains),\n\t\t\"#{ipv6Addr}\", domains.Ipv6Addr,\n\t\t\"#{ipv6Result}\", util.LogStr(string(ipv6Result)), // i18n\n\t\t\"#{ipv6Domains}\", getDomainsStr(domains.Ipv6Domains),\n\t).Replace(orgPara)\n}\n\n// getDomainsStr 用逗号分割域名\nfunc getDomainsStr(domains []*Domain) string {\n\tstr := \"\"\n\tfor i, v46 := range domains {\n\t\tstr += v46.String()\n\t\tif i != len(domains)-1 {\n\t\t\tstr += \",\"\n\t\t}\n\t}\n\n\treturn str\n}\n\n// extractHeaders converts s into a map of headers.\n//\n// See also: https://github.com/appleboy/gorush/blob/v1.17.0/notify/feedback.go#L15\nfunc extractHeaders(s string) map[string]string {\n\tlines := util.SplitLines(s)\n\theaders := make(map[string]string, len(lines))\n\n\tfor _, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif line == \"\" {\n\t\t\tcontinue\n\t\t}\n\n\t\tparts := strings.Split(line, \":\")\n\t\tif len(parts) != 2 {\n\t\t\tutil.Log(\"Webhook Header不正确: %s\", line)\n\t\t\tcontinue\n\t\t}\n\n\t\tk, v := strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1])\n\t\theaders[k] = v\n\t}\n\n\treturn headers\n}\n"
  },
  {
    "path": "config/webhook_test.go",
    "content": "package config\n\nimport (\n\t\"reflect\"\n\t\"testing\"\n)\n\n// TestExtractHeaders 测试 parseHeaderArr\nfunc TestExtractHeaders(t *testing.T) {\n\tinput := `\na: foo\nb: bar`\n\texpected := map[string]string{\n\t\t\"a\": \"foo\",\n\t\t\"b\": \"bar\",\n\t}\n\n\tparsedHeaders := extractHeaders(input)\n\tif !reflect.DeepEqual(parsedHeaders, expected) {\n\t\tt.Errorf(\"Expected %v, got %v\", expected, parsedHeaders)\n\t}\n}\n"
  },
  {
    "path": "dns/alidns.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\talidnsEndpoint string = \"https://alidns.aliyuncs.com/\"\n)\n\n// https://help.aliyun.com/document_detail/29776.html?spm=a2c4g.11186623.6.672.715a45caji9dMA\n// Alidns Alidns\ntype Alidns struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\n\n// AlidnsRecord record\ntype AlidnsRecord struct {\n\tDomainName string\n\tRecordID   string\n\tValue      string\n}\n\n// AlidnsSubDomainRecords 记录\ntype AlidnsSubDomainRecords struct {\n\tTotalCount    int\n\tDomainRecords struct {\n\t\tRecord []AlidnsRecord\n\t}\n}\n\n// AlidnsResp 修改/添加返回结果\ntype AlidnsResp struct {\n\tRecordID  string\n\tRequestID string\n}\n\n// Init 初始化\nfunc (ali *Alidns) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tali.Domains.Ipv4Cache = ipv4cache\n\tali.Domains.Ipv6Cache = ipv6cache\n\tali.DNS = dnsConf.DNS\n\tali.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tali.TTL = \"600\"\n\t} else {\n\t\tali.TTL = dnsConf.TTL\n\t}\n\tali.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (ali *Alidns) AddUpdateDomainRecords() config.Domains {\n\tali.addUpdateDomainRecords(\"A\")\n\tali.addUpdateDomainRecords(\"AAAA\")\n\treturn ali.Domains\n}\n\nfunc (ali *Alidns) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := ali.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tvar records AlidnsSubDomainRecords\n\t\t// 获取当前域名信息\n\t\tparams := domain.GetCustomParams()\n\t\tparams.Set(\"Action\", \"DescribeSubDomainRecords\")\n\t\tparams.Set(\"DomainName\", domain.DomainName)\n\t\tparams.Set(\"SubDomain\", domain.GetFullDomain())\n\t\tparams.Set(\"Type\", recordType)\n\t\terr := ali.request(params, &records)\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif records.TotalCount > 0 {\n\t\t\t// 默认第一个\n\t\t\trecordSelected := records.DomainRecords.Record[0]\n\t\t\tif params.Has(\"RecordId\") {\n\t\t\t\tfor i := 0; i < len(records.DomainRecords.Record); i++ {\n\t\t\t\t\tif records.DomainRecords.Record[i].RecordID == params.Get(\"RecordId\") {\n\t\t\t\t\t\trecordSelected = records.DomainRecords.Record[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 存在，更新\n\t\t\tali.modify(recordSelected, domain, recordType, ipAddr)\n\t\t} else {\n\t\t\t// 不存在，创建\n\t\t\tali.create(domain, recordType, ipAddr)\n\t\t}\n\n\t}\n}\n\n// 创建\nfunc (ali *Alidns) create(domain *config.Domain, recordType string, ipAddr string) {\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"Action\", \"AddDomainRecord\")\n\tparams.Set(\"DomainName\", domain.DomainName)\n\tparams.Set(\"RR\", domain.GetSubDomain())\n\tparams.Set(\"Type\", recordType)\n\tparams.Set(\"Value\", ipAddr)\n\tparams.Set(\"TTL\", ali.TTL)\n\n\tvar result AlidnsResp\n\terr := ali.request(params, &result)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif result.RecordID != \"\" {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, \"返回RecordId为空\")\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 修改\nfunc (ali *Alidns) modify(recordSelected AlidnsRecord, domain *config.Domain, recordType string, ipAddr string) {\n\n\t// 相同不修改\n\tif recordSelected.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"Action\", \"UpdateDomainRecord\")\n\tparams.Set(\"RR\", domain.GetSubDomain())\n\tparams.Set(\"RecordId\", recordSelected.RecordID)\n\tparams.Set(\"Type\", recordType)\n\tparams.Set(\"Value\", ipAddr)\n\tparams.Set(\"TTL\", ali.TTL)\n\n\tvar result AlidnsResp\n\terr := ali.request(params, &result)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif result.RecordID != \"\" {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, \"返回RecordId为空\")\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// request 统一请求接口\nfunc (ali *Alidns) request(params url.Values, result interface{}) (err error) {\n\tmethod := http.MethodGet\n\tutil.AliyunSigner(ali.DNS.ID, ali.DNS.Secret, &params, method, \"2015-01-09\")\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\talidnsEndpoint,\n\t\tbytes.NewBuffer(nil),\n\t)\n\treq.URL.RawQuery = params.Encode()\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tclient := ali.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/aliesa.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\taliesaEndpoint string = \"https://esa.cn-hangzhou.aliyuncs.com/\"\n)\n\n// Aliesa Aliesa\ntype Aliesa struct {\n\tDNS     config.DNS\n\tDomains config.Domains\n\tTTL     string\n\n\tsiteCache   map[string]AliesaSite\n\tdomainCache config.DomainTuples\n\thttpClient  *http.Client\n}\n\n// AliesaSiteResp 站点返回结果\ntype AliesaSiteResp struct {\n\tTotalCount int\n\tSites      []AliesaSite\n}\n\n// AliesaSites 站点\ntype AliesaSite struct {\n\tSiteId     int64\n\tSiteName   string\n\tAccessType string\n}\n\n// AliesaRecordResp 记录返回结果\ntype AliesaRecordResp struct {\n\tTotalCount int\n\tRecords    []AliesaRecord\n}\n\n// AliesaRecord 记录\ntype AliesaRecord struct {\n\tRecordId   int64\n\tRecordName string\n\tData       struct {\n\t\tValue string\n\t}\n}\n\n// AliesaResp 修改/添加返回结果\ntype AliesaResp struct {\n\tOriginPoolId int64 `json:\"Id\"`\n\tRecordID     int64\n\tRequestID    string\n}\n\n// Init 初始化\nfunc (ali *Aliesa) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tali.Domains.Ipv4Cache = ipv4cache\n\tali.Domains.Ipv6Cache = ipv6cache\n\tali.DNS = dnsConf.DNS\n\tali.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tali.TTL = \"600\"\n\t} else {\n\t\tali.TTL = dnsConf.TTL\n\t}\n\tali.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (ali *Aliesa) AddUpdateDomainRecords() config.Domains {\n\tali.siteCache = make(map[string]AliesaSite)\n\tali.domainCache = ali.Domains.GetAllNewIpResult(\"A/AAAA\")\n\tali.addUpdateDomainRecords(\"A\")\n\tali.addUpdateDomainRecords(\"AAAA\")\n\tali.addUpdateDomainRecords(\"A/AAAA\")\n\treturn ali.Domains\n}\n\nfunc (ali *Aliesa) addUpdateDomainRecords(recordType string) {\n\tfor _, domain := range ali.domainCache {\n\t\tif domain.RecordType != recordType {\n\t\t\tcontinue\n\t\t}\n\n\t\t// 获取站点\n\t\tsiteSelected, err := ali.getSite(domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.SetUpdateStatus(config.UpdatedFailed)\n\t\t\treturn\n\t\t}\n\t\tif siteSelected.SiteId == 0 {\n\t\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain.Primary.DomainName)\n\t\t\tdomain.SetUpdateStatus(config.UpdatedFailed)\n\t\t\treturn\n\t\t}\n\n\t\t// 处理源地址池\n\t\tpoolId, origins, err := ali.getOriginPool(siteSelected, domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.SetUpdateStatus(config.UpdatedFailed)\n\t\t\treturn\n\t\t}\n\t\t// TODO：不允许相同ip\n\t\tif len(origins) != 0 {\n\t\t\tali.updateOriginPool(siteSelected, domain, poolId, origins)\n\t\t\treturn\n\t\t}\n\n\t\t// 获取记录\n\t\trecordSelected, err := ali.getRecord(siteSelected, domain, \"A/AAAA\")\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.SetUpdateStatus(config.UpdatedFailed)\n\t\t\treturn\n\t\t}\n\t\tif recordSelected.RecordId != 0 {\n\t\t\t// 存在，更新\n\t\t\tali.modify(recordSelected, domain, \"A/AAAA\")\n\t\t} else {\n\t\t\t// 不存在，创建\n\t\t\tali.create(siteSelected, domain, \"A/AAAA\")\n\t\t}\n\t}\n}\n\n// 创建\n// https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-createrecord\nfunc (ali *Aliesa) create(site AliesaSite, domainTuple *config.DomainTuple, recordType string) {\n\tdomain := domainTuple.Primary\n\tipAddr := domainTuple.GetIpAddrPool(\",\")\n\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"Action\", \"CreateRecord\")\n\tparams.Set(\"SiteId\", strconv.FormatInt(site.SiteId, 10))\n\tparams.Set(\"RecordName\", domain.String())\n\n\tparams.Set(\"Type\", recordType)\n\tparams.Set(\"Data\", `{\"Value\":\"`+ipAddr+`\"}`)\n\tparams.Set(\"Ttl\", ali.TTL)\n\n\t// 兼容 CNAME 接入方式\n\tif site.AccessType == \"CNAME\" && !params.Has(\"Proxied\") {\n\t\tparams.Set(\"Proxied\", \"true\")\n\t}\n\tif params.Has(\"Proxied\") && !params.Has(\"BizName\") {\n\t\tparams.Set(\"BizName\", \"web\")\n\t}\n\n\tvar result AliesaResp\n\terr := ali.request(http.MethodPost, params, &result)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedFailed)\n\t\treturn\n\t}\n\n\tif result.RecordID != 0 {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedSuccess)\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, \"返回RecordId为空\")\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedFailed)\n\t}\n}\n\n// 修改\n// https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-updaterecord\nfunc (ali *Aliesa) modify(record AliesaRecord, domainTuple *config.DomainTuple, recordType string) {\n\tdomain := domainTuple.Primary\n\tipAddr := domainTuple.GetIpAddrPool(\",\")\n\t// 相同不修改\n\tif record.Data.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"Action\", \"UpdateRecord\")\n\tparams.Set(\"RecordId\", strconv.FormatInt(record.RecordId, 10))\n\n\tparams.Set(\"Type\", recordType)\n\tparams.Set(\"Data\", `{\"Value\":\"`+ipAddr+`\"}`)\n\tparams.Set(\"Ttl\", ali.TTL)\n\n\tvar result AliesaResp\n\terr := ali.request(http.MethodPost, params, &result)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedFailed)\n\t\treturn\n\t}\n\n\t// 不检查 result.RecordID ，更新成功也会返回 0\n\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomainTuple.SetUpdateStatus(config.UpdatedSuccess)\n}\n\n// 获取当前域名信息\n// https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listrecords\nfunc (ali *Aliesa) getRecord(site AliesaSite, domainTuple *config.DomainTuple, recordType string) (result AliesaRecord, err error) {\n\tdomain := domainTuple.Primary\n\tvar recordResp AliesaRecordResp\n\n\tparams := url.Values{}\n\tparams.Set(\"Action\", \"ListRecords\")\n\tparams.Set(\"SiteId\", strconv.FormatInt(site.SiteId, 10))\n\tparams.Set(\"RecordName\", domain.String())\n\tparams.Set(\"Type\", recordType)\n\terr = ali.request(http.MethodGet, params, &recordResp)\n\n\t// recordResp.TotalCount == 0\n\tif len(recordResp.Records) == 0 {\n\t\treturn\n\t}\n\n\t// 指定 RecordId\n\trecordId := domain.GetCustomParams().Get(\"RecordId\")\n\tif recordId != \"\" {\n\t\tfor i := 0; i < len(recordResp.Records); i++ {\n\t\t\tif strconv.FormatInt(recordResp.Records[i].RecordId, 10) == recordId {\n\t\t\t\treturn recordResp.Records[i], nil\n\t\t\t}\n\t\t}\n\t}\n\treturn recordResp.Records[0], nil\n}\n\n// 获取域名的站点信息\n// https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listsites\nfunc (ali *Aliesa) getSite(domainTuple *config.DomainTuple) (result AliesaSite, err error) {\n\tdomain := domainTuple.Primary\n\tif site, ok := ali.siteCache[domain.DomainName]; ok {\n\t\treturn site, nil\n\t}\n\n\t// 解析自定义参数 SiteId，但不使用 api GetSite 查询\n\tsiteIdStr := domain.GetCustomParams().Get(\"SiteId\")\n\tif siteId, _ := strconv.ParseInt(siteIdStr, 10, 64); siteId != 0 {\n\t\t// 兼容 CNAME 接入方式\n\t\tresult.AccessType = \"CNAME\"\n\t\tresult.SiteName = domain.DomainName\n\t\tresult.SiteId = siteId\n\t\treturn\n\t}\n\n\tvar siteResp AliesaSiteResp\n\tparams := url.Values{}\n\tparams.Set(\"Action\", \"ListSites\")\n\tparams.Set(\"SiteName\", domain.DomainName)\n\terr = ali.request(http.MethodGet, params, &siteResp)\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// siteResp.TotalCount == 0\n\tif len(siteResp.Sites) == 0 {\n\t\treturn\n\t}\n\n\tresult = siteResp.Sites[0]\n\tali.siteCache[domain.DomainName] = result\n\treturn\n}\n\n// getOriginPool 获取源地址池\n// https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-listoriginpools\nfunc (ali *Aliesa) getOriginPool(site AliesaSite, domainTuple *config.DomainTuple) (id int64, origins []map[string]interface{}, err error) {\n\tname, found := strings.CutSuffix(domainTuple.Primary.SubDomain, \".origin-pool\")\n\tif !found {\n\t\treturn\n\t}\n\n\tparams := url.Values{}\n\tparams.Set(\"Action\", \"ListOriginPools\")\n\tparams.Set(\"SiteId\", strconv.FormatInt(site.SiteId, 10))\n\tparams.Set(\"Name\", name)\n\tparams.Set(\"MatchType\", \"exact\")\n\n\tresult := struct {\n\t\tTotalCount  int\n\t\tOriginPools []struct {\n\t\t\tId      int64\n\t\t\tOrigins []map[string]interface{}\n\t\t}\n\t}{}\n\n\terr = ali.request(http.MethodGet, params, &result)\n\tif err == nil && len(result.OriginPools) > 0 {\n\t\tpool := result.OriginPools[0]\n\t\tid = pool.Id\n\t\torigins = pool.Origins\n\t}\n\treturn\n}\n\n// updateOriginPool 更新源地址池\n// https://help.aliyun.com/zh/edge-security-acceleration/esa/api-esa-2024-09-10-updateoriginpool\nfunc (ali *Aliesa) updateOriginPool(site AliesaSite, domainTuple *config.DomainTuple, id int64, origins []map[string]interface{}) {\n\tneedUpdate := false\n\tcount := len(domainTuple.Domains)\n\tfor _, origin := range origins {\n\t\t// 源地址池不能有多个相同地址，因此 Domain 更少放内层\n\t\tfor i, d := range domainTuple.Domains {\n\t\t\tname := d.GetCustomParams().Get(\"Name\")\n\t\t\tif origin[\"Name\"] != name {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\t// 相同不修改\n\t\t\taddress := domainTuple.IpAddrs[i]\n\t\t\tif origin[\"Address\"] != address {\n\t\t\t\torigin[\"Address\"] = address\n\t\t\t\tneedUpdate = true\n\t\t\t}\n\t\t\tcount--\n\t\t\tbreak\n\t\t}\n\t}\n\n\tdomain := domainTuple.Primary\n\tipAddr := domainTuple.GetIpAddrPool(\",\")\n\tif count > 0 {\n\t\t// 有新增的源地址\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, \"不支持新增源地址\")\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedFailed)\n\t\treturn\n\t}\n\tif !needUpdate {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\toriginsData, _ := json.Marshal(origins)\n\tparams := url.Values{}\n\tparams.Set(\"Action\", \"UpdateOriginPool\")\n\tparams.Set(\"SiteId\", strconv.FormatInt(site.SiteId, 10))\n\tparams.Set(\"Id\", strconv.FormatInt(id, 10))\n\tparams.Set(\"Origins\", string(originsData))\n\n\tresult := AliesaResp{}\n\terr := ali.request(http.MethodPost, params, &result)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedFailed)\n\t\treturn\n\t}\n\n\tif result.OriginPoolId != 0 {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedSuccess)\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, \"返回 OriginPool Id为空\")\n\t\tdomainTuple.SetUpdateStatus(config.UpdatedFailed)\n\t}\n}\n\n// request 统一请求接口\nfunc (ali *Aliesa) request(method string, params url.Values, result interface{}) (err error) {\n\tutil.AliyunSigner(ali.DNS.ID, ali.DNS.Secret, &params, method, \"2024-09-10\")\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\taliesaEndpoint,\n\t\tbytes.NewBuffer(nil),\n\t)\n\treq.URL.RawQuery = params.Encode()\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tclient := ali.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/baidu.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// https://cloud.baidu.com/doc/BCD/s/4jwvymhs7\n\nconst (\n\tbaiduEndpoint = \"https://bcd.baidubce.com\"\n)\n\ntype BaiduCloud struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// BaiduRecord 单条解析记录\ntype BaiduRecord struct {\n\tRecordId uint   `json:\"recordId\"`\n\tDomain   string `json:\"domain\"`\n\tView     string `json:\"view\"`\n\tRdtype   string `json:\"rdtype\"`\n\tTTL      int    `json:\"ttl\"`\n\tRdata    string `json:\"rdata\"`\n\tZoneName string `json:\"zoneName\"`\n\tStatus   string `json:\"status\"`\n}\n\n// BaiduRecordsResp 获取解析列表拿到的结果\ntype BaiduRecordsResp struct {\n\tTotalCount int           `json:\"totalCount\"`\n\tResult     []BaiduRecord `json:\"result\"`\n}\n\n// BaiduListRequest 获取解析列表请求的body json\ntype BaiduListRequest struct {\n\tDomain   string `json:\"domain\"`\n\tPageNum  int    `json:\"pageNum\"`\n\tPageSize int    `json:\"pageSize\"`\n}\n\n// BaiduModifyRequest 修改解析请求的body json\ntype BaiduModifyRequest struct {\n\tRecordId uint   `json:\"recordId\"`\n\tDomain   string `json:\"domain\"`\n\tView     string `json:\"view\"`\n\tRdType   string `json:\"rdType\"`\n\tTTL      int    `json:\"ttl\"`\n\tRdata    string `json:\"rdata\"`\n\tZoneName string `json:\"zoneName\"`\n}\n\n// BaiduCreateRequest 创建新解析请求的body json\ntype BaiduCreateRequest struct {\n\tDomain   string `json:\"domain\"`\n\tRdType   string `json:\"rdType\"`\n\tTTL      int    `json:\"ttl\"`\n\tRdata    string `json:\"rdata\"`\n\tZoneName string `json:\"zoneName\"`\n}\n\nfunc (baidu *BaiduCloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tbaidu.Domains.Ipv4Cache = ipv4cache\n\tbaidu.Domains.Ipv6Cache = ipv6cache\n\tbaidu.DNS = dnsConf.DNS\n\tbaidu.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认300s\n\t\tbaidu.TTL = 300\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\tbaidu.TTL = 300\n\t\t} else {\n\t\t\tbaidu.TTL = ttl\n\t\t}\n\t}\n\tbaidu.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (baidu *BaiduCloud) AddUpdateDomainRecords() config.Domains {\n\tbaidu.addUpdateDomainRecords(\"A\")\n\tbaidu.addUpdateDomainRecords(\"AAAA\")\n\treturn baidu.Domains\n}\n\nfunc (baidu *BaiduCloud) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := baidu.Domains.GetNewIpResult(recordType)\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tvar records BaiduRecordsResp\n\n\t\trequestBody := BaiduListRequest{\n\t\t\tDomain:   domain.DomainName,\n\t\t\tPageNum:  1,\n\t\t\tPageSize: 1000,\n\t\t}\n\n\t\terr := baidu.request(\"POST\", baiduEndpoint+\"/v1/domain/resolve/list\", requestBody, &records)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tfind := false\n\t\tfor _, record := range records.Result {\n\t\t\tif record.Domain == domain.GetSubDomain() {\n\t\t\t\t//存在就去更新\n\t\t\t\tbaidu.modify(record, domain, recordType, ipAddr)\n\t\t\t\tfind = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif !find {\n\t\t\t//没找到，去创建\n\t\t\tbaidu.create(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// create 创建新的解析\nfunc (baidu *BaiduCloud) create(domain *config.Domain, recordType string, ipAddr string) {\n\tvar baiduCreateRequest = BaiduCreateRequest{\n\t\tDomain:   domain.GetSubDomain(), //处理一下@\n\t\tRdType:   recordType,\n\t\tTTL:      baidu.TTL,\n\t\tRdata:    ipAddr,\n\t\tZoneName: domain.DomainName,\n\t}\n\tvar result BaiduRecordsResp\n\n\terr := baidu.request(\"POST\", baiduEndpoint+\"/v1/domain/resolve/add\", baiduCreateRequest, &result)\n\tif err == nil {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// modify 更新解析\nfunc (baidu *BaiduCloud) modify(record BaiduRecord, domain *config.Domain, rdType string, ipAddr string) {\n\t//没有变化直接跳过\n\tif record.Rdata == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\tvar baiduModifyRequest = BaiduModifyRequest{\n\t\tRecordId: record.RecordId,\n\t\tDomain:   record.Domain,\n\t\tView:     record.View,\n\t\tRdType:   rdType,\n\t\tTTL:      record.TTL,\n\t\tRdata:    ipAddr,\n\t\tZoneName: record.ZoneName,\n\t}\n\tvar result BaiduRecordsResp\n\n\terr := baidu.request(\"POST\", baiduEndpoint+\"/v1/domain/resolve/edit\", baiduModifyRequest, &result)\n\tif err == nil {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// request 统一请求接口\nfunc (baidu *BaiduCloud) request(method string, url string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tutil.BaiduSigner(baidu.DNS.ID, baidu.DNS.Secret, req)\n\n\tclient := baidu.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/callback.go",
    "content": "package dns\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\ntype Callback struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\tlastIpv4   string\n\tlastIpv6   string\n\thttpClient *http.Client\n}\n\n// Init 初始化\nfunc (cb *Callback) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tcb.Domains.Ipv4Cache = ipv4cache\n\tcb.Domains.Ipv6Cache = ipv6cache\n\tcb.lastIpv4 = ipv4cache.Addr\n\tcb.lastIpv6 = ipv6cache.Addr\n\n\tcb.DNS = dnsConf.DNS\n\tcb.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600\n\t\tcb.TTL = \"600\"\n\t} else {\n\t\tcb.TTL = dnsConf.TTL\n\t}\n\tcb.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (cb *Callback) AddUpdateDomainRecords() config.Domains {\n\tcb.addUpdateDomainRecords(\"A\")\n\tcb.addUpdateDomainRecords(\"AAAA\")\n\treturn cb.Domains\n}\n\nfunc (cb *Callback) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := cb.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\t// 防止多次发送Webhook通知\n\tif recordType == \"A\" {\n\t\tif cb.lastIpv4 == ipAddr {\n\t\t\tutil.Log(\"你的IPv4未变化, 未触发 %s 请求\", \"Callback\")\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif cb.lastIpv6 == ipAddr {\n\t\t\tutil.Log(\"你的IPv6未变化, 未触发 %s 请求\", \"Callback\")\n\t\t\treturn\n\t\t}\n\t}\n\n\tfor _, domain := range domains {\n\t\tmethod := \"GET\"\n\t\tpostPara := \"\"\n\t\tcontentType := \"application/x-www-form-urlencoded\"\n\t\tif cb.DNS.Secret != \"\" {\n\t\t\tmethod = \"POST\"\n\t\t\tpostPara = replacePara(cb.DNS.Secret, ipAddr, domain, recordType, cb.TTL)\n\t\t\tif json.Valid([]byte(postPara)) {\n\t\t\t\tcontentType = \"application/json\"\n\t\t\t}\n\t\t}\n\t\trequestURL := replacePara(cb.DNS.ID, ipAddr, domain, recordType, cb.TTL)\n\t\tu, err := url.Parse(requestURL)\n\t\tif err != nil {\n\t\t\tutil.Log(\"Callback的URL不正确\")\n\t\t\treturn\n\t\t}\n\t\treq, err := http.NewRequest(method, u.String(), strings.NewReader(postPara))\n\t\tif err != nil {\n\t\t\tutil.Log(\"异常信息: %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\t\treq.Header.Add(\"content-type\", contentType)\n\n\t\tclt := util.CreateHTTPClient()\n\t\tresp, err := clt.Do(req)\n\t\tbody, err := util.GetHTTPResponseOrg(resp, err)\n\t\tif err == nil {\n\t\t\tutil.Log(\"Callback调用成功, 域名: %s, IP: %s, 返回数据: %s\", domain, ipAddr, string(body))\n\t\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t\t} else {\n\t\t\tutil.Log(\"Callback调用失败, 异常信息: %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t}\n\t}\n}\n\n// replacePara 替换参数\nfunc replacePara(orgPara, ipAddr string, domain *config.Domain, recordType string, ttl string) string {\n\t// params 使用 map 以便添加更多参数\n\tparams := map[string]string{\n\t\t\"ip\":         ipAddr,\n\t\t\"domain\":     domain.String(),\n\t\t\"recordType\": recordType,\n\t\t\"ttl\":        ttl,\n\t}\n\n\t// 也替换域名的自定义参数\n\tfor k, v := range domain.GetCustomParams() {\n\t\tif len(v) == 1 {\n\t\t\tparams[k] = v[0]\n\t\t}\n\t}\n\n\t// 将 map 转换为 [NewReplacer] 所需的参数\n\t// map 中的每个元素占用 2 个位置（kv），因此需要预留 2 倍的空间\n\toldnew := make([]string, 0, len(params)*2)\n\tfor k, v := range params {\n\t\tk = fmt.Sprintf(\"#{%s}\", k)\n\t\toldnew = append(oldnew, k, v)\n\t}\n\n\treturn strings.NewReplacer(oldnew...).Replace(orgPara)\n}\n"
  },
  {
    "path": "dns/cloudflare.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst zonesAPI = \"https://api.cloudflare.com/client/v4/zones\"\n\n// Cloudflare Cloudflare实现\ntype Cloudflare struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// CloudflareZonesResp cloudflare zones返回结果\ntype CloudflareZonesResp struct {\n\tCloudflareStatus\n\tResult []struct {\n\t\tID     string\n\t\tName   string\n\t\tStatus string\n\t\tPaused bool\n\t}\n}\n\n// CloudflareRecordsResp records\ntype CloudflareRecordsResp struct {\n\tCloudflareStatus\n\tResult []CloudflareRecord\n}\n\n// CloudflareRecord 记录实体\ntype CloudflareRecord struct {\n\tID      string `json:\"id\"`\n\tName    string `json:\"name\"`\n\tType    string `json:\"type\"`\n\tContent string `json:\"content\"`\n\tProxied bool   `json:\"proxied\"`\n\tTTL     int    `json:\"ttl\"`\n\tComment string `json:\"comment\"`\n}\n\n// CloudflareStatus 公共状态\ntype CloudflareStatus struct {\n\tSuccess  bool\n\tMessages []string\n}\n\n// Init 初始化\nfunc (cf *Cloudflare) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tcf.Domains.Ipv4Cache = ipv4cache\n\tcf.Domains.Ipv6Cache = ipv6cache\n\tcf.DNS = dnsConf.DNS\n\tcf.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认1 auto ttl\n\t\tcf.TTL = 1\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\tcf.TTL = 1\n\t\t} else {\n\t\t\tcf.TTL = ttl\n\t\t}\n\t}\n\tcf.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (cf *Cloudflare) AddUpdateDomainRecords() config.Domains {\n\tcf.addUpdateDomainRecords(\"A\")\n\tcf.addUpdateDomainRecords(\"AAAA\")\n\treturn cf.Domains\n}\n\nfunc (cf *Cloudflare) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := cf.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\t// get zone\n\t\tresult, err := cf.getZones(domain)\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif len(result.Result) == 0 {\n\t\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain.DomainName)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tparams := url.Values{}\n\t\tparams.Set(\"type\", recordType)\n\t\t// The name of DNS records in Cloudflare API expects Punycode.\n\t\t//\n\t\t// See: cloudflare/cloudflare-go#690\n\t\tparams.Set(\"name\", domain.ToASCII())\n\t\tparams.Set(\"per_page\", \"50\")\n\t\t// Add a comment only if it exists\n\t\tif c := domain.GetCustomParams().Get(\"comment\"); c != \"\" {\n\t\t\tparams.Set(\"comment\", c)\n\t\t}\n\n\t\tzoneID := result.Result[0].ID\n\n\t\tvar records CloudflareRecordsResp\n\t\t// getDomains 最多更新前50条\n\t\terr = cf.request(\n\t\t\t\"GET\",\n\t\t\tfmt.Sprintf(zonesAPI+\"/%s/dns_records?%s\", zoneID, params.Encode()),\n\t\t\tnil,\n\t\t\t&records,\n\t\t)\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif !records.Success {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", strings.Join(records.Messages, \", \"))\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif len(records.Result) > 0 {\n\t\t\t// 更新\n\t\t\tcf.modify(records, zoneID, domain, ipAddr)\n\t\t} else {\n\t\t\t// 新增\n\t\t\tcf.create(zoneID, domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// 创建\nfunc (cf *Cloudflare) create(zoneID string, domain *config.Domain, recordType string, ipAddr string) {\n\trecord := &CloudflareRecord{\n\t\tType:    recordType,\n\t\tName:    domain.ToASCII(),\n\t\tContent: ipAddr,\n\t\tProxied: false,\n\t\tTTL:     cf.TTL,\n\t\tComment: domain.GetCustomParams().Get(\"comment\"),\n\t}\n\trecord.Proxied = domain.GetCustomParams().Get(\"proxied\") == \"true\"\n\tvar status CloudflareStatus\n\terr := cf.request(\n\t\t\"POST\",\n\t\tfmt.Sprintf(zonesAPI+\"/%s/dns_records\", zoneID),\n\t\trecord,\n\t\t&status,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Success {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, strings.Join(status.Messages, \", \"))\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 修改\nfunc (cf *Cloudflare) modify(result CloudflareRecordsResp, zoneID string, domain *config.Domain, ipAddr string) {\n\tfor _, record := range result.Result {\n\t\t// 相同不修改\n\t\tif record.Content == ipAddr {\n\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\tcontinue\n\t\t}\n\t\tvar status CloudflareStatus\n\t\trecord.Content = ipAddr\n\t\trecord.TTL = cf.TTL\n\t\t// 存在参数才修改proxied\n\t\tif domain.GetCustomParams().Has(\"proxied\") {\n\t\t\trecord.Proxied = domain.GetCustomParams().Get(\"proxied\") == \"true\"\n\t\t}\n\t\terr := cf.request(\n\t\t\t\"PUT\",\n\t\t\tfmt.Sprintf(zonesAPI+\"/%s/dns_records/%s\", zoneID, record.ID),\n\t\t\trecord,\n\t\t\t&status,\n\t\t)\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif status.Success {\n\t\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t\t} else {\n\t\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, strings.Join(status.Messages, \", \"))\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t}\n\t}\n}\n\n// 获得域名记录列表\nfunc (cf *Cloudflare) getZones(domain *config.Domain) (result CloudflareZonesResp, err error) {\n\tparams := url.Values{}\n\tparams.Set(\"name\", domain.DomainName)\n\tparams.Set(\"status\", \"active\")\n\tparams.Set(\"per_page\", \"50\")\n\n\terr = cf.request(\n\t\t\"GET\",\n\t\tfmt.Sprintf(zonesAPI+\"?%s\", params.Encode()),\n\t\tnil,\n\t\t&result,\n\t)\n\n\treturn\n}\n\n// request 统一请求接口\nfunc (cf *Cloudflare) request(method string, url string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+cf.DNS.Secret)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := cf.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/dnsla.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"io\"\n\t\"net/http\"\n\t\"strconv\"\n)\n\nconst (\n\trecordList   string = \"http://api.dns.la/api/recordList\"\n\trecordModify string = \"http://api.dns.la/api/record\"\n\trecordCreate string = \"http://api.dns.la/api/record\"\n)\n\n// https://www.dns.la/docs/ApiDoc\n// dnsla dnsla实现\ntype Dnsla struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// DnslaRecord\ntype DnslaRecord struct {\n\tID   string `json:\"id\"`\n\tHost string `json:\"host\"`\n\tType int    `json:\"type\"`\n\tData string `json:\"data\"`\n}\n\n// DnslaRecordListResp recordListAPI结果\ntype DnslaRecordListResp struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tTotal   int           `json:\"total\"`\n\t\tResults []DnslaRecord `json:\"results\"`\n\t} `json:\"data\"`\n}\n\n// DnslaStatus DnslaStatus\ntype DnslaStatus struct {\n\tCode int    `json:\"code\"`\n\tMsg  string `json:\"msg\"`\n\tData struct {\n\t\tId string `json:\"id\"`\n\t} `json:\"data\"`\n}\n\n// Init 初始化\nfunc (dnsla *Dnsla) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tdnsla.Domains.Ipv4Cache = ipv4cache\n\tdnsla.Domains.Ipv6Cache = ipv6cache\n\tdnsla.DNS = dnsConf.DNS\n\tdnsla.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tdnsla.TTL = 600\n\t} else {\n\t\tttlInt, _ := strconv.Atoi(dnsConf.TTL)\n\t\tdnsla.TTL = ttlInt\n\t}\n\tdnsla.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (dnsla *Dnsla) AddUpdateDomainRecords() config.Domains {\n\tdnsla.addUpdateDomainRecords(\"A\")\n\tdnsla.addUpdateDomainRecords(\"AAAA\")\n\treturn dnsla.Domains\n}\n\nfunc (dnsla *Dnsla) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := dnsla.Domains.GetNewIpResult(recordType)\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\tfor _, domain := range domains {\n\t\tresultByte, err := dnsla.getRecordList(domain, recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\t\tvar jsonResult DnslaRecordListResp\n\t\terrU := json.Unmarshal(resultByte, &jsonResult)\n\t\tif errU != nil {\n\t\t\tutil.Log(errU.Error())\n\t\t\treturn\n\t\t}\n\t\tif jsonResult.Data.Total > 0 { // 默认第一个\n\t\t\trecordSelected := jsonResult.Data.Results[0]\n\t\t\tparams := domain.GetCustomParams()\n\t\t\tif params.Has(\"id\") {\n\t\t\t\tfor i := 0; i < len(jsonResult.Data.Results); i++ {\n\t\t\t\t\tif jsonResult.Data.Results[i].ID == params.Get(\"id\") {\n\t\t\t\t\t\trecordSelected = jsonResult.Data.Results[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 更新\n\t\t\tdnsla.modify(recordSelected, domain, recordType, ipAddr)\n\t\t} else {\n\t\t\t// 新增\n\t\t\tdnsla.create(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// 创建\nfunc (dnsla *Dnsla) create(domain *config.Domain, recordType string, ipAddr string) {\n\trecordTypeInt := 1\n\tif recordType == \"AAAA\" {\n\t\trecordTypeInt = 28\n\t}\n\ttype CreateParams struct {\n\t\tDomain string `json:\"Domain\"`\n\t\tHost   string `json:\"Host\"`\n\t\tType   int    `json:\"Type\"`\n\t\tData   string `json:\"Data\"`\n\t\tTTL    int    `json:\"TTL\"`\n\t}\n\tcreateParams := CreateParams{\n\t\tDomain: domain.DomainName,\n\t\tHost:   domain.GetSubDomain(),\n\t\tType:   recordTypeInt,\n\t\tData:   ipAddr,\n\t\tTTL:    dnsla.TTL,\n\t}\n\tjsonData, _ := json.Marshal(createParams)\n\tresultByte, err := dnsla.request(\"POST\", recordCreate, jsonData)\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\tvar jsonResult DnslaStatus\n\terrU := json.Unmarshal(resultByte, &jsonResult)\n\tif errU != nil {\n\t\tutil.Log(errU.Error())\n\t\treturn\n\t}\n\tif jsonResult.Code == 200 {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, jsonResult.Msg)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 修改\nfunc (dnsla *Dnsla) modify(record DnslaRecord, domain *config.Domain, recordType string, ipAddr string) {\n\t// 相同不修改\n\tif record.Data == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\trecordTypeInt := 1\n\tif recordType == \"AAAA\" {\n\t\trecordTypeInt = 28\n\t}\n\ttype ModifyParams struct {\n\t\tID   string `json:\"Id\"`\n\t\tHost string `json:\"Host\"`\n\t\tType int    `json:\"Type\"`\n\t\tData string `json:\"Data\"`\n\t\tTTL  int    `json:\"TTL\"`\n\t}\n\tmodifyParams := ModifyParams{\n\t\tID:   record.ID,\n\t\tHost: domain.GetSubDomain(),\n\t\tType: recordTypeInt,\n\t\tData: ipAddr,\n\t\tTTL:  dnsla.TTL,\n\t}\n\tjsonData, _ := json.Marshal(modifyParams)\n\tresultByte, err := dnsla.request(\"PUT\", recordModify, jsonData)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tvar jsonResult DnslaStatus\n\terrU := json.Unmarshal(resultByte, &jsonResult)\n\tif errU != nil {\n\t\tutil.Log(errU.Error())\n\t\treturn\n\t}\n\tif jsonResult.Code == 200 {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, jsonResult.Msg)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// request sends a POST request to the given API with the given values.\nfunc (dnsla *Dnsla) request(method, apiAddr string, values []byte) (body []byte, err error) {\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\tapiAddr,\n\t\tbytes.NewReader(values),\n\t)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\t// 设置自定义 Headers\n\tbyteBuff := []byte(dnsla.DNS.ID + \":\" + dnsla.DNS.Secret)\n\ttoken := \"Basic \" + base64.StdEncoding.EncodeToString(byteBuff)\n\treq.Header.Set(\"Authorization\", token)\n\treq.Header.Set(\"Content-Type\", \"application/json;charset=utf-8\")\n\t// 4. 发送请求\n\tclient := dnsla.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer resp.Body.Close()\n\n\tbody, _ = io.ReadAll(resp.Body)\n\treturn\n}\n\n// 获得域名记录列表\nfunc (dnsla *Dnsla) getRecordList(domain *config.Domain, typ string) (result []byte, err error) {\n\trecordTypeInt := \"1\"\n\tif typ == \"AAAA\" {\n\t\trecordTypeInt = \"28\"\n\t}\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"domain\", domain.DomainName)\n\tparams.Set(\"host\", domain.GetSubDomain())\n\tparams.Set(\"type\", recordTypeInt)\n\tparams.Set(\"pageIndex\", \"1\")\n\tparams.Set(\"pageSize\", \"999\")\n\n\turl := recordList + \"?\" + params.Encode()\n\treq, err := http.NewRequest(\"GET\", url, nil)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\tbyteBuff := []byte(dnsla.DNS.ID + \":\" + dnsla.DNS.Secret)\n\ttoken := \"Basic \" + base64.StdEncoding.EncodeToString(byteBuff)\n\t// 设置 Headers\n\treq.Header.Set(\"Authorization\", token)\n\n\t// 发送请求\n\tclient := dnsla.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 读取响应\n\tresult, errR := io.ReadAll(resp.Body)\n\tif errR != nil {\n\t\tutil.Log(errR.Error())\n\t\treturn\n\t}\n\treturn\n}\n"
  },
  {
    "path": "dns/dnspod.go",
    "content": "package dns\n\nimport (\n\t\"net/http\"\n\t\"net/url\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\trecordListAPI   string = \"https://dnsapi.cn/Record.List\"\n\trecordModifyURL string = \"https://dnsapi.cn/Record.Modify\"\n\trecordCreateAPI string = \"https://dnsapi.cn/Record.Create\"\n)\n\n// https://cloud.tencent.com/document/api/302/8516\n// Dnspod 腾讯云dns实现\ntype Dnspod struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\n\n// DnspodRecord DnspodRecord\ntype DnspodRecord struct {\n\tID      string\n\tName    string\n\tType    string\n\tValue   string\n\tEnabled string\n}\n\n// DnspodRecordListResp recordListAPI结果\ntype DnspodRecordListResp struct {\n\tDnspodStatus\n\tRecords []DnspodRecord\n}\n\n// DnspodStatus DnspodStatus\ntype DnspodStatus struct {\n\tStatus struct {\n\t\tCode    string\n\t\tMessage string\n\t}\n}\n\n// Init 初始化\nfunc (dnspod *Dnspod) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tdnspod.Domains.Ipv4Cache = ipv4cache\n\tdnspod.Domains.Ipv6Cache = ipv6cache\n\tdnspod.DNS = dnsConf.DNS\n\tdnspod.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tdnspod.TTL = \"600\"\n\t} else {\n\t\tdnspod.TTL = dnsConf.TTL\n\t}\n\tdnspod.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (dnspod *Dnspod) AddUpdateDomainRecords() config.Domains {\n\tdnspod.addUpdateDomainRecords(\"A\")\n\tdnspod.addUpdateDomainRecords(\"AAAA\")\n\treturn dnspod.Domains\n}\n\nfunc (dnspod *Dnspod) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := dnspod.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tresult, err := dnspod.getRecordList(domain, recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif len(result.Records) > 0 {\n\t\t\t// 默认第一个\n\t\t\trecordSelected := result.Records[0]\n\t\t\tparams := domain.GetCustomParams()\n\t\t\tif params.Has(\"record_id\") {\n\t\t\t\tfor i := 0; i < len(result.Records); i++ {\n\t\t\t\t\tif result.Records[i].ID == params.Get(\"record_id\") {\n\t\t\t\t\t\trecordSelected = result.Records[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 更新\n\t\t\tdnspod.modify(recordSelected, domain, recordType, ipAddr)\n\t\t} else {\n\t\t\t// 新增\n\t\t\tdnspod.create(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// 创建\nfunc (dnspod *Dnspod) create(domain *config.Domain, recordType string, ipAddr string) {\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"login_token\", dnspod.DNS.ID+\",\"+dnspod.DNS.Secret)\n\tparams.Set(\"domain\", domain.DomainName)\n\tparams.Set(\"sub_domain\", domain.GetSubDomain())\n\tparams.Set(\"record_type\", recordType)\n\tparams.Set(\"value\", ipAddr)\n\tparams.Set(\"ttl\", dnspod.TTL)\n\tparams.Set(\"format\", \"json\")\n\n\tif !params.Has(\"record_line\") {\n\t\tparams.Set(\"record_line\", \"默认\")\n\t}\n\n\tstatus, err := dnspod.request(recordCreateAPI, params)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Status.Code == \"1\" {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, status.Status.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 修改\nfunc (dnspod *Dnspod) modify(record DnspodRecord, domain *config.Domain, recordType string, ipAddr string) {\n\n\t// 相同不修改\n\tif record.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"login_token\", dnspod.DNS.ID+\",\"+dnspod.DNS.Secret)\n\tparams.Set(\"domain\", domain.DomainName)\n\tparams.Set(\"sub_domain\", domain.GetSubDomain())\n\tparams.Set(\"record_type\", recordType)\n\tparams.Set(\"value\", ipAddr)\n\tparams.Set(\"ttl\", dnspod.TTL)\n\tparams.Set(\"format\", \"json\")\n\tparams.Set(\"record_id\", record.ID)\n\n\tif !params.Has(\"record_line\") {\n\t\tparams.Set(\"record_line\", \"默认\")\n\t}\n\n\tstatus, err := dnspod.request(recordModifyURL, params)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Status.Code == \"1\" {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, status.Status.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// request sends a POST request to the given API with the given values.\nfunc (dnspod *Dnspod) request(apiAddr string, values url.Values) (status DnspodStatus, err error) {\n\tclient := dnspod.httpClient\n\tresp, err := client.PostForm(\n\t\tapiAddr,\n\t\tvalues,\n\t)\n\n\terr = util.GetHTTPResponse(resp, err, &status)\n\n\treturn\n}\n\n// 获得域名记录列表\nfunc (dnspod *Dnspod) getRecordList(domain *config.Domain, typ string) (result DnspodRecordListResp, err error) {\n\n\tparams := domain.GetCustomParams()\n\tparams.Set(\"login_token\", dnspod.DNS.ID+\",\"+dnspod.DNS.Secret)\n\tparams.Set(\"domain\", domain.DomainName)\n\tparams.Set(\"record_type\", typ)\n\tparams.Set(\"sub_domain\", domain.GetSubDomain())\n\tparams.Set(\"format\", \"json\")\n\n\tclient := dnspod.httpClient\n\tresp, err := client.PostForm(\n\t\trecordListAPI,\n\t\tparams,\n\t)\n\n\terr = util.GetHTTPResponse(resp, err, &result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/dynadot.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// https://www.dynadot.com/set_ddns\nconst (\n\tdynadotEndpoint string = \"https://www.dynadot.com/set_ddns\"\n)\n\n// Dynadot Dynadot\ntype Dynadot struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\tLastIpv4   string\n\tLastIpv6   string\n\thttpClient *http.Client\n}\n\n// DynadotRecord record\ntype DynadotRecord struct {\n\tDomainName     string\n\tSubDomainNames []string\n\tCustomParams   url.Values\n\tDomains        []*config.Domain\n\tContainRoot    bool\n}\n\n// DynadotResp 修改/添加返回结果\ntype DynadotResp struct {\n\tStatus    string   `json:\"status\"`\n\tErrorCode int      `json:\"error_code\"`\n\tContent   []string `json:\"content\"`\n}\n\n// Init 初始化\nfunc (dynadot *Dynadot) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tdynadot.Domains.Ipv4Cache = ipv4cache\n\tdynadot.Domains.Ipv6Cache = ipv6cache\n\tdynadot.LastIpv4 = ipv4cache.Addr\n\tdynadot.LastIpv6 = ipv6cache.Addr\n\tdynadot.DNS = dnsConf.DNS\n\tdynadot.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tdynadot.TTL = \"600\"\n\t} else {\n\t\tdynadot.TTL = dnsConf.TTL\n\t}\n\tdynadot.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (dynadot *Dynadot) AddUpdateDomainRecords() config.Domains {\n\tdynadot.addOrUpdateDomainRecords(\"A\")\n\tdynadot.addOrUpdateDomainRecords(\"AAAA\")\n\treturn dynadot.Domains\n}\n\n// addOrUpdateDomainRecords 添加或更新记录\nfunc (dynadot *Dynadot) addOrUpdateDomainRecords(recordType string) {\n\tipAddr, domains := dynadot.Domains.GetNewIpResult(recordType)\n\n\tif len(ipAddr) == 0 {\n\t\treturn\n\t}\n\n\t// 防止多次发送Webhook通知\n\tif recordType == \"A\" {\n\t\tif dynadot.LastIpv4 == ipAddr {\n\t\t\tutil.Log(\"你的IPv4未变化, 未触发 %s 请求\", \"dynadot\")\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif dynadot.LastIpv6 == ipAddr {\n\t\t\tutil.Log(\"你的IPv6未变化, 未触发 %s 请求\", \"dynadot\")\n\t\t\treturn\n\t\t}\n\t}\n\n\trecords := mergeDomains(domains)\n\t// dynadot 仅支持一个域名对应一个dynamic password\n\tif len(records) != 1 {\n\t\tutil.Log(\"dynadot仅支持单域名配置，多个域名请添加更多配置\")\n\t\treturn\n\t}\n\tfor _, record := range records {\n\t\t// 创建或更新\n\t\tdynadot.createOrModify(record, recordType, ipAddr)\n\t}\n}\n\n// 合并域名的子域名\nfunc mergeDomains(domains []*config.Domain) (records []*DynadotRecord) {\n\trecords = make([]*DynadotRecord, 0)\n\tfor _, domain := range domains {\n\t\tvar record *DynadotRecord\n\t\tfor _, r := range records {\n\t\t\tif r.DomainName == domain.DomainName {\n\t\t\t\trecord = r\n\t\t\t\tparams := domain.GetCustomParams()\n\t\t\t\tfor key := range params {\n\t\t\t\t\trecord.CustomParams.Add(key, params.Get(key))\n\t\t\t\t}\n\t\t\t\trecord.Domains = append(record.Domains, domain)\n\t\t\t\trecord.SubDomainNames = append(record.SubDomainNames, domain.GetSubDomain())\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif record == nil {\n\t\t\trecord = &DynadotRecord{\n\t\t\t\tDomainName:     domain.DomainName,\n\t\t\t\tCustomParams:   domain.GetCustomParams(),\n\t\t\t\tDomains:        []*config.Domain{domain},\n\t\t\t\tSubDomainNames: []string{domain.GetSubDomain()},\n\t\t\t}\n\t\t\trecords = append(records, record)\n\t\t}\n\t\tif len(domain.SubDomain) == 0 {\n\t\t\t// 包含根域名\n\t\t\trecord.ContainRoot = true\n\t\t}\n\t}\n\treturn records\n}\n\n// 创建或变更记录\nfunc (dynadot *Dynadot) createOrModify(record *DynadotRecord, recordType string, ipAddr string) {\n\tparams := record.CustomParams\n\tparams.Set(\"domain\", record.DomainName)\n\tparams.Set(\"subDomain\", strings.Join(record.SubDomainNames, \",\"))\n\tparams.Set(\"type\", recordType)\n\tparams.Set(\"ip\", ipAddr)\n\tparams.Set(\"pwd\", dynadot.DNS.Secret)\n\tparams.Set(\"ttl\", dynadot.TTL)\n\tparams.Set(\"containRoot\", strconv.FormatBool(record.ContainRoot))\n\n\tvar result DynadotResp\n\terr := dynadot.request(params, &result)\n\n\tdomains := record.Domains\n\tfor _, domain := range domains {\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif result.ErrorCode != -1 {\n\t\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t\t} else {\n\t\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, strings.Join(result.Content, \",\"))\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t}\n\t}\n\n}\n\n// request 统一请求接口\nfunc (dynadot *Dynadot) request(params url.Values, result interface{}) (err error) {\n\n\treq, err := http.NewRequest(\n\t\t\"GET\",\n\t\tdynadotEndpoint,\n\t\tbytes.NewBuffer(nil),\n\t)\n\treq.URL.RawQuery = params.Encode()\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tclient := dynadot.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/dynv6.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n)\n\nconst (\n\tdynv6Endpoint = \"https://dynv6.com\"\n)\n\ntype Dynv6 struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\n\ntype Dynv6Zone struct {\n\tID   uint   `json:\"id\"`\n\tName string `json:\"name\"`\n\tIpv4 string `json:\"ipv4address\"`\n\tIpv6 string `json:\"ipv6prefix\"`\n}\n\ntype Dynv6Record struct {\n\tID     uint   `json:\"id\"`\n\tZoneID uint   `json:\"zoneID\"`\n\tName   string `json:\"name\"`\n\tType   string `json:\"type\"`\n\tData   string `json:\"data\"`\n}\n\n// Init 初始化\nfunc (dynv6 *Dynv6) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tdynv6.Domains.Ipv4Cache = ipv4cache\n\tdynv6.Domains.Ipv6Cache = ipv6cache\n\tdynv6.DNS = dnsConf.DNS\n\tdynv6.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tdynv6.TTL = \"600\"\n\t} else {\n\t\tdynv6.TTL = dnsConf.TTL\n\t}\n\tdynv6.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (dynv6 *Dynv6) AddUpdateDomainRecords() config.Domains {\n\tdynv6.addUpdateDomainRecords(\"A\")\n\tdynv6.addUpdateDomainRecords(\"AAAA\")\n\treturn dynv6.Domains\n}\n\nfunc (dynv6 *Dynv6) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := dynv6.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tisFindZone, findZone, isMain, err := dynv6.findZone(domain)\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif !isFindZone {\n\t\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\tzoneId := strconv.FormatUint(uint64(findZone.ID), 10)\n\n\t\tif isMain {\n\t\t\t// 如果使用的域名是主域名，对比DNS记录确定是否调用更新接口\n\t\t\tif (recordType == \"A\" && findZone.Ipv4 == ipAddr) || (recordType == \"AAAA\" && findZone.Ipv6 == ipAddr) {\n\t\t\t\t// ip与dns服务器一致，不执行更新\n\t\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedNothing\n\t\t\t} else {\n\t\t\t\tdynv6.modifyMain(domain, zoneId, recordType, ipAddr)\n\t\t\t}\n\t\t} else {\n\t\t\t// 如果是子域名，检查是否有该子域名记录，有就更新记录，没有就创建\n\n\t\t\t// 处理subDomain\n\t\t\tprocessSubDomainOk := dynv6.processSubDomain(domain, findZone)\n\n\t\t\tif !processSubDomainOk {\n\t\t\t\tutil.Log(\"域名: %s 不正确\", domain)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tisFindRecord, findRecord, err := dynv6.findRecord(domain, zoneId, recordType)\n\n\t\t\tif err != nil {\n\t\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tif isFindRecord {\n\t\t\t\t// 判断是否需要更新\n\t\t\t\tif findRecord.Type == recordType && findRecord.Data == ipAddr {\n\t\t\t\t\t// ip与dns服务器一致，不执行更新\n\t\t\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\t\t\tdomain.UpdateStatus = config.UpdatedNothing\n\t\t\t\t} else {\n\t\t\t\t\tdynv6.modify(domain, zoneId, findRecord, recordType, ipAddr)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// 创建记录\n\t\t\t\tdynv6.create(domain, zoneId, recordType, ipAddr)\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (dynv6 *Dynv6) processSubDomain(domain *config.Domain, zone Dynv6Zone) bool {\n\t// 确定subDomain\n\tsubDomainLen := len(domain.String()) - len(zone.Name) - 1\n\tif subDomainLen <= 0 {\n\t\treturn false\n\t}\n\tsubDomain := domain.String()[:subDomainLen]\n\n\tdomain.DomainName = zone.Name\n\tdomain.SubDomain = subDomain\n\treturn true\n}\n\n// 根据domain获取zone\nfunc (dynv6 *Dynv6) findZone(domain *config.Domain) (isFind bool, zone Dynv6Zone, isMain bool, err error) {\n\tvar zones []Dynv6Zone\n\tisFind = false\n\tisMain = false\n\n\t// 获取所有zone\n\terr = dynv6.request(\"GET\", dynv6Endpoint+\"/api/v2/zones\", nil, &zones)\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 遍历token权限下所有zone，确定当前域名属于哪个zone，并判断当前域名是主域名还是子域名\n\tfor _, z := range zones {\n\t\tif strings.HasSuffix(domain.String(), z.Name) {\n\t\t\tisFind = true\n\t\t\tzone = z\n\t\t\tif domain.String() == z.Name {\n\t\t\t\tisMain = true\n\t\t\t}\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn\n}\n\n// 根据domain获取record\nfunc (dynv6 *Dynv6) findRecord(domain *config.Domain, zoneId string, recordType string) (isFind bool, record Dynv6Record, err error) {\n\tvar records []Dynv6Record\n\tisFind = false\n\n\terr = dynv6.request(\"GET\", dynv6Endpoint+\"/api/v2/zones/\"+zoneId+\"/records\", nil, &records)\n\tif err != nil {\n\t\treturn\n\t}\n\n\t// 遍历zone下所有record，判断是更新还是创建\n\tfor _, r := range records {\n\t\tif r.Name == domain.SubDomain && r.Type == recordType {\n\t\t\tisFind = true\n\t\t\trecord = r\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn\n}\n\n// modify 更新根域名\nfunc (dynv6 *Dynv6) modifyMain(domain *config.Domain, zoneId string, recordType string, ipAddr string) {\n\tvar zoneUpdateReq = Dynv6Zone{}\n\tif recordType == \"A\" {\n\t\tzoneUpdateReq.Ipv4 = ipAddr\n\t} else {\n\t\tzoneUpdateReq.Ipv6 = ipAddr\n\t}\n\n\terr := dynv6.request(\"PATCH\", dynv6Endpoint+\"/api/v2/zones/\"+zoneId, zoneUpdateReq, &Dynv6Zone{})\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// create 创建新的解析\nfunc (dynv6 *Dynv6) create(domain *config.Domain, zoneId string, recordType string, ipAddr string) {\n\trecordUpdateReq := Dynv6Record{\n\t\tName: domain.SubDomain,\n\t\tType: recordType,\n\t\tData: ipAddr,\n\t}\n\n\terr := dynv6.request(\"POST\", dynv6Endpoint+\"/api/v2/zones/\"+zoneId+\"/records\", recordUpdateReq, &Dynv6Record{})\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// modify 更新解析\nfunc (dynv6 *Dynv6) modify(domain *config.Domain, zoneId string, record Dynv6Record, recordType string, ipAddr string) {\n\trecord.Type = recordType\n\trecord.Data = ipAddr\n\n\trecordId := strconv.FormatUint(uint64(record.ID), 10)\n\n\terr := dynv6.request(\"PATCH\", dynv6Endpoint+\"/api/v2/zones/\"+zoneId+\"/records/\"+recordId, record, &Dynv6Record{})\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// request 统一请求接口\nfunc (dynv6 *Dynv6) request(method string, url string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq.Header.Add(\"Authorization\", \"Bearer \"+dynv6.DNS.Secret)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := dynv6.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\treturn err\n}\n"
  },
  {
    "path": "dns/edgeone.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"golang.org/x/net/idna\"\n)\n\n// https://cloud.tencent.com/document/api/1552/80730\nconst (\n\tedgeoneEndPoint = \"https://teo.tencentcloudapi.com\"\n\tedgeoneVersion  = \"2022-09-01\"\n)\n\ntype EdgeOne struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\ntype EdgeOneRecord struct {\n\tZoneId   string `json:\"ZoneId\"`\n\tName     string `json:\"Name\"` // FullDomain\n\tType     string `json:\"Type\"` // record type, e.g. A AAAA\n\tContent  string `json:\"Content\"`\n\tLocation string `json:\"Location\"`\n\tTTL      int    `json:\"TTL\"`\n\tWeight   int    `json:\"Weight,omitempty\"`\n\tRecordId string `json:\"RecordId,omitempty\"`\n\tStatus   string `json:\"Status,omitempty\"`\n}\n\ntype EdgeOneRecordResponse struct {\n\tEdgeOneStatus\n\tResponse struct {\n\t\tDnsRecords []EdgeOneRecord `json:\"DnsRecords\"`\n\t\tTotalCount int             `json:\"TotalCount\"`\n\t}\n}\n\ntype EdgeOneZoneResponse struct {\n\tEdgeOneStatus\n\tResponse struct {\n\t\tTotalCount int `json:\"TotalCount\"`\n\t\tZones      []struct {\n\t\t\tZoneId   string `json:\"ZoneId\"`\n\t\t\tZoneName string `json:\"ZoneName\"`\n\t\t} `json:\"Zones\"`\n\t}\n}\n\ntype Filter struct {\n\tName   string   `json:\"Name\"`\n\tValues []string `json:\"Values\"`\n}\n\ntype EdgeOneDescribeDns struct {\n\tZoneId  string   `json:\"ZoneId,omitempty\"`\n\tFilters []Filter `json:\"Filters\"`\n}\n\n// https://cloud.tencent.com/document/product/1552/80729\ntype EdgeOneStatus struct {\n\tResponse struct {\n\t\tError struct {\n\t\t\tCode    string\n\t\t\tMessage string\n\t\t}\n\t}\n}\n\n// Init 初始化\nfunc (eo *EdgeOne) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\teo.Domains.Ipv4Cache = ipv4cache\n\teo.Domains.Ipv6Cache = ipv6cache\n\teo.DNS = dnsConf.DNS\n\teo.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认 600s\n\t\teo.TTL = 600\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\teo.TTL = 600\n\t\t} else {\n\t\t\teo.TTL = ttl\n\t\t}\n\t}\n\teo.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新 IPv4/IPv6 记录\nfunc (eo *EdgeOne) AddUpdateDomainRecords() config.Domains {\n\teo.addUpdateDomainRecords(\"A\")\n\teo.addUpdateDomainRecords(\"AAAA\")\n\treturn eo.Domains\n}\n\nfunc (eo *EdgeOne) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := eo.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tzoneResult, err := eo.getZone(domain.DomainName)\n\t\tif err != nil || zoneResult.Response.TotalCount <= 0 || zoneResult.Response.Zones[0].ZoneName != domain.DomainName {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\t\tzoneId := zoneResult.Response.Zones[0].ZoneId\n\t\trecordResult, err := eo.getRecordList(domain, recordType, zoneId)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tparams := domain.GetCustomParams()\n\t\tvar isValid func(*EdgeOneRecord) bool\n\t\tif params.Has(\"RecordId\") {\n\t\t\tisValid = func(r *EdgeOneRecord) bool { return r.RecordId == params.Get(\"RecordId\") }\n\t\t} else {\n\t\t\tisValid = func(r *EdgeOneRecord) bool {\n\t\t\t\treturn r.Status == \"enable\" || r.Status == \"disable\" && r.Content == ipAddr\n\t\t\t}\n\t\t}\n\t\tvar recordSelected *EdgeOneRecord\n\t\tfor i := range recordResult.Response.DnsRecords {\n\t\t\tr := &recordResult.Response.DnsRecords[i]\n\t\t\tif isValid(r) {\n\t\t\t\trecordSelected = r\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t\tif recordSelected != nil {\n\t\t\t// 修改记录\n\t\t\teo.modify(*recordSelected, domain, recordType, ipAddr, zoneId)\n\t\t} else {\n\t\t\t// 添加记录\n\t\t\teo.create(domain, recordType, ipAddr, zoneId)\n\t\t}\n\t}\n}\n\n// CreateDnsRecord https://cloud.tencent.com/document/product/1552/80720\nfunc (eo *EdgeOne) create(domain *config.Domain, recordType string, ipAddr string, ZoneId string) {\n\td := domain.DomainName\n\tif domain.SubDomain != \"\" && domain.SubDomain != \"@\" {\n\t\td = domain.SubDomain + \".\" + domain.DomainName\n\t}\n\tasciiDomain, _ := idna.ToASCII(d)\n\trecord := &EdgeOneRecord{\n\t\tZoneId:   ZoneId,\n\t\tName:     asciiDomain,\n\t\tType:     recordType,\n\t\tContent:  ipAddr,\n\t\tLocation: eo.getLocation(domain),\n\t\tTTL:      eo.TTL,\n\t}\n\tvar status EdgeOneStatus\n\terr := eo.request(\n\t\t\"CreateDnsRecord\",\n\t\trecord,\n\t\t&status,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Response.Error.Code == \"\" {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, status.Response.Error.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// ModifyDnsRecords https://cloud.tencent.com/document/product/1552/114252\nfunc (eo *EdgeOne) modify(record EdgeOneRecord, domain *config.Domain, recordType string, ipAddr string, ZoneId string) {\n\t// 相同不修改\n\tif record.Content == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\tvar status EdgeOneStatus\n\td := domain.DomainName\n\tif domain.SubDomain != \"\" && domain.SubDomain != \"@\" {\n\t\td = domain.SubDomain + \".\" + domain.DomainName\n\t}\n\tasciiDomain, _ := idna.ToASCII(d)\n\trecord.ZoneId = ZoneId\n\trecord.Name = asciiDomain\n\trecord.Type = recordType\n\trecord.Content = ipAddr\n\trecord.Location = eo.getLocation(domain)\n\trecord.TTL = eo.TTL\n\n\terr := eo.request(\n\t\t\"ModifyDnsRecords\",\n\t\tstruct {\n\t\t\tZoneId     string          `json:\"ZoneId\"`\n\t\t\tDnsRecords []EdgeOneRecord `json:\"DnsRecords\"`\n\t\t}{\n\t\t\tZoneId:     ZoneId,\n\t\t\tDnsRecords: []EdgeOneRecord{record},\n\t\t},\n\t\t&status,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Response.Error.Code == \"\" {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, status.Response.Error.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\nfunc (eo *EdgeOne) getZone(domain string) (result EdgeOneZoneResponse, err error) {\n\tasciiDomain, _ := idna.ToASCII(domain)\n\trecord := EdgeOneDescribeDns{\n\t\tFilters: []Filter{\n\t\t\t{Name: \"zone-name\", Values: []string{asciiDomain}},\n\t\t},\n\t}\n\terr = eo.request(\n\t\t\"DescribeZones\",\n\t\trecord,\n\t\t&result,\n\t)\n\treturn\n}\n\n// DescribeDnsRecords https://cloud.tencent.com/document/product/1552/80716\nfunc (eo *EdgeOne) getRecordList(domain *config.Domain, recordType string, ZoneId string) (result EdgeOneRecordResponse, err error) {\n\td := domain.DomainName\n\tif domain.SubDomain != \"\" && domain.SubDomain != \"@\" {\n\t\td = domain.SubDomain + \".\" + domain.DomainName\n\t}\n\tasciiDomain, _ := idna.ToASCII(d)\n\trecord := EdgeOneDescribeDns{\n\t\tZoneId: ZoneId,\n\t\tFilters: []Filter{\n\t\t\t{Name: \"name\", Values: []string{asciiDomain}},\n\t\t\t{Name: \"type\", Values: []string{recordType}},\n\t\t},\n\t}\n\n\terr = eo.request(\n\t\t\"DescribeDnsRecords\",\n\t\trecord,\n\t\t&result,\n\t)\n\n\treturn\n}\n\n// getLocation 获取记录线路，为空返回默认\nfunc (eo *EdgeOne) getLocation(domain *config.Domain) string {\n\tif domain.GetCustomParams().Has(\"Location\") {\n\t\treturn domain.GetCustomParams().Get(\"Location\")\n\t}\n\treturn \"Default\"\n}\n\n// request 统一请求接口\nfunc (eo *EdgeOne) request(action string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\treq, err := http.NewRequest(\n\t\t\"POST\",\n\t\tedgeoneEndPoint,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-TC-Version\", edgeoneVersion)\n\n\tutil.TencentCloudSigner(eo.DNS.ID, eo.DNS.Secret, req, action, string(jsonStr), util.EdgeOne)\n\n\tclient := eo.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/eranet.go",
    "content": "package dns\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// Eranet DNS实现\ntype Eranet struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\n\ntype EranetRecord struct {\n\tID     int `json:\"id\"`\n\tDomain string\n\tHost   string\n\tType   string\n\tValue  string\n\tState  int\n\t// Name    string\n\t// Enabled string\n}\n\ntype EranetRecordListResp struct {\n\tEranetBaseResult\n\tData []EranetRecord\n}\n\ntype EranetBaseResult struct {\n\tRequestId string `json:\"RequestId\"`\n\tId        int    `json:\"Id\"`\n\tError     string `json:\"error\"`\n}\n\n// Init 初始化\nfunc (eranet *Eranet) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\teranet.Domains.Ipv4Cache = ipv4cache\n\teranet.Domains.Ipv6Cache = ipv6cache\n\teranet.DNS = dnsConf.DNS\n\teranet.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\teranet.TTL = \"600\"\n\t} else {\n\t\teranet.TTL = dnsConf.TTL\n\t}\n\teranet.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (eranet *Eranet) AddUpdateDomainRecords() config.Domains {\n\teranet.addUpdateDomainRecords(\"A\")\n\teranet.addUpdateDomainRecords(\"AAAA\")\n\treturn eranet.Domains\n}\n\nfunc (eranet *Eranet) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := eranet.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tresult, err := eranet.getRecordList(domain, recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif len(result.Data) > 0 {\n\t\t\t// 默认第一个\n\t\t\trecordSelected := result.Data[0]\n\t\t\tparams := domain.GetCustomParams()\n\t\t\tif params.Has(\"Id\") {\n\t\t\t\tfor i := 0; i < len(result.Data); i++ {\n\t\t\t\t\tif strconv.Itoa(result.Data[i].ID) == params.Get(\"Id\") {\n\t\t\t\t\t\trecordSelected = result.Data[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 更新\n\t\t\teranet.modify(recordSelected, domain, recordType, ipAddr)\n\t\t} else {\n\t\t\t// 新增\n\t\t\teranet.create(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// create 创建DNS记录\nfunc (eranet *Eranet) create(domain *config.Domain, recordType string, ipAddr string) {\n\tparam := map[string]string{\n\t\t\"Domain\": domain.DomainName,\n\t\t\"Host\":   domain.GetSubDomain(),\n\t\t\"Type\":   recordType,\n\t\t\"Value\":  ipAddr,\n\t\t\"Ttl\":    eranet.TTL,\n\t}\n\tres, err := eranet.request(\"/api/Dns/AddDomainRecord\", param, \"GET\")\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tvar result NowcnBaseResult\n\terr = json.Unmarshal(res, &result)\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tif result.Error != \"\" {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, result.Error)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// modify 修改DNS记录\nfunc (eranet *Eranet) modify(record EranetRecord, domain *config.Domain, recordType string, ipAddr string) {\n\t// 相同不修改\n\tif record.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\tparam := map[string]string{\n\t\t\"Id\":     strconv.Itoa(record.ID),\n\t\t\"Domain\": domain.DomainName,\n\t\t\"Host\":   domain.GetSubDomain(),\n\t\t\"Type\":   recordType,\n\t\t\"Value\":  ipAddr,\n\t\t\"Ttl\":    eranet.TTL,\n\t}\n\tres, err := eranet.request(\"/api/Dns/UpdateDomainRecord\", param, \"GET\")\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tvar result NowcnBaseResult\n\terr = json.Unmarshal(res, &result)\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tif result.Error != \"\" {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, result.Error)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// getRecordList 获取域名记录列表\nfunc (eranet *Eranet) getRecordList(domain *config.Domain, typ string) (result EranetRecordListResp, err error) {\n\tparam := map[string]string{\n\t\t\"Domain\": domain.DomainName,\n\t\t\"Type\":   typ,\n\t\t\"Host\":   domain.GetSubDomain(),\n\t}\n\tres, err := eranet.request(\"/api/Dns/DescribeRecordIndex\", param, \"GET\")\n\terr = json.Unmarshal(res, &result)\n\treturn\n}\n\nfunc (eranet *Eranet) queryParams(param map[string]any) string {\n\tvar queryParams []string\n\tfor key, value := range param {\n\t\t// 只对键进行URL编码，值保持原样（特别是@符号）\n\t\tencodedKey := url.QueryEscape(key)\n\t\tvalueStr := fmt.Sprintf(\"%v\", value)\n\t\t// 对值进行选择性编码，保留@符号\n\t\tencodedValue := strings.ReplaceAll(url.QueryEscape(valueStr), \"%40\", \"@\")\n\t\tencodedValue = strings.ReplaceAll(encodedValue, \"%3A\", \":\")\n\t\tqueryParams = append(queryParams, encodedKey+\"=\"+encodedValue)\n\t}\n\treturn strings.Join(queryParams, \"&\")\n}\n\nfunc (t *Eranet) sign(params map[string]string, method string) (string, error) {\n\t// 添加公共参数\n\tparams[\"AccessInstanceID\"] = t.DNS.ID\n\tparams[\"SignatureMethod\"] = \"HMAC-SHA1\"\n\tparams[\"SignatureNonce\"] = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\tparams[\"Timestamp\"] = time.Now().UTC().Format(\"2006-01-02T15:04:05Z\")\n\n\t// 1. 排序参数(按首字母顺序)\n\tvar keys []string\n\tfor k := range params {\n\t\tif k != \"Signature\" { // 排除Signature参数\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t}\n\tsort.Strings(keys)\n\n\t// 2. 构造规范化请求字符串\n\tvar canonicalizedQuery []string\n\tfor _, k := range keys {\n\t\t// URL编码参数名和参数值\n\t\tencodedKey := util.PercentEncode(k)\n\t\tencodedValue := util.PercentEncode(params[k])\n\t\tcanonicalizedQuery = append(canonicalizedQuery, encodedKey+\"=\"+encodedValue)\n\t}\n\tcanonicalizedQueryString := strings.Join(canonicalizedQuery, \"&\")\n\n\t// 3. 构造待签名字符串\n\tstringToSign := method + \"&\" + util.PercentEncode(\"/\") + \"&\" + util.PercentEncode(canonicalizedQueryString)\n\n\t// 4. 计算HMAC-SHA1签名\n\tkey := t.DNS.Secret + \"&\"\n\th := hmac.New(sha1.New, []byte(key))\n\th.Write([]byte(stringToSign))\n\tsignature := base64.StdEncoding.EncodeToString(h.Sum(nil))\n\n\t// 5. 添加签名到参数中\n\tparams[\"Signature\"] = signature\n\n\t// 6. 重新构造最终的查询字符串(包含签名)\n\tkeys = append(keys, \"Signature\")\n\tsort.Strings(keys)\n\tvar finalQuery []string\n\tfor _, k := range keys {\n\t\tencodedKey := util.PercentEncode(k)\n\t\tencodedValue := util.PercentEncode(params[k])\n\t\tfinalQuery = append(finalQuery, encodedKey+\"=\"+encodedValue)\n\t}\n\n\treturn strings.Join(finalQuery, \"&\"), nil\n}\n\nfunc (t *Eranet) request(apiPath string, params map[string]string, method string) ([]byte, error) {\n\t// 生成签名\n\tqueryString, err := t.sign(params, method)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"生成签名失败: %v\", err)\n\t}\n\n\t// 构造完整URL\n\tbaseURL := \"https://www.eranet.com\"\n\tfullURL := baseURL + apiPath + \"?\" + queryString\n\n\t// 创建HTTP请求\n\treq, err := http.NewRequest(method, fullURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %v\", err)\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\t// 发送请求\n\tclient := t.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 读取响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"读取响应失败: %v\", err)\n\t}\n\n\t// 检查HTTP状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"API请求失败，状态码: %d, 响应: %s\", resp.StatusCode, string(body))\n\t}\n\n\treturn body, nil\n}\n"
  },
  {
    "path": "dns/gcore.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst gcoreAPIEndpoint = \"https://api.gcore.com/dns/v2\"\n\n// Gcore Gcore DNS实现\ntype Gcore struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// GcoreZoneResponse zones返回结果\ntype GcoreZoneResponse struct {\n\tZones       []GcoreZone `json:\"zones\"`\n\tTotalAmount int         `json:\"total_amount\"`\n}\n\n// GcoreZone 域名信息\ntype GcoreZone struct {\n\tID   int    `json:\"id\"`\n\tName string `json:\"name\"`\n}\n\n// GcoreRRSetListResponse RRSet列表返回结果\ntype GcoreRRSetListResponse struct {\n\tRRSets      []GcoreRRSet `json:\"rrsets\"`\n\tTotalAmount int          `json:\"total_amount\"`\n}\n\n// GcoreRRSet RRSet记录实体\ntype GcoreRRSet struct {\n\tName            string                 `json:\"name\"`\n\tType            string                 `json:\"type\"`\n\tTTL             int                    `json:\"ttl\"`\n\tResourceRecords []GcoreResourceRecord  `json:\"resource_records\"`\n\tMeta            map[string]interface{} `json:\"meta,omitempty\"`\n}\n\n// GcoreResourceRecord 资源记录\ntype GcoreResourceRecord struct {\n\tContent []interface{}          `json:\"content\"`\n\tEnabled bool                   `json:\"enabled\"`\n\tID      int                    `json:\"id,omitempty\"`\n\tMeta    map[string]interface{} `json:\"meta,omitempty\"`\n}\n\n// GcoreInputRRSet 输入的RRSet\ntype GcoreInputRRSet struct {\n\tTTL             int                        `json:\"ttl\"`\n\tResourceRecords []GcoreInputResourceRecord `json:\"resource_records\"`\n\tMeta            map[string]interface{}     `json:\"meta,omitempty\"`\n}\n\n// GcoreInputResourceRecord 输入的资源记录\ntype GcoreInputResourceRecord struct {\n\tContent []interface{}          `json:\"content\"`\n\tEnabled bool                   `json:\"enabled\"`\n\tMeta    map[string]interface{} `json:\"meta,omitempty\"`\n}\n\n// Init 初始化\nfunc (gc *Gcore) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tgc.Domains.Ipv4Cache = ipv4cache\n\tgc.Domains.Ipv6Cache = ipv6cache\n\tgc.DNS = dnsConf.DNS\n\tgc.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认 120 秒（免费版最低值）\n\t\tgc.TTL = 120\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\tgc.TTL = 120\n\t\t} else {\n\t\t\tgc.TTL = ttl\n\t\t}\n\t}\n\tgc.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新 IPv4 / IPv6 记录\nfunc (gc *Gcore) AddUpdateDomainRecords() config.Domains {\n\tgc.addUpdateDomainRecords(\"A\")\n\tgc.addUpdateDomainRecords(\"AAAA\")\n\treturn gc.Domains\n}\n\nfunc (gc *Gcore) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := gc.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\t// get zone\n\t\tzoneInfo, err := gc.getZoneByDomain(domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\tif zoneInfo == nil {\n\t\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain.DomainName)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\t// 查询现有记录\n\t\texistingRecord, err := gc.getRRSet(zoneInfo.Name, domain.GetSubDomain(), recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\tif existingRecord != nil {\n\t\t\t// 更新现有记录\n\t\t\tgc.updateRecord(zoneInfo.Name, domain, recordType, ipAddr, existingRecord)\n\t\t} else {\n\t\t\t// 创建新记录\n\t\t\tgc.createRecord(zoneInfo.Name, domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// 获取域名对应的Zone信息\nfunc (gc *Gcore) getZoneByDomain(domain *config.Domain) (*GcoreZone, error) {\n\tvar result GcoreZoneResponse\n\tparams := url.Values{}\n\tparams.Set(\"name\", domain.DomainName)\n\n\terr := gc.request(\n\t\t\"GET\",\n\t\tfmt.Sprintf(\"%s/zones?%s\", gcoreAPIEndpoint, params.Encode()),\n\t\tnil,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif len(result.Zones) > 0 {\n\t\treturn &result.Zones[0], nil\n\t}\n\n\treturn nil, nil\n}\n\n// 获取指定的RRSet记录\nfunc (gc *Gcore) getRRSet(zoneName, recordName, recordType string) (*GcoreRRSet, error) {\n\tvar result GcoreRRSetListResponse\n\n\terr := gc.request(\n\t\t\"GET\",\n\t\tfmt.Sprintf(\"%s/zones/%s/rrsets\", gcoreAPIEndpoint, zoneName),\n\t\tnil,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 查找匹配的记录\n\tfullRecordName := recordName\n\tif recordName != \"\" && recordName != \"@\" {\n\t\tfullRecordName = recordName + \".\" + zoneName\n\t} else {\n\t\tfullRecordName = zoneName\n\t}\n\n\tfor _, rrset := range result.RRSets {\n\t\tif rrset.Name == fullRecordName && rrset.Type == recordType {\n\t\t\treturn &rrset, nil\n\t\t}\n\t}\n\n\treturn nil, nil\n}\n\n// 创建新记录\nfunc (gc *Gcore) createRecord(zoneName string, domain *config.Domain, recordType string, ipAddr string) {\n\trecordName := domain.GetSubDomain()\n\tif recordName == \"\" || recordName == \"@\" {\n\t\trecordName = zoneName\n\t} else {\n\t\trecordName = recordName + \".\" + zoneName\n\t}\n\n\tinputRRSet := GcoreInputRRSet{\n\t\tTTL: gc.TTL,\n\t\tResourceRecords: []GcoreInputResourceRecord{\n\t\t\t{\n\t\t\t\tContent: []interface{}{ipAddr},\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t},\n\t}\n\n\tvar result interface{}\n\terr := gc.request(\n\t\t\"POST\",\n\t\tfmt.Sprintf(\"%s/zones/%s/%s/%s\", gcoreAPIEndpoint, zoneName, recordName, recordType),\n\t\tinputRRSet,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomain.UpdateStatus = config.UpdatedSuccess\n}\n\n// 更新现有记录\nfunc (gc *Gcore) updateRecord(zoneName string, domain *config.Domain, recordType string, ipAddr string, existingRecord *GcoreRRSet) {\n\t// 检查IP是否相同\n\tif len(existingRecord.ResourceRecords) > 0 && len(existingRecord.ResourceRecords[0].Content) > 0 {\n\t\tif existingRecord.ResourceRecords[0].Content[0] == ipAddr {\n\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\treturn\n\t\t}\n\t}\n\n\trecordName := domain.GetSubDomain()\n\tif recordName == \"\" || recordName == \"@\" {\n\t\trecordName = zoneName\n\t} else {\n\t\trecordName = recordName + \".\" + zoneName\n\t}\n\n\tinputRRSet := GcoreInputRRSet{\n\t\tTTL: gc.TTL,\n\t\tResourceRecords: []GcoreInputResourceRecord{\n\t\t\t{\n\t\t\t\tContent: []interface{}{ipAddr},\n\t\t\t\tEnabled: true,\n\t\t\t},\n\t\t},\n\t}\n\n\tvar result interface{}\n\terr := gc.request(\n\t\t\"PUT\",\n\t\tfmt.Sprintf(\"%s/zones/%s/%s/%s\", gcoreAPIEndpoint, zoneName, recordName, recordType),\n\t\tinputRRSet,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomain.UpdateStatus = config.UpdatedSuccess\n}\n\n// request 统一请求接口\nfunc (gc *Gcore) request(method string, url string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Authorization\", \"APIKey \"+gc.DNS.Secret)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := gc.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/godaddy.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\ntype godaddyRecord struct {\n\tData string `json:\"data\"`\n\tName string `json:\"name\"`\n\tTTL  int    `json:\"ttl\"`\n\tType string `json:\"type\"`\n}\n\ntype godaddyRecords []godaddyRecord\n\ntype GoDaddyDNS struct {\n\tdns      config.DNS\n\tdomains  config.Domains\n\tttl      int\n\theader   http.Header\n\tclient   *http.Client\n\tlastIpv4 string\n\tlastIpv6 string\n}\n\nfunc (g *GoDaddyDNS) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tg.domains.Ipv4Cache = ipv4cache\n\tg.domains.Ipv6Cache = ipv6cache\n\tg.lastIpv4 = ipv4cache.Addr\n\tg.lastIpv6 = ipv6cache.Addr\n\n\tg.dns = dnsConf.DNS\n\tg.domains.GetNewIp(dnsConf)\n\tg.ttl = 600\n\tif val, err := strconv.Atoi(dnsConf.TTL); err == nil {\n\t\tg.ttl = val\n\t}\n\tg.header = map[string][]string{\n\t\t\"Authorization\": {fmt.Sprintf(\"sso-key %s:%s\", g.dns.ID, g.dns.Secret)},\n\t\t\"Content-Type\":  {\"application/json\"},\n\t}\n\n\tg.client = dnsConf.GetHTTPClient()\n}\n\nfunc (g *GoDaddyDNS) updateDomainRecord(recordType string, ipAddr string, domains []*config.Domain) {\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\t// 防止多次发送Webhook通知\n\tif recordType == \"A\" {\n\t\tif g.lastIpv4 == ipAddr {\n\t\t\tutil.Log(\"你的IPv4未变化, 未触发 %s 请求\", \"godaddy\")\n\t\t\treturn\n\t\t}\n\t} else {\n\t\tif g.lastIpv6 == ipAddr {\n\t\t\tutil.Log(\"你的IPv6未变化, 未触发 %s 请求\", \"godaddy\")\n\t\t\treturn\n\t\t}\n\t}\n\n\tfor _, domain := range domains {\n\t\terr := g.sendReq(http.MethodPut, recordType, domain, &godaddyRecords{godaddyRecord{\n\t\t\tData: ipAddr,\n\t\t\tName: domain.GetSubDomain(),\n\t\t\tTTL:  g.ttl,\n\t\t\tType: recordType,\n\t\t}})\n\t\tif err == nil {\n\t\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t\t} else {\n\t\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t}\n\t}\n}\n\nfunc (g *GoDaddyDNS) AddUpdateDomainRecords() config.Domains {\n\tif ipv4Addr, ipv4Domains := g.domains.GetNewIpResult(\"A\"); ipv4Addr != \"\" {\n\t\tg.updateDomainRecord(\"A\", ipv4Addr, ipv4Domains)\n\t}\n\tif ipv6Addr, ipv6Domains := g.domains.GetNewIpResult(\"AAAA\"); ipv6Addr != \"\" {\n\t\tg.updateDomainRecord(\"AAAA\", ipv6Addr, ipv6Domains)\n\t}\n\treturn g.domains\n}\n\nfunc (g *GoDaddyDNS) sendReq(method string, rType string, domain *config.Domain, data *godaddyRecords) error {\n\n\tvar body *bytes.Buffer\n\tif data != nil {\n\t\tif buffer, err := json.Marshal(data); err != nil {\n\t\t\treturn err\n\t\t} else {\n\t\t\tbody = bytes.NewBuffer(buffer)\n\t\t}\n\t}\n\tpath := fmt.Sprintf(\"https://api.godaddy.com/v1/domains/%s/records/%s/%s\",\n\t\tdomain.DomainName, rType, domain.GetSubDomain())\n\n\treq, err := http.NewRequest(method, path, body)\n\tif err != nil {\n\t\treturn err\n\t}\n\treq.Header = g.header\n\tresp, err := g.client.Do(req)\n\t_, err = util.GetHTTPResponseOrg(resp, err)\n\treturn err\n}\n"
  },
  {
    "path": "dns/huawei.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\thuaweicloudEndpoint string = \"https://dns.myhuaweicloud.com\"\n)\n\n// https://support.huaweicloud.com/api-dns/dns_api_64001.html\n// Huaweicloud Huaweicloud\ntype Huaweicloud struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// HuaweicloudZonesResp zones response\ntype HuaweicloudZonesResp struct {\n\tZones []struct {\n\t\tID         string\n\t\tName       string\n\t\tRecordsets []HuaweicloudRecordsets\n\t}\n}\n\n// HuaweicloudRecordsResp 记录返回结果\ntype HuaweicloudRecordsResp struct {\n\tRecordsets []HuaweicloudRecordsets\n}\n\n// HuaweicloudRecordsets 记录\ntype HuaweicloudRecordsets struct {\n\tID      string\n\tName    string `json:\"name\"`\n\tZoneID  string `json:\"zone_id\"`\n\tStatus  string\n\tType    string   `json:\"type\"`\n\tTTL     int      `json:\"ttl\"`\n\tRecords []string `json:\"records\"`\n\tWeight  int      `json:\"weight\"`\n}\n\n// Init 初始化\nfunc (hw *Huaweicloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\thw.Domains.Ipv4Cache = ipv4cache\n\thw.Domains.Ipv6Cache = ipv6cache\n\thw.DNS = dnsConf.DNS\n\thw.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认300s\n\t\thw.TTL = 300\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\thw.TTL = 300\n\t\t} else {\n\t\t\thw.TTL = ttl\n\t\t}\n\t}\n\thw.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (hw *Huaweicloud) AddUpdateDomainRecords() config.Domains {\n\thw.addUpdateDomainRecords(\"A\")\n\thw.addUpdateDomainRecords(\"AAAA\")\n\treturn hw.Domains\n}\n\nfunc (hw *Huaweicloud) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := hw.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tcustomParams := domain.GetCustomParams()\n\t\tparams := url.Values{}\n\t\tparams.Set(\"name\", domain.String())\n\t\tparams.Set(\"type\", recordType)\n\n\t\t// 如果有精准匹配\n\t\t// 详见 查询记录集 https://support.huaweicloud.com/api-dns/dns_api_64002.html\n\t\tif customParams.Has(\"zone_id\") && customParams.Has(\"recordset_id\") {\n\t\t\tvar record HuaweicloudRecordsets\n\t\t\terr := hw.request(\n\t\t\t\t\"GET\",\n\t\t\t\tfmt.Sprintf(huaweicloudEndpoint+\"/v2.1/zones/%s/recordsets/%s\", customParams.Get(\"zone_id\"), customParams.Get(\"recordset_id\")),\n\t\t\t\tparams,\n\t\t\t\t&record,\n\t\t\t)\n\n\t\t\tif err != nil {\n\t\t\t\tutil.Log(\"查询域名信息发生异常！ %s\", err)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\t// 更新\n\t\t\thw.modify(record, domain, ipAddr)\n\n\t\t} else { // 没有精准匹配，则支持更多的查询参数。详见 查询租户记录集列表 https://support.huaweicloud.com/api-dns/dns_api_64003.html\n\t\t\t// 复制所有自定义参数\n\t\t\tutil.CopyUrlParams(customParams, params, nil)\n\t\t\t// 参数名修正\n\t\t\tif params.Has(\"recordset_id\") {\n\t\t\t\tparams.Set(\"id\", params.Get(\"recordset_id\"))\n\t\t\t\tparams.Del(\"recordset_id\")\n\t\t\t}\n\n\t\t\tvar records HuaweicloudRecordsResp\n\t\t\terr := hw.request(\n\t\t\t\t\"GET\",\n\t\t\t\thuaweicloudEndpoint+\"/v2.1/recordsets\",\n\t\t\t\tparams,\n\t\t\t\t&records,\n\t\t\t)\n\n\t\t\tif err != nil {\n\t\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfind := false\n\t\t\tfor _, record := range records.Recordsets {\n\t\t\t\t// 名称相同才更新。华为云默认是模糊搜索\n\t\t\t\tif record.Name == domain.String()+\".\" {\n\t\t\t\t\t// 更新\n\t\t\t\t\thw.modify(record, domain, ipAddr)\n\t\t\t\t\tfind = true\n\t\t\t\t\tbreak\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif !find {\n\t\t\t\tthIdParamName := \"\"\n\t\t\t\tif customParams.Has(\"id\") {\n\t\t\t\t\tthIdParamName = \"id\"\n\t\t\t\t} else if customParams.Has(\"recordset_id\") {\n\t\t\t\t\tthIdParamName = \"recordset_id\"\n\t\t\t\t}\n\n\t\t\t\tif thIdParamName != \"\" {\n\t\t\t\t\tutil.Log(\"域名 %s 解析未找到，且因添加了参数 %s=%s 导致无法创建。本次更新已被忽略\", domain, thIdParamName, customParams.Get(thIdParamName))\n\t\t\t\t} else {\n\t\t\t\t\t// 新增\n\t\t\t\t\thw.create(domain, recordType, ipAddr)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n\n// 创建\nfunc (hw *Huaweicloud) create(domain *config.Domain, recordType string, ipAddr string) {\n\tzone, err := hw.getZones(domain)\n\tif err != nil {\n\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif len(zone.Zones) == 0 {\n\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain.DomainName)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tzoneID := zone.Zones[0].ID\n\tfor _, z := range zone.Zones {\n\t\tif z.Name == domain.DomainName+\".\" {\n\t\t\tzoneID = z.ID\n\t\t\tbreak\n\t\t}\n\t}\n\n\trecord := &HuaweicloudRecordsets{\n\t\tType:    recordType,\n\t\tName:    domain.String() + \".\",\n\t\tRecords: []string{ipAddr},\n\t\tTTL:     hw.TTL,\n\t\tWeight:  1,\n\t}\n\tvar result HuaweicloudRecordsets\n\terr = hw.request(\n\t\t\"POST\",\n\t\tfmt.Sprintf(huaweicloudEndpoint+\"/v2.1/zones/%s/recordsets\", zoneID),\n\t\trecord,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif len(result.Records) > 0 && result.Records[0] == ipAddr {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, result.Status)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 修改\nfunc (hw *Huaweicloud) modify(record HuaweicloudRecordsets, domain *config.Domain, ipAddr string) {\n\n\t// 相同不修改\n\tif len(record.Records) > 0 && record.Records[0] == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\tvar request = make(map[string]interface{})\n\trequest[\"name\"] = record.Name\n\trequest[\"type\"] = record.Type\n\trequest[\"records\"] = []string{ipAddr}\n\trequest[\"ttl\"] = hw.TTL\n\n\tvar result HuaweicloudRecordsets\n\n\terr := hw.request(\n\t\t\"PUT\",\n\t\tfmt.Sprintf(huaweicloudEndpoint+\"/v2.1/zones/%s/recordsets/%s\", record.ZoneID, record.ID),\n\t\t&request,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif len(result.Records) > 0 && result.Records[0] == ipAddr {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, result.Status)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 获得域名记录列表\nfunc (hw *Huaweicloud) getZones(domain *config.Domain) (result HuaweicloudZonesResp, err error) {\n\terr = hw.request(\n\t\t\"GET\",\n\t\thuaweicloudEndpoint+\"/v2/zones\",\n\t\turl.Values{\"name\": []string{domain.DomainName}},\n\t\t&result,\n\t)\n\n\treturn\n}\n\n// request 统一请求接口\nfunc (hw *Huaweicloud) request(method string, urlString string, data interface{}, result interface{}) (err error) {\n\tvar (\n\t\treq *http.Request\n\t)\n\n\tif method == \"GET\" {\n\t\treq, err = http.NewRequest(\n\t\t\tmethod,\n\t\t\turlString,\n\t\t\tbytes.NewBuffer(nil),\n\t\t)\n\n\t\treq.URL.RawQuery = data.(url.Values).Encode()\n\t} else {\n\t\tjsonStr := make([]byte, 0)\n\t\tif data != nil {\n\t\t\tjsonStr, _ = json.Marshal(data)\n\t\t}\n\n\t\treq, err = http.NewRequest(\n\t\t\tmethod,\n\t\t\turlString,\n\t\t\tbytes.NewBuffer(jsonStr),\n\t\t)\n\t}\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\ts := util.Signer{\n\t\tKey:    hw.DNS.ID,\n\t\tSecret: hw.DNS.Secret,\n\t}\n\ts.Sign(req)\n\n\treq.Header.Add(\"content-type\", \"application/json\")\n\n\tclient := hw.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/index.go",
    "content": "package dns\n\nimport (\n\t\"time\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// DNS interface\ntype DNS interface {\n\tInit(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache)\n\t// 添加或更新IPv4/IPv6记录\n\tAddUpdateDomainRecords() (domains config.Domains)\n}\n\nvar (\n\tAddresses = []string{\n\t\talidnsEndpoint,\n\t\taliesaEndpoint,\n\t\tbaiduEndpoint,\n\t\tzonesAPI,\n\t\trecordListAPI,\n\t\thuaweicloudEndpoint,\n\t\tnameCheapEndpoint,\n\t\tnameSiloListRecordEndpoint,\n\t\tporkbunEndpoint,\n\t\ttencentCloudEndPoint,\n\t\tdynadotEndpoint,\n\t\tdynv6Endpoint,\n\t\tgcoreAPIEndpoint,\n\t\tedgeoneEndPoint,\n\t\trainyunEndpoint,\n\t}\n\n\tIpcache = [][2]util.IpCache{}\n)\n\n// RunTimer 定时运行\nfunc RunTimer(delay time.Duration) {\n\tfor {\n\t\tRunOnce()\n\t\ttime.Sleep(delay)\n\t}\n}\n\n// RunOnce RunOnce\nfunc RunOnce() {\n\tconf, err := config.GetConfigCached()\n\tif err != nil {\n\t\treturn\n\t}\n\tif util.ForceCompareGlobal || len(Ipcache) != len(conf.DnsConf) {\n\t\tIpcache = [][2]util.IpCache{}\n\t\tfor range conf.DnsConf {\n\t\t\tIpcache = append(Ipcache, [2]util.IpCache{{}, {}})\n\t\t}\n\t}\n\n\tfor i, dc := range conf.DnsConf {\n\t\tvar dnsSelected DNS\n\t\tswitch dc.DNS.Name {\n\t\tcase \"alidns\":\n\t\t\tdnsSelected = &Alidns{}\n\t\tcase \"aliesa\":\n\t\t\tdnsSelected = &Aliesa{}\n\t\tcase \"tencentcloud\":\n\t\t\tdnsSelected = &TencentCloud{}\n\t\tcase \"trafficroute\":\n\t\t\tdnsSelected = &TrafficRoute{}\n\t\tcase \"dnspod\":\n\t\t\tdnsSelected = &Dnspod{}\n\t\tcase \"dnsla\":\n\t\t\tdnsSelected = &Dnsla{}\n\t\tcase \"cloudflare\":\n\t\t\tdnsSelected = &Cloudflare{}\n\t\tcase \"huaweicloud\":\n\t\t\tdnsSelected = &Huaweicloud{}\n\t\tcase \"callback\":\n\t\t\tdnsSelected = &Callback{}\n\t\tcase \"baiducloud\":\n\t\t\tdnsSelected = &BaiduCloud{}\n\t\tcase \"porkbun\":\n\t\t\tdnsSelected = &Porkbun{}\n\t\tcase \"godaddy\":\n\t\t\tdnsSelected = &GoDaddyDNS{}\n\t\tcase \"namecheap\":\n\t\t\tdnsSelected = &NameCheap{}\n\t\tcase \"namesilo\":\n\t\t\tdnsSelected = &NameSilo{}\n\t\tcase \"vercel\":\n\t\t\tdnsSelected = &Vercel{}\n\t\tcase \"dynadot\":\n\t\t\tdnsSelected = &Dynadot{}\n\t\tcase \"dynv6\":\n\t\t\tdnsSelected = &Dynv6{}\n\t\tcase \"spaceship\":\n\t\t\tdnsSelected = &Spaceship{}\n\t\tcase \"nowcn\":\n\t\t\tdnsSelected = &Nowcn{}\n\t\tcase \"eranet\":\n\t\t\tdnsSelected = &Eranet{}\n\t\tcase \"gcore\":\n\t\t\tdnsSelected = &Gcore{}\n\t\tcase \"edgeone\":\n\t\t\tdnsSelected = &EdgeOne{}\n\t\tcase \"nsone\":\n\t\t\tdnsSelected = &NSOne{}\n\t\tcase \"name_com\":\n\t\t\tdnsSelected = &NameCom{}\n\t\tcase \"rainyun\":\n\t\t\tdnsSelected = &Rainyun{}\n\t\tdefault:\n\t\t\tdnsSelected = &Alidns{}\n\t\t}\n\t\tdnsSelected.Init(&dc, &Ipcache[i][0], &Ipcache[i][1])\n\t\tdomains := dnsSelected.AddUpdateDomainRecords()\n\t\t// webhook\n\t\tv4Status, v6Status := config.ExecWebhook(&domains, &conf)\n\t\t// 重置单个cache\n\t\tif v4Status == config.UpdatedFailed {\n\t\t\tIpcache[i][0] = util.IpCache{}\n\t\t}\n\t\tif v6Status == config.UpdatedFailed {\n\t\t\tIpcache[i][1] = util.IpCache{}\n\t\t}\n\t}\n\n\tutil.ForceCompareGlobal = false\n}\n"
  },
  {
    "path": "dns/name_com.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\tlistRecords  = \"https://api.name.com/core/v1/domains/%s/records\"\n\tcreateRecord = \"https://api.name.com/core/v1/domains/%s/records\"\n\tupdateRecord = \"https://api.name.com/core/v1/domains/%s/records/%d\"\n)\n\ntype NameCom struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\n\ntype NameComRecord struct {\n\tTTL    int    `json:\"ttl\"`\n\tType   string `json:\"type\"`\n\tAnswer string `json:\"answer\"`\n\tHost   string `json:\"host\"`\n}\n\ntype NameComRecordResp struct {\n\tTTL        int    `json:\"ttl\"`\n\tType       string `json:\"type\"`\n\tAnswer     string `json:\"answer\"`\n\tDomainName string `json:\"domainName\"`\n\tFqdn       string `json:\"fqdn\"`\n\tHost       string `json:\"host\"`\n\tId         int    `json:\"id\"`\n\tPriority   int    `json:\"priority\"`\n}\n\ntype NameComRecordListResp struct {\n\tTotalCount int                 `json:\"totalCount\"`\n\tFrom       int                 `json:\"from\"`\n\tTo         int                 `json:\"to\"`\n\tRecords    []NameComRecordResp `json:\"records\"`\n\tLastPage   int                 `json:\"lastPage\"`\n\tNextPage   int                 `json:\"nextPage\"`\n}\n\nfunc (n *NameCom) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tn.Domains.Ipv4Cache = ipv4cache\n\tn.Domains.Ipv6Cache = ipv6cache\n\tn.DNS = dnsConf.DNS\n\tn.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\tn.TTL = \"300\"\n\t} else {\n\t\tn.TTL = dnsConf.TTL\n\t}\n\tn.httpClient = dnsConf.GetHTTPClient()\n}\n\nfunc (n *NameCom) AddUpdateDomainRecords() (domains config.Domains) {\n\tn.addUpdateDomainRecords(\"A\")\n\tn.addUpdateDomainRecords(\"AAAA\")\n\tdomains = n.Domains\n\treturn\n}\n\nfunc (n *NameCom) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := n.Domains.GetNewIpResult(recordType)\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tresp, err := n.getRecordList(domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\t\tresp4TypeRecords := make([]NameComRecordResp, 0, resp.TotalCount)\n\t\tif resp.TotalCount > 0 {\n\t\t\tfor _, r := range resp.Records {\n\t\t\t\tif r.Type == recordType && r.Host == domain.SubDomain {\n\t\t\t\t\tresp4TypeRecords = append(resp4TypeRecords, r)\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif len(resp4TypeRecords) > 0 {\n\t\t\tfor _, r := range resp4TypeRecords {\n\t\t\t\terr := n.update(r, domain, ipAddr, recordType)\n\t\t\t\tif err != nil {\n\t\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\t\treturn\n\t\t\t\t}\n\t\t\t}\n\t\t} else {\n\t\t\t_, err := n.create(domain, recordType, ipAddr)\n\t\t\tif err != nil {\n\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc (n *NameCom) getRecordList(domain *config.Domain) (resp *NameComRecordListResp, err error) {\n\turl := fmt.Sprintf(listRecords, domain.DomainName)\n\terr = n.request(\"GET\", url, nil, &resp)\n\treturn\n}\n\nfunc (n *NameCom) create(domain *config.Domain, recordType string, ipAddr string) (resp *NameComRecord, err error) {\n\ti, err := strconv.Atoi(n.TTL)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tresq := &NameComRecord{\n\t\tTTL:    i,\n\t\tAnswer: ipAddr,\n\t\tHost:   domain.SubDomain,\n\t\tType:   recordType,\n\t}\n\turl := fmt.Sprintf(createRecord, domain.DomainName)\n\terr = n.request(\"POST\", url, resq, resp)\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\treturn\n\t}\n\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\treturn\n}\n\nfunc (n *NameCom) update(record NameComRecordResp, domain *config.Domain, ipAddr, recordType string) (err error) {\n\tif record.Answer == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\trecord.Answer = ipAddr\n\trecord.Type = recordType\n\turl := fmt.Sprintf(updateRecord, domain.DomainName, record.Id)\n\terr = n.request(\"PUT\", url, record, nil)\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\treturn\n\t}\n\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\treturn\n}\n\nfunc (n *NameCom) request(action string, url string, data any, result any) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, err = json.Marshal(data)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t}\n\treq, err := http.NewRequest(\n\t\taction,\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Authorization\", \"Basic \"+base64.StdEncoding.EncodeToString([]byte(n.DNS.ID+\":\"+n.DNS.Secret)))\n\tif strings.EqualFold(action, \"POST\") || strings.EqualFold(action, \"PUT\") {\n\t\treq.Header.Add(\"Content-Type\", \"application/json\")\n\t}\n\n\tclient := n.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/namecheap.go",
    "content": "package dns\n\nimport (\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\tnameCheapEndpoint string = \"https://dynamicdns.park-your-domain.com/update?host=#{host}&domain=#{domain}&password=#{password}&ip=#{ip}\"\n)\n\n// NameCheap Domain\ntype NameCheap struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tlastIpv4   string\n\tlastIpv6   string\n\thttpClient *http.Client\n}\n\n// NameCheap 修改域名解析结果\ntype NameCheapResp struct {\n\tStatus string\n\tErrors []string\n}\n\n// Init 初始化\nfunc (nc *NameCheap) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tnc.Domains.Ipv4Cache = ipv4cache\n\tnc.Domains.Ipv6Cache = ipv6cache\n\tnc.lastIpv4 = ipv4cache.Addr\n\tnc.lastIpv6 = ipv6cache.Addr\n\n\tnc.DNS = dnsConf.DNS\n\tnc.Domains.GetNewIp(dnsConf)\n\tnc.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (nc *NameCheap) AddUpdateDomainRecords() config.Domains {\n\tnc.addUpdateDomainRecords(\"A\")\n\tnc.addUpdateDomainRecords(\"AAAA\")\n\treturn nc.Domains\n}\n\nfunc (nc *NameCheap) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := nc.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\t// 防止多次发送Webhook通知\n\tif recordType == \"A\" {\n\t\tif nc.lastIpv4 == ipAddr {\n\t\t\tutil.Log(\"你的IPv4未变化, 未触发 %s 请求\", \"NameCheap\")\n\t\t\treturn\n\t\t}\n\t} else {\n\t\t// https://www.namecheap.com/support/knowledgebase/article.aspx/29/11/how-to-dynamically-update-the-hosts-ip-with-an-http-request/\n\t\tutil.Log(\"Namecheap 不支持更新 IPv6\")\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tnc.modify(domain, ipAddr)\n\t}\n}\n\n// 修改\nfunc (nc *NameCheap) modify(domain *config.Domain, ipAddr string) {\n\tvar result NameCheapResp\n\terr := nc.request(&result, ipAddr, domain)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tswitch result.Status {\n\tcase \"Success\":\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\tdefault:\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, result.Status)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// request 统一请求接口\nfunc (nc *NameCheap) request(result *NameCheapResp, ipAddr string, domain *config.Domain) (err error) {\n\turl := strings.NewReplacer(\n\t\t\"#{host}\", domain.GetSubDomain(),\n\t\t\"#{domain}\", domain.DomainName,\n\t\t\"#{password}\", nc.DNS.Secret,\n\t\t\"#{ip}\", ipAddr,\n\t).Replace(nameCheapEndpoint)\n\n\treq, err := http.NewRequest(\n\t\thttp.MethodGet,\n\t\turl,\n\t\thttp.NoBody,\n\t)\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tclient := nc.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tdefer resp.Body.Close()\n\tdata, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstatus := string(data)\n\n\tif strings.Contains(status, \"<ErrCount>0</ErrCount>\") {\n\t\tresult.Status = \"Success\"\n\t} else {\n\t\tresult.Status = status\n\t}\n\n\treturn\n}\n"
  },
  {
    "path": "dns/namesilo.go",
    "content": "package dns\n\nimport (\n\t\"encoding/xml\"\n\t\"io\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\tnameSiloListRecordEndpoint   = \"https://www.namesilo.com/api/dnsListRecords?version=1&type=xml&key=#{password}&domain=#{domain}\"\n\tnameSiloAddRecordEndpoint    = \"https://www.namesilo.com/api/dnsAddRecord?version=1&type=xml&key=#{password}&domain=#{domain}&rrhost=#{host}&rrtype=#{recordType}&rrvalue=#{ip}&rrttl=3600\"\n\tnameSiloUpdateRecordEndpoint = \"https://www.namesilo.com/api/dnsUpdateRecord?version=1&type=xml&key=#{password}&domain=#{domain}&rrhost=#{host}&rrid=#{recordID}&rrvalue=#{ip}&rrttl=3600\"\n)\n\n// NameSilo Domain\ntype NameSilo struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tlastIpv4   string\n\tlastIpv6   string\n\thttpClient *http.Client\n}\n\n// NameSiloResp 修改域名解析结果\ntype NameSiloResp struct {\n\tXMLName xml.Name      `xml:\"namesilo\"`\n\tRequest Request       `xml:\"request\"`\n\tReply   ReplyResponse `xml:\"reply\"`\n}\n\ntype ReplyResponse struct {\n\tCode     int    `xml:\"code\"`\n\tDetail   string `xml:\"detail\"`\n\tRecordID string `xml:\"record_id\"`\n}\n\ntype NameSiloDNSListRecordResp struct {\n\tXMLName xml.Name `xml:\"namesilo\"`\n\tRequest Request  `xml:\"request\"`\n\tReply   Reply    `xml:\"reply\"`\n}\n\ntype Request struct {\n\tOperation string `xml:\"operation\"`\n\tIP        string `xml:\"ip\"`\n}\n\ntype Reply struct {\n\tCode          int              `xml:\"code\"`\n\tDetail        string           `xml:\"detail\"`\n\tResourceItems []ResourceRecord `xml:\"resource_record\"`\n}\n\ntype ResourceRecord struct {\n\tRecordID string `xml:\"record_id\"`\n\tType     string `xml:\"type\"`\n\tHost     string `xml:\"host\"`\n\tValue    string `xml:\"value\"`\n\tTTL      int    `xml:\"ttl\"`\n\tDistance int    `xml:\"distance\"`\n}\n\n// Init 初始化\nfunc (ns *NameSilo) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tns.Domains.Ipv4Cache = ipv4cache\n\tns.Domains.Ipv6Cache = ipv6cache\n\tns.lastIpv4 = ipv4cache.Addr\n\tns.lastIpv6 = ipv6cache.Addr\n\n\tns.DNS = dnsConf.DNS\n\tns.Domains.GetNewIp(dnsConf)\n\tns.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (ns *NameSilo) AddUpdateDomainRecords() config.Domains {\n\tns.addUpdateDomainRecords(\"A\")\n\tns.addUpdateDomainRecords(\"AAAA\")\n\treturn ns.Domains\n}\n\nfunc (ns *NameSilo) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := ns.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\n\t\tif domain.SubDomain == \"\" {\n\t\t\tdomain.SubDomain = \"@\"\n\t\t}\n\n\t\t// 拿到DNS记录列表，从列表中去取对应域名的id，有id进行修改，没ID进行新增\n\t\trecords, err := ns.listRecords(domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\t\titems := records.Reply.ResourceItems\n\t\trecord := findResourceRecord(items, recordType, domain.SubDomain)\n\t\tvar isAdd bool\n\t\tvar recordID string\n\t\tif record == nil {\n\t\t\tisAdd = true\n\t\t} else {\n\t\t\trecordID = record.RecordID\n\t\t\tif record.Value == ipAddr {\n\t\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\t\tns.modify(domain, recordID, recordType, ipAddr, isAdd)\n\t}\n}\n\n// 修改\nfunc (ns *NameSilo) modify(domain *config.Domain, recordID, recordType, ipAddr string, isAdd bool) {\n\tvar err error\n\tvar result string\n\tvar requestType string\n\tif isAdd {\n\t\trequestType = \"新增\"\n\t\tresult, err = ns.request(ipAddr, domain, \"\", recordType, nameSiloAddRecordEndpoint)\n\t} else {\n\t\trequestType = \"更新\"\n\t\tresult, err = ns.request(ipAddr, domain, recordID, \"\", nameSiloUpdateRecordEndpoint)\n\t}\n\tif err != nil {\n\t\tutil.Log(\"异常信息: %s\", err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\tvar resp NameSiloResp\n\txml.Unmarshal([]byte(result), &resp)\n\tif resp.Reply.Code == 300 {\n\t\tutil.Log(requestType+\"域名解析 %s 成功! IP: %s\\n\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(requestType+\"域名解析 %s 失败! 异常信息: %s\", domain, resp.Reply.Detail)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\nfunc (ns *NameSilo) listRecords(domain *config.Domain) (*NameSiloDNSListRecordResp, error) {\n\tresult, err := ns.request(\"\", domain, \"\", \"\", nameSiloListRecordEndpoint)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar resp NameSiloDNSListRecordResp\n\tif err = xml.Unmarshal([]byte(result), &resp); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &resp, nil\n}\n\n// request 统一请求接口\nfunc (ns *NameSilo) request(ipAddr string, domain *config.Domain, recordID, recordType, url string) (result string, err error) {\n\turl = strings.NewReplacer(\n\t\t\"#{host}\", domain.SubDomain,\n\t\t\"#{domain}\", domain.DomainName,\n\t\t\"#{password}\", ns.DNS.Secret,\n\t\t\"#{recordID}\", recordID,\n\t\t\"#{recordType}\", recordType,\n\t\t\"#{ip}\", ipAddr,\n\t).Replace(url)\n\treq, err := http.NewRequest(\n\t\thttp.MethodGet,\n\t\turl,\n\t\thttp.NoBody,\n\t)\n\n\tif err != nil {\n\t\treturn\n\t}\n\n\tclient := ns.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tdefer resp.Body.Close()\n\tdata, err := io.ReadAll(resp.Body)\n\tresult = string(data)\n\treturn\n}\n\nfunc findResourceRecord(data []ResourceRecord, recordType, domain string) *ResourceRecord {\n\tfor i := 0; i < len(data); i++ {\n\t\tif data[i].Host == domain && data[i].Type == recordType {\n\t\t\treturn &data[i]\n\t\t}\n\t}\n\treturn nil\n}\n"
  },
  {
    "path": "dns/nowcn.go",
    "content": "package dns\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha1\"\n\t\"encoding/base64\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// https://www.todaynic.com/docApi/\n// Nowcn nowcn DNS实现\ntype Nowcn struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\n\n// NowcnRecord DNS记录结构\ntype NowcnRecord struct {\n\tID     int `json:\"id\"`\n\tDomain string\n\tHost   string\n\tType   string\n\tValue  string\n\tState  int\n\t// Name    string\n\t// Enabled string\n}\n\n// NowcnRecordListResp 记录列表响应\ntype NowcnRecordListResp struct {\n\tNowcnBaseResult\n\tData []NowcnRecord\n}\n\n// NowcnStatus API响应状态\ntype NowcnBaseResult struct {\n\tRequestId string `json:\"RequestId\"`\n\tId        int    `json:\"Id\"`\n\tError     string `json:\"error\"`\n}\n\n// Init 初始化\nfunc (nowcn *Nowcn) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tnowcn.Domains.Ipv4Cache = ipv4cache\n\tnowcn.Domains.Ipv6Cache = ipv6cache\n\tnowcn.DNS = dnsConf.DNS\n\tnowcn.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\tnowcn.TTL = \"600\"\n\t} else {\n\t\tnowcn.TTL = dnsConf.TTL\n\t}\n\tnowcn.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (nowcn *Nowcn) AddUpdateDomainRecords() config.Domains {\n\tnowcn.addUpdateDomainRecords(\"A\")\n\tnowcn.addUpdateDomainRecords(\"AAAA\")\n\treturn nowcn.Domains\n}\n\nfunc (nowcn *Nowcn) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := nowcn.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tresult, err := nowcn.getRecordList(domain, recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif len(result.Data) > 0 {\n\t\t\t// 默认第一个\n\t\t\trecordSelected := result.Data[0]\n\t\t\tparams := domain.GetCustomParams()\n\t\t\tif params.Has(\"Id\") {\n\t\t\t\tfor i := 0; i < len(result.Data); i++ {\n\t\t\t\t\tif strconv.Itoa(result.Data[i].ID) == params.Get(\"Id\") {\n\t\t\t\t\t\trecordSelected = result.Data[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\t// 更新\n\t\t\tnowcn.modify(recordSelected, domain, recordType, ipAddr)\n\t\t} else {\n\t\t\t// 新增\n\t\t\tnowcn.create(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// create 创建DNS记录\nfunc (nowcn *Nowcn) create(domain *config.Domain, recordType string, ipAddr string) {\n\tparam := map[string]string{\n\t\t\"Domain\": domain.DomainName,\n\t\t\"Host\":   domain.GetSubDomain(),\n\t\t\"Type\":   recordType,\n\t\t\"Value\":  ipAddr,\n\t\t\"Ttl\":    nowcn.TTL,\n\t}\n\tres, err := nowcn.request(\"/api/Dns/AddDomainRecord\", param, \"GET\")\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tvar result NowcnBaseResult\n\terr = json.Unmarshal(res, &result)\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tif result.Error != \"\" {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, result.Error)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// modify 修改DNS记录\nfunc (nowcn *Nowcn) modify(record NowcnRecord, domain *config.Domain, recordType string, ipAddr string) {\n\t// 相同不修改\n\tif record.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\tparam := map[string]string{\n\t\t\"Id\":     strconv.Itoa(record.ID),\n\t\t\"Domain\": domain.DomainName,\n\t\t\"Host\":   domain.GetSubDomain(),\n\t\t\"Type\":   recordType,\n\t\t\"Value\":  ipAddr,\n\t\t\"Ttl\":    nowcn.TTL,\n\t}\n\tres, err := nowcn.request(\"/api/Dns/UpdateDomainRecord\", param, \"GET\")\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tvar result NowcnBaseResult\n\terr = json.Unmarshal(res, &result)\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err.Error())\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n\tif result.Error != \"\" {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, result.Error)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t}\n}\n\n// getRecordList 获取域名记录列表\nfunc (nowcn *Nowcn) getRecordList(domain *config.Domain, typ string) (result NowcnRecordListResp, err error) {\n\tparam := map[string]string{\n\t\t\"Domain\": domain.DomainName,\n\t\t\"Type\":   typ,\n\t\t\"Host\":   domain.GetSubDomain(),\n\t}\n\tres, err := nowcn.request(\"/api/Dns/DescribeRecordIndex\", param, \"GET\")\n\terr = json.Unmarshal(res, &result)\n\treturn\n}\n\nfunc (t *Nowcn) sign(params map[string]string, method string) (string, error) {\n\t// 添加公共参数\n\tparams[\"AccessInstanceID\"] = t.DNS.ID\n\tparams[\"SignatureMethod\"] = \"HMAC-SHA1\"\n\tparams[\"SignatureNonce\"] = fmt.Sprintf(\"%d\", time.Now().UnixNano())\n\tparams[\"Timestamp\"] = time.Now().UTC().Format(\"2006-01-02T15:04:05Z\")\n\n\t// 1. 排序参数(按首字母顺序)\n\tvar keys []string\n\tfor k := range params {\n\t\tif k != \"Signature\" { // 排除Signature参数\n\t\t\tkeys = append(keys, k)\n\t\t}\n\t}\n\tsort.Strings(keys)\n\n\t// 2. 构造规范化请求字符串\n\tvar canonicalizedQuery []string\n\tfor _, k := range keys {\n\t\t// URL编码参数名和参数值\n\t\tencodedKey := util.PercentEncode(k)\n\t\tencodedValue := util.PercentEncode(params[k])\n\t\tcanonicalizedQuery = append(canonicalizedQuery, encodedKey+\"=\"+encodedValue)\n\t}\n\tcanonicalizedQueryString := strings.Join(canonicalizedQuery, \"&\")\n\n\t// 3. 构造待签名字符串\n\tstringToSign := method + \"&\" + util.PercentEncode(\"/\") + \"&\" + util.PercentEncode(canonicalizedQueryString)\n\n\t// 4. 计算HMAC-SHA1签名\n\tkey := t.DNS.Secret + \"&\"\n\th := hmac.New(sha1.New, []byte(key))\n\th.Write([]byte(stringToSign))\n\tsignature := base64.StdEncoding.EncodeToString(h.Sum(nil))\n\n\t// 5. 添加签名到参数中\n\tparams[\"Signature\"] = signature\n\n\t// 6. 重新构造最终的查询字符串(包含签名)\n\tkeys = append(keys, \"Signature\")\n\tsort.Strings(keys)\n\tvar finalQuery []string\n\tfor _, k := range keys {\n\t\tencodedKey := util.PercentEncode(k)\n\t\tencodedValue := util.PercentEncode(params[k])\n\t\tfinalQuery = append(finalQuery, encodedKey+\"=\"+encodedValue)\n\t}\n\n\treturn strings.Join(finalQuery, \"&\"), nil\n}\n\nfunc (t *Nowcn) request(apiPath string, params map[string]string, method string) ([]byte, error) {\n\t// 生成签名\n\tqueryString, err := t.sign(params, method)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"生成签名失败: %v\", err)\n\t}\n\n\t// 构造完整URL\n\tbaseURL := \"https://api.now.cn\"\n\tfullURL := baseURL + apiPath + \"?\" + queryString\n\n\t// 创建HTTP请求\n\treq, err := http.NewRequest(method, fullURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"创建请求失败: %v\", err)\n\t}\n\n\t// 设置请求头\n\treq.Header.Set(\"Accept\", \"application/json\")\n\n\t// 发送请求\n\tclient := t.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"请求失败: %v\", err)\n\t}\n\tdefer resp.Body.Close()\n\n\t// 读取响应\n\tbody, err := io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"读取响应失败: %v\", err)\n\t}\n\n\t// 检查HTTP状态码\n\tif resp.StatusCode != http.StatusOK {\n\t\treturn nil, fmt.Errorf(\"API请求失败，状态码: %d, 响应: %s\", resp.StatusCode, string(body))\n\t}\n\n\treturn body, nil\n}\n"
  },
  {
    "path": "dns/nsone.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst nsoneAPIEndpoint = \"https://api.nsone.net/v1/zones\"\n\ntype NSOne struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\ntype NSOneZone struct {\n\tAssignedNameservers []string `json:\"assigned_nameservers\"`\n\tDNSServers          []string `json:\"dns_servers\"`\n\tExpiry              int      `json:\"expiry\"`\n\tName                string   `json:\"name\"`\n\tLink                string   `json:\"link\"`\n\tPrimaryMaster       string   `json:\"primary_master\"`\n\tHostmaster          string   `json:\"hostmaster\"`\n\tID                  string   `json:\"id\"`\n\tMeta                struct {\n\t\tAsn           []string `json:\"asn\"`\n\t\tCaProvince    []string `json:\"ca_province\"`\n\t\tConnections   int      `json:\"connections\"`\n\t\tCountry       []string `json:\"country\"`\n\t\tGeoregion     []string `json:\"georegion\"`\n\t\tHighWatermark float64  `json:\"high_watermark\"`\n\t\tIPPrefixes    []string `json:\"ip_prefixes\"`\n\t\tLatitude      float64  `json:\"latitude\"`\n\t\tLoadAvg       float64  `json:\"loadAvg\"`\n\t\tLongitude     float64  `json:\"longitude\"`\n\t\tLowWatermark  float64  `json:\"low_watermark\"`\n\t\tNote          string   `json:\"note\"`\n\t\tPriority      int      `json:\"priority\"`\n\t\tPulsar        string   `json:\"pulsar\"`\n\t\tRequests      int      `json:\"requests\"`\n\t\tUp            bool     `json:\"up\"`\n\t\tUsState       []string `json:\"us_state\"`\n\t\tWeight        float64  `json:\"weight\"`\n\t} `json:\"meta\"`\n\tNetworkPools []string `json:\"network_pools\"`\n\tNetworks     []int    `json:\"networks\"`\n\tNxTTL        int      `json:\"nx_ttl\"`\n\tSerial       int      `json:\"serial\"`\n\tPrimary      struct {\n\t\tEnabled     bool `json:\"enabled\"`\n\t\tSecondaries []struct {\n\t\t\tIP      string `json:\"ip\"`\n\t\t\tNetwork int    `json:\"network\"`\n\t\t\tNotify  bool   `json:\"notify\"`\n\t\t\tPort    int    `json:\"port\"`\n\t\t\tTsig    struct {\n\t\t\t\tEnabled bool   `json:\"enabled\"`\n\t\t\t\tHash    string `json:\"hash\"`\n\t\t\t\tName    string `json:\"name\"`\n\t\t\t\tKey     string `json:\"key\"`\n\t\t\t} `json:\"tsig\"`\n\t\t} `json:\"secondaries\"`\n\t} `json:\"primary\"`\n\tRefresh   int `json:\"refresh\"`\n\tRetry     int `json:\"retry\"`\n\tSecondary struct {\n\t\tStatus         string `json:\"status\"`\n\t\tError          string `json:\"error\"`\n\t\tLastXfr        int    `json:\"last_xfr\"`\n\t\tLastTry        int    `json:\"last_try\"`\n\t\tEnabled        bool   `json:\"enabled\"`\n\t\tExpired        bool   `json:\"expired\"`\n\t\tPrimaryIP      string `json:\"primary_ip\"`\n\t\tPrimaryPort    int    `json:\"primary_port\"`\n\t\tPrimaryNetwork int    `json:\"primary_network\"`\n\t\tTsig           struct {\n\t\t\tEnabled        bool   `json:\"enabled\"`\n\t\t\tHash           string `json:\"hash\"`\n\t\t\tName           string `json:\"name\"`\n\t\t\tKey            string `json:\"key\"`\n\t\t\tSignedNotifies bool   `json:\"signed_notifies\"`\n\t\t} `json:\"tsig\"`\n\t\tOtherPorts      []int    `json:\"other_ports\"`\n\t\tOtherIps        []string `json:\"other_ips\"`\n\t\tOtherNetworks   []int    `json:\"other_networks\"`\n\t\tOtherNotifyOnly []bool   `json:\"other_notify_only\"`\n\t} `json:\"secondary\"`\n\tTTL       int      `json:\"ttl\"`\n\tZone      string   `json:\"zone\"`\n\tViews     []string `json:\"views\"`\n\tLocalTags []string `json:\"local_tags\"`\n\tTags      struct {\n\t\tID int64 `json:\"id\"`\n\t} `json:\"tags\"`\n\tCreatedAt  int  `json:\"created_at\"`\n\tUpdatedAt  int  `json:\"updated_at\"`\n\tDnssec     bool `json:\"dnssec\"`\n\tSignatures []struct {\n\t\tAnswer []string `json:\"answer\"`\n\t} `json:\"signatures\"`\n\tPresigned     bool `json:\"presigned\"`\n\tIDVersion     int  `json:\"id_version\"`\n\tActiveVersion bool `json:\"active_version\"`\n}\n\ntype NSOneRecordAnswer struct {\n\tAnswer []string `json:\"answer\"`\n\tID     string   `json:\"id,omitempty\"`\n\tMeta   struct {\n\t\tID int64 `json:\"id,omitempty\"`\n\t} `json:\"meta,omitempty\"`\n\tRegion string `json:\"region,omitempty\"`\n\tFeeds  []struct {\n\t\tSource string `json:\"source,omitempty\"`\n\t\tFeed   string `json:\"feed,omitempty\"`\n\t} `json:\"feeds,omitempty\"`\n}\n\ntype NSOneRecordResponse struct {\n\tAnswers []NSOneRecordAnswer `json:\"answers\"`\n\tDomain  string              `json:\"domain\"`\n\tFilters []struct {\n\t\tConfig struct {\n\t\t\tEliminate bool `json:\"eliminate\"`\n\t\t} `json:\"config\"`\n\t} `json:\"filters\"`\n\tLink string `json:\"link\"`\n\tMeta struct {\n\t\tID int64 `json:\"id\"`\n\t} `json:\"meta\"`\n\tNetworks []int `json:\"networks\"`\n\tRegions  struct {\n\t\tID int64 `json:\"id\"`\n\t} `json:\"regions\"`\n\tTier            int      `json:\"tier\"`\n\tTTL             int      `json:\"ttl\"`\n\tOverrideTTL     bool     `json:\"override_ttl\"`\n\tType            string   `json:\"type\"`\n\tUseClientSubnet bool     `json:\"use_client_subnet\"`\n\tZone            string   `json:\"zone\"`\n\tZoneName        string   `json:\"zone_name\"`\n\tBlockedTags     []string `json:\"blocked_tags\"`\n\tLocalTags       []string `json:\"local_tags\"`\n\tTags            struct {\n\t\tID int64 `json:\"id\"`\n\t} `json:\"tags\"`\n\tOverrideAddressRecords bool `json:\"override_address_records\"`\n\tSignatures             []struct {\n\t\tAnswer []string `json:\"answer\"`\n\t} `json:\"signatures\"`\n\tCreatedAt int    `json:\"created_at\"`\n\tUpdatedAt int    `json:\"updated_at\"`\n\tID        string `json:\"id\"`\n\tCustomer  int    `json:\"customer\"`\n\tFeeds     []struct {\n\t\tSource string `json:\"source\"`\n\t\tFeed   string `json:\"feed\"`\n\t} `json:\"feeds\"`\n}\n\ntype NSOneRecordRequest struct {\n\tAnswers []NSOneRecordAnswer `json:\"answers\"`\n\tDomain  string              `json:\"domain\"`\n\tTTL     int                 `json:\"ttl\"`\n\tType    string              `json:\"type\"`\n\tZone    string              `json:\"zone\"`\n}\n\nfunc (nsone *NSOne) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tnsone.Domains.Ipv4Cache = ipv4cache\n\tnsone.Domains.Ipv6Cache = ipv6cache\n\tnsone.DNS = dnsConf.DNS\n\tnsone.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\tnsone.TTL = 60\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\t// Default TTL in documentation is 1 hour\n\t\t\tnsone.TTL = 3600\n\t\t} else {\n\t\t\tnsone.TTL = ttl\n\t\t}\n\t}\n\tnsone.httpClient = dnsConf.GetHTTPClient()\n}\n\nfunc (nsone *NSOne) AddUpdateDomainRecords() config.Domains {\n\tnsone.addUpdateDomainRecords(\"A\")\n\tnsone.addUpdateDomainRecords(\"AAAA\")\n\treturn nsone.Domains\n}\n\nfunc (nsone *NSOne) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := nsone.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tzoneInfo, err := nsone.getZone(domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\tif zoneInfo == nil {\n\t\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain.DomainName)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\texistingRecord, err := nsone.getRecord(domain, recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\tif existingRecord != nil {\n\t\t\tnsone.updateRecord(domain, recordType, ipAddr, existingRecord)\n\t\t} else {\n\t\t\tnsone.createRecord(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\nfunc (nsone *NSOne) getZone(domain *config.Domain) (*NSOneZone, error) {\n\tvar result NSOneZone\n\tparams := url.Values{}\n\tparams.Set(\"records\", \"false\")\n\n\terr := nsone.request(\n\t\t\"GET\",\n\t\tfmt.Sprintf(\"%s/%s?%s\", nsoneAPIEndpoint, domain.DomainName, params.Encode()),\n\t\tnil,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &result, nil\n}\n\nfunc (nsone *NSOne) getRecord(domain *config.Domain, recordType string) (*NSOneRecordResponse, error) {\n\tvar result NSOneRecordResponse\n\tparams := url.Values{}\n\tparams.Set(\"records\", \"false\")\n\n\terr := nsone.request(\n\t\t\"GET\",\n\t\tfmt.Sprintf(\"%s/%s/%s/%s?%s\", nsoneAPIEndpoint, domain.DomainName, domain.GetFullDomain(), recordType, params.Encode()),\n\t\tnil,\n\t\t&result,\n\t)\n\n\tif err == nil && len(result.Answers) > 0 {\n\t\treturn &result, nil\n\t}\n\n\treturn nil, nil\n}\n\nfunc (nsone *NSOne) createRecord(domain *config.Domain, recordType string, ipAddr string) {\n\trecordName := domain.GetFullDomain()\n\trequest := NSOneRecordRequest{\n\t\tAnswers: []NSOneRecordAnswer{\n\t\t\t{\n\t\t\t\tAnswer: []string{\n\t\t\t\t\tipAddr,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tDomain: recordName,\n\t\tTTL:    nsone.TTL,\n\t\tType:   recordType,\n\t\tZone:   domain.DomainName,\n\t}\n\n\tvar response NSOneRecordResponse\n\terr := nsone.request(\n\t\t\"PUT\",\n\t\tfmt.Sprintf(\"%s/%s/%s/%s\", nsoneAPIEndpoint, domain.DomainName, recordName, recordType),\n\t\trequest,\n\t\t&response,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomain.UpdateStatus = config.UpdatedSuccess\n}\n\nfunc (nsone *NSOne) updateRecord(domain *config.Domain, recordType string, ipAddr string, existingRecord *NSOneRecordResponse) {\n\tif len(existingRecord.Answers) > 0 && len(existingRecord.Answers[0].Answer) > 0 {\n\t\tif existingRecord.Answers[0].Answer[0] == ipAddr {\n\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\treturn\n\t\t}\n\t}\n\n\trecordName := domain.GetFullDomain()\n\trequest := NSOneRecordRequest{\n\t\tAnswers: []NSOneRecordAnswer{\n\t\t\t{\n\t\t\t\tAnswer: []string{\n\t\t\t\t\tipAddr,\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tDomain: recordName,\n\t\tTTL:    nsone.TTL,\n\t\tType:   recordType,\n\t\tZone:   domain.DomainName,\n\t}\n\n\tvar response NSOneRecordResponse\n\terr := nsone.request(\n\t\t\"POST\",\n\t\tfmt.Sprintf(\"%s/%s/%s/%s\", nsoneAPIEndpoint, domain.DomainName, recordName, recordType),\n\t\trequest,\n\t\t&response,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomain.UpdateStatus = config.UpdatedSuccess\n}\n\nfunc (nsone *NSOne) request(method string, url string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\n\treq.Header.Set(\"X-NSONE-Key\", nsone.DNS.Secret)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := nsone.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/porkbun.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\tporkbunEndpoint string = \"https://api.porkbun.com/api/json/v3/dns\"\n)\n\ntype Porkbun struct {\n\tDNSConfig  config.DNS\n\tDomains    config.Domains\n\tTTL        string\n\thttpClient *http.Client\n}\ntype PorkbunDomainRecord struct {\n\tName    *string `json:\"name\"`    // subdomain\n\tType    *string `json:\"type\"`    // record type, e.g. A AAAA CNAME\n\tContent *string `json:\"content\"` // value\n\tTtl     *string `json:\"ttl\"`     // default 300\n}\n\ntype PorkbunResponse struct {\n\tStatus string `json:\"status\"`\n}\n\ntype PorkbunDomainQueryResponse struct {\n\t*PorkbunResponse\n\tRecords []PorkbunDomainRecord `json:\"records\"`\n}\n\ntype PorkbunApiKey struct {\n\tAccessKey string `json:\"apikey\"`\n\tSecretKey string `json:\"secretapikey\"`\n}\n\ntype PorkbunDomainCreateOrUpdateVO struct {\n\t*PorkbunApiKey\n\t*PorkbunDomainRecord\n}\n\n// Init 初始化\nfunc (pb *Porkbun) Init(conf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tpb.Domains.Ipv4Cache = ipv4cache\n\tpb.Domains.Ipv6Cache = ipv6cache\n\tpb.DNSConfig = conf.DNS\n\tpb.Domains.GetNewIp(conf)\n\tif conf.TTL == \"\" {\n\t\t// 默认600s\n\t\tpb.TTL = \"600\"\n\t} else {\n\t\tpb.TTL = conf.TTL\n\t}\n\tpb.httpClient = conf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (pb *Porkbun) AddUpdateDomainRecords() config.Domains {\n\tpb.addUpdateDomainRecords(\"A\")\n\tpb.addUpdateDomainRecords(\"AAAA\")\n\treturn pb.Domains\n}\n\nfunc (pb *Porkbun) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := pb.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tvar record PorkbunDomainQueryResponse\n\t\t// 获取当前域名信息\n\t\terr := pb.request(\n\t\t\tporkbunEndpoint+fmt.Sprintf(\"/retrieveByNameType/%s/%s/%s\", domain.DomainName, recordType, domain.SubDomain),\n\t\t\t&PorkbunApiKey{\n\t\t\t\tAccessKey: pb.DNSConfig.ID,\n\t\t\t\tSecretKey: pb.DNSConfig.Secret,\n\t\t\t},\n\t\t\t&record,\n\t\t)\n\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\t\tif record.Status == \"SUCCESS\" {\n\t\t\tif len(record.Records) > 0 {\n\t\t\t\t// 存在，更新\n\t\t\t\tpb.modify(&record, domain, recordType, ipAddr)\n\t\t\t} else {\n\t\t\t\t// 不存在，创建\n\t\t\t\tpb.create(domain, recordType, ipAddr)\n\t\t\t}\n\t\t} else {\n\t\t\tutil.Log(\"在DNS服务商中未找到根域名: %s\", domain.DomainName)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t}\n\t}\n}\n\n// 创建\nfunc (pb *Porkbun) create(domain *config.Domain, recordType string, ipAddr string) {\n\tvar response PorkbunResponse\n\n\terr := pb.request(\n\t\tporkbunEndpoint+fmt.Sprintf(\"/create/%s\", domain.DomainName),\n\t\t&PorkbunDomainCreateOrUpdateVO{\n\t\t\tPorkbunApiKey: &PorkbunApiKey{\n\t\t\t\tAccessKey: pb.DNSConfig.ID,\n\t\t\t\tSecretKey: pb.DNSConfig.Secret,\n\t\t\t},\n\t\t\tPorkbunDomainRecord: &PorkbunDomainRecord{\n\t\t\t\tName:    &domain.SubDomain,\n\t\t\t\tType:    &recordType,\n\t\t\t\tContent: &ipAddr,\n\t\t\t\tTtl:     &pb.TTL,\n\t\t\t},\n\t\t},\n\t\t&response,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif response.Status == \"SUCCESS\" {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, response.Status)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// 修改\nfunc (pb *Porkbun) modify(record *PorkbunDomainQueryResponse, domain *config.Domain, recordType string, ipAddr string) {\n\n\t// 相同不修改\n\tif len(record.Records) > 0 && *record.Records[0].Content == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\tvar response PorkbunResponse\n\n\terr := pb.request(\n\t\tporkbunEndpoint+fmt.Sprintf(\"/editByNameType/%s/%s/%s\", domain.DomainName, recordType, domain.SubDomain),\n\t\t&PorkbunDomainCreateOrUpdateVO{\n\t\t\tPorkbunApiKey: &PorkbunApiKey{\n\t\t\t\tAccessKey: pb.DNSConfig.ID,\n\t\t\t\tSecretKey: pb.DNSConfig.Secret,\n\t\t\t},\n\t\t\tPorkbunDomainRecord: &PorkbunDomainRecord{\n\t\t\t\tContent: &ipAddr,\n\t\t\t\tTtl:     &pb.TTL,\n\t\t\t},\n\t\t},\n\t\t&response,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif response.Status == \"SUCCESS\" {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, response.Status)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// request 统一请求接口\nfunc (pb *Porkbun) request(url string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\treq, err := http.NewRequest(\n\t\t\"POST\",\n\t\turl,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := pb.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/rainyun.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\trainyunEndpoint = \"https://api.v2.rainyun.com\"\n)\n\n// https://s.apifox.cn/a4595cc8-44c5-4678-a2a3-eed7738dab03/api-153559362\n// Rainyun Rainyun\ntype Rainyun struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// RainyunRecord 雨云DNS记录\ntype RainyunRecord struct {\n\tRecordID int64  `json:\"record_id\"`\n\tHost     string `json:\"host\"`\n\tType     string `json:\"type\"`\n\tValue    string `json:\"value\"`\n\tLine     string `json:\"line\"`\n\tTTL      int    `json:\"ttl\"`\n\tLevel    int    `json:\"level\"`\n}\n\n// RainyunResp 雨云API通用响应\ntype RainyunResp struct {\n\tCode    int    `json:\"code\"`\n\tMessage string `json:\"message\"`\n\tData    any    `json:\"data\"`\n}\n\n// Init 初始化\nfunc (rainyun *Rainyun) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\trainyun.Domains.Ipv4Cache = ipv4cache\n\trainyun.Domains.Ipv6Cache = ipv6cache\n\trainyun.DNS = dnsConf.DNS\n\trainyun.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认600s\n\t\trainyun.TTL = 600\n\t} else {\n\t\tttlInt, _ := strconv.Atoi(dnsConf.TTL)\n\t\trainyun.TTL = ttlInt\n\t}\n\trainyun.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (rainyun *Rainyun) AddUpdateDomainRecords() (domains config.Domains) {\n\trainyun.addUpdateDomainRecords(\"A\")\n\trainyun.addUpdateDomainRecords(\"AAAA\")\n\treturn rainyun.Domains\n}\n\nfunc (rainyun *Rainyun) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := rainyun.Domains.GetNewIpResult(recordType)\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\t// 获取Domain ID\n\t\tdomainID := rainyun.DNS.ID\n\n\t\t// 获取记录列表\n\t\trecords, err := rainyun.getRecordList(domainID)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\tcontinue\n\t\t}\n\n\t\t// 查找匹配的记录\n\t\tvar recordSelected *RainyunRecord\n\t\tfor i := range records {\n\t\t\tif strings.EqualFold(records[i].Host, domain.GetSubDomain()) &&\n\t\t\t\tstrings.EqualFold(records[i].Type, recordType) {\n\t\t\t\trecordSelected = &records[i]\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif recordSelected != nil {\n\t\t\t// 更新记录\n\t\t\trainyun.modify(domainID, recordSelected, domain, ipAddr)\n\t\t} else {\n\t\t\t// 新增记录\n\t\t\trainyun.create(domainID, domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// getRecordList 获取域名记录列表\nfunc (rainyun *Rainyun) getRecordList(domainID string) ([]RainyunRecord, error) {\n\tquery := url.Values{}\n\tquery.Set(\"limit\", \"100\")\n\tquery.Set(\"page_no\", \"1\")\n\n\tvar result struct {\n\t\tTotalRecords int             `json:\"TotalRecords\"`\n\t\tRecords      []RainyunRecord `json:\"Records\"`\n\t}\n\terr := rainyun.request(\n\t\thttp.MethodGet,\n\t\tfmt.Sprintf(\"/product/domain/%s/dns/\", url.PathEscape(domainID)),\n\t\tquery,\n\t\tnil,\n\t\t&result,\n\t)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\treturn result.Records, nil\n}\n\n// create 创建DNS记录\nfunc (rainyun *Rainyun) create(domainID string, domain *config.Domain, recordType string, ipAddr string) {\n\trecord := &RainyunRecord{\n\t\tHost:  domain.GetSubDomain(),\n\t\tType:  recordType,\n\t\tValue: ipAddr,\n\t\tLine:  \"DEFAULT\",\n\t\tTTL:   rainyun.TTL,\n\t\tLevel: 10,\n\t}\n\n\terr := rainyun.createRecord(domainID, record)\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomain.UpdateStatus = config.UpdatedSuccess\n}\n\n// createRecord 发送POST请求创建记录\nfunc (rainyun *Rainyun) createRecord(domainID string, record *RainyunRecord) error {\n\tpayload := map[string]any{\n\t\t\"host\":      record.Host,\n\t\t\"line\":      record.Line,\n\t\t\"level\":     record.Level,\n\t\t\"ttl\":       record.TTL,\n\t\t\"type\":      record.Type,\n\t\t\"value\":     record.Value,\n\t\t\"record_id\": 0,\n\t}\n\n\tbyt, _ := json.Marshal(payload)\n\treturn rainyun.request(\n\t\thttp.MethodPost,\n\t\tfmt.Sprintf(\"/product/domain/%s/dns\", url.PathEscape(domainID)),\n\t\tnil,\n\t\tbyt,\n\t\tnil,\n\t)\n}\n\n// modify 修改DNS记录\nfunc (rainyun *Rainyun) modify(domainID string, record *RainyunRecord, domain *config.Domain, ipAddr string) {\n\tif record.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\n\trecord.Value = ipAddr\n\trecord.TTL = rainyun.TTL\n\n\terr := rainyun.patchRecord(domainID, record)\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\tdomain.UpdateStatus = config.UpdatedSuccess\n}\n\n// patchRecord 发送PATCH请求更新记录\nfunc (rainyun *Rainyun) patchRecord(domainID string, record *RainyunRecord) error {\n\tpayload := map[string]any{\n\t\t\"host\":      record.Host,\n\t\t\"line\":      record.Line,\n\t\t\"level\":     record.Level,\n\t\t\"ttl\":       record.TTL,\n\t\t\"type\":      record.Type,\n\t\t\"value\":     record.Value,\n\t\t\"record_id\": record.RecordID,\n\t}\n\n\tbyt, _ := json.Marshal(payload)\n\treturn rainyun.request(\n\t\thttp.MethodPatch,\n\t\tfmt.Sprintf(\"/product/domain/%s/dns\", url.PathEscape(domainID)),\n\t\tnil,\n\t\tbyt,\n\t\tnil,\n\t)\n}\n\n// request 统一请求接口\nfunc (rainyun *Rainyun) request(method string, path string, query url.Values, body []byte, result any) error {\n\tu, err := url.Parse(rainyunEndpoint)\n\tif err != nil {\n\t\treturn err\n\t}\n\tu.Path = path\n\tif query != nil {\n\t\tu.RawQuery = query.Encode()\n\t}\n\n\tvar reader *bytes.Reader\n\tif body == nil {\n\t\treader = bytes.NewReader(nil)\n\t} else {\n\t\treader = bytes.NewReader(body)\n\t}\n\n\treq, err := http.NewRequest(method, u.String(), reader)\n\tif err != nil {\n\t\treturn err\n\t}\n\t// 认证\n\treq.Header.Set(\"x-api-key\", rainyun.DNS.Secret)\n\tif method == http.MethodPost || method == http.MethodPatch || method == http.MethodPut {\n\t\treq.Header.Set(\"Content-Type\", \"application/json\")\n\t}\n\n\tresp, err := rainyun.httpClient.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar apiResp RainyunResp\n\terr = util.GetHTTPResponse(resp, err, &apiResp)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif apiResp.Code != 200 {\n\t\tif apiResp.Message != \"\" {\n\t\t\treturn fmt.Errorf(\"%s\", apiResp.Message)\n\t\t}\n\t\treturn fmt.Errorf(\"Rainyun API error, code=%d\", apiResp.Code)\n\t}\n\n\tif result == nil {\n\t\treturn nil\n\t}\n\n\tdataBytes, err := json.Marshal(apiResp.Data)\n\tif err != nil {\n\t\treturn err\n\t}\n\treturn json.Unmarshal(dataBytes, result)\n}\n"
  },
  {
    "path": "dns/spaceship.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst spaceshipAPI = \"https://spaceship.dev/api/v1/dns/records\"\nconst maxRecords = 500\n\ntype Spaceship struct {\n\tdomains    config.Domains\n\theader     http.Header\n\tttl        int\n\thttpClient *http.Client\n}\n\nfunc (s *Spaceship) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\ts.domains.Ipv4Cache = ipv4cache\n\ts.domains.Ipv6Cache = ipv6cache\n\ts.domains.GetNewIp(dnsConf)\n\n\ts.ttl = 600\n\tif val, err := strconv.Atoi(dnsConf.TTL); err == nil {\n\t\ts.ttl = val\n\t}\n\ts.header = http.Header{\n\t\t\"X-API-Key\":    {dnsConf.DNS.ID},\n\t\t\"X-API-Secret\": {dnsConf.DNS.Secret},\n\t\t\"Content-Type\": {\"application/json\"},\n\t}\n\ts.httpClient = dnsConf.GetHTTPClient()\n}\n\nfunc (s *Spaceship) AddUpdateDomainRecords() (domains config.Domains) {\n\tfor _, recordType := range []string{\"A\", \"AAAA\"} {\n\t\tip, domains := s.domains.GetNewIpResult(recordType)\n\t\tif ip == \"\" {\n\t\t\tcontinue\n\t\t}\n\t\tfor _, domain := range domains {\n\t\t\thasUpdated, err := s.updateRecord(recordType, ip, domain)\n\t\t\tif err != nil {\n\t\t\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif !hasUpdated {\n\t\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ip, domain)\n\t\t\t} else {\n\t\t\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ip)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t\t\t}\n\t\t}\n\t}\n\treturn s.domains\n}\n\nfunc (s *Spaceship) request(domain *config.Domain, method string, query url.Values, payload []byte) (response []byte, err error) {\n\turl := fmt.Sprintf(\"%s/%s\", spaceshipAPI, domain.DomainName)\n\treq, err := http.NewRequest(method, url, bytes.NewBuffer([]byte(payload)))\n\tif err != nil {\n\t\treturn\n\t}\n\treq.Header = s.header\n\treq.URL.RawQuery = query.Encode()\n\n\tcli := s.httpClient\n\tresp, err := cli.Do(req)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tdefer resp.Body.Close()\n\tresponse, err = io.ReadAll(resp.Body)\n\tif err != nil {\n\t\treturn\n\t}\n\n\ttype DataItem struct {\n\t\tField   string `json:\"field\"`\n\t\tDetails string `json:\"details\"`\n\t}\n\n\ttype ErrorResponse struct {\n\t\tDetail string      `json:\"detail\"`\n\t\tData   *[]DataItem `json:\"data,omitempty\"`\n\t}\n\n\tif resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {\n\t\tvar e ErrorResponse\n\t\terr = json.Unmarshal(response, &e)\n\t\tif err != nil {\n\t\t\treturn\n\t\t}\n\t\terr = fmt.Errorf(\"request error: %s\", e.Detail)\n\t\treturn\n\t}\n\n\treturn\n}\n\nfunc (s *Spaceship) createRecord(recordType string, ip string, domain *config.Domain) (err error) {\n\ttype Item struct {\n\t\tType    string `json:\"type\"`\n\t\tAddress string `json:\"address\"`\n\t\tName    string `json:\"name\"`\n\t\tTTL     int    `json:\"ttl\"`\n\t}\n\n\ttype Payload struct {\n\t\tForce bool   `json:\"force\"`\n\t\tItems []Item `json:\"items\"`\n\t}\n\n\tpayload := Payload{\n\t\tForce: true,\n\t\tItems: []Item{\n\t\t\t{\n\t\t\t\tType:    recordType,\n\t\t\t\tAddress: ip,\n\t\t\t\tName:    domain.SubDomain,\n\t\t\t\tTTL:     s.ttl,\n\t\t\t},\n\t\t},\n\t}\n\tdata, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn\n\t}\n\t_, err = s.request(domain, \"PUT\", url.Values{}, data)\n\treturn\n}\n\nfunc (s *Spaceship) getRecords(recordType string, domain *config.Domain) (ips []string, err error) {\n\ttype Group struct {\n\t\tType string `json:\"type\"`\n\t}\n\n\ttype Item struct {\n\t\tType    string `json:\"type\"`\n\t\tAddress string `json:\"address\"`\n\t\tName    string `json:\"name\"`\n\t\tTTL     int    `json:\"ttl\"`\n\t\tGroup   Group  `json:\"group\"`\n\t}\n\n\ttype Response struct {\n\t\tItems []Item `json:\"items\"`\n\t\tTotal int    `json:\"total\"`\n\t}\n\n\tresp, err := s.request(domain, \"GET\", url.Values{\"take\": {strconv.Itoa(maxRecords)}, \"skip\": {\"0\"}}, []byte{})\n\tif err != nil {\n\t\treturn\n\t}\n\n\tvar response Response\n\terr = json.Unmarshal(resp, &response)\n\tif err != nil {\n\t\treturn\n\t}\n\n\tif response.Total > maxRecords {\n\t\terr = fmt.Errorf(\"could not fetch all %d records in a one request\", response.Total)\n\t\treturn\n\t}\n\n\tfor _, item := range response.Items {\n\t\tif item.Type == recordType && item.Name == domain.SubDomain {\n\t\t\tips = append(ips, item.Address)\n\t\t}\n\t}\n\treturn\n}\n\nfunc (s *Spaceship) deleteRecords(recordType string, domain *config.Domain, ips []string) (err error) {\n\tif len(ips) == 0 {\n\t\treturn\n\t}\n\n\tif len(ips) > maxRecords {\n\t\terr = fmt.Errorf(\"could not delete all %d records in a one request\", len(ips))\n\t\treturn\n\t}\n\n\ttype Item struct {\n\t\tType    string `json:\"type\"`\n\t\tAddress string `json:\"address\"`\n\t\tName    string `json:\"name\"`\n\t}\n\tvar payload []Item\n\tfor _, ip := range ips {\n\t\tpayload = append(payload, Item{\n\t\t\tType:    recordType,\n\t\t\tAddress: ip,\n\t\t\tName:    domain.SubDomain,\n\t\t})\n\t}\n\tdata, err := json.Marshal(payload)\n\tif err != nil {\n\t\treturn\n\t}\n\t_, err = s.request(domain, \"DELETE\", url.Values{}, data)\n\treturn\n}\n\nfunc (s *Spaceship) updateRecord(recordType string, ip string, domain *config.Domain) (hasUpdated bool, err error) {\n\tips, err := s.getRecords(recordType, domain)\n\tif err != nil {\n\t\treturn\n\t}\n\tif len(ips) == 1 && ips[0] == ip {\n\t\treturn\n\t}\n\terr = s.deleteRecords(recordType, domain, ips)\n\tif err != nil {\n\t\treturn\n\t}\n\terr = s.createRecord(recordType, ip, domain)\n\thasUpdated = true\n\treturn\n}\n"
  },
  {
    "path": "dns/tencent_cloud.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nconst (\n\ttencentCloudEndPoint = \"https://dnspod.tencentcloudapi.com\"\n\ttencentCloudVersion  = \"2021-03-23\"\n)\n\n// TencentCloud 腾讯云 DNSPod API 3.0 实现\n// https://cloud.tencent.com/document/api/1427/56193\ntype TencentCloud struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// TencentCloudRecord 腾讯云记录\ntype TencentCloudRecord struct {\n\tDomain string `json:\"Domain\"`\n\t// DescribeRecordList 不需要 SubDomain\n\tSubDomain string `json:\"SubDomain,omitempty\"`\n\t// CreateRecord/ModifyRecord 不需要 Subdomain\n\tSubdomain  string `json:\"Subdomain,omitempty\"`\n\tRecordType string `json:\"RecordType\"`\n\tRecordLine string `json:\"RecordLine\"`\n\t// DescribeRecordList 不需要 Value\n\tValue string `json:\"Value,omitempty\"`\n\t// CreateRecord/DescribeRecordList 不需要 RecordId\n\tRecordId int64 `json:\"RecordId,omitempty\"`\n\t// DescribeRecordList 不需要 TTL\n\tTTL int `json:\"TTL,omitempty\"`\n}\n\n// TencentCloudRecordListsResp 获取域名的解析记录列表返回结果\ntype TencentCloudRecordListsResp struct {\n\tTencentCloudStatus\n\tResponse struct {\n\t\tRecordCountInfo struct {\n\t\t\tTotalCount int `json:\"TotalCount\"`\n\t\t} `json:\"RecordCountInfo\"`\n\n\t\tRecordList []TencentCloudRecord `json:\"RecordList\"`\n\t}\n}\n\n// TencentCloudStatus 腾讯云返回状态\n// https://cloud.tencent.com/document/product/1427/56192\ntype TencentCloudStatus struct {\n\tResponse struct {\n\t\tError struct {\n\t\t\tCode    string\n\t\t\tMessage string\n\t\t}\n\t}\n}\n\nfunc (tc *TencentCloud) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\ttc.Domains.Ipv4Cache = ipv4cache\n\ttc.Domains.Ipv6Cache = ipv6cache\n\ttc.DNS = dnsConf.DNS\n\ttc.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\t// 默认 600s\n\t\ttc.TTL = 600\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\ttc.TTL = 600\n\t\t} else {\n\t\t\ttc.TTL = ttl\n\t\t}\n\t}\n\ttc.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新 IPv4/IPv6 记录\nfunc (tc *TencentCloud) AddUpdateDomainRecords() config.Domains {\n\ttc.addUpdateDomainRecords(\"A\")\n\ttc.addUpdateDomainRecords(\"AAAA\")\n\treturn tc.Domains\n}\n\nfunc (tc *TencentCloud) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := tc.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tresult, err := tc.getRecordList(domain, recordType)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t\treturn\n\t\t}\n\n\t\tif result.Response.RecordCountInfo.TotalCount > 0 {\n\t\t\t// 默认第一个\n\t\t\trecordSelected := result.Response.RecordList[0]\n\t\t\tparams := domain.GetCustomParams()\n\t\t\tif params.Has(\"RecordId\") {\n\t\t\t\tfor i := 0; i < result.Response.RecordCountInfo.TotalCount; i++ {\n\t\t\t\t\tif strconv.FormatInt(result.Response.RecordList[i].RecordId, 10) == params.Get(\"RecordId\") {\n\t\t\t\t\t\trecordSelected = result.Response.RecordList[i]\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// 修改记录\n\t\t\ttc.modify(recordSelected, domain, recordType, ipAddr)\n\t\t} else {\n\t\t\t// 添加记录\n\t\t\ttc.create(domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// create 添加记录\n// CreateRecord https://cloud.tencent.com/document/api/1427/56180\nfunc (tc *TencentCloud) create(domain *config.Domain, recordType string, ipAddr string) {\n\trecord := &TencentCloudRecord{\n\t\tDomain:     domain.DomainName,\n\t\tSubDomain:  domain.GetSubDomain(),\n\t\tRecordType: recordType,\n\t\tRecordLine: tc.getRecordLine(domain),\n\t\tValue:      ipAddr,\n\t\tTTL:        tc.TTL,\n\t}\n\n\tvar status TencentCloudStatus\n\terr := tc.request(\n\t\t\"CreateRecord\",\n\t\trecord,\n\t\t&status,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Response.Error.Code == \"\" {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, status.Response.Error.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// modify 修改记录\n// ModifyRecord https://cloud.tencent.com/document/api/1427/56157\nfunc (tc *TencentCloud) modify(record TencentCloudRecord, domain *config.Domain, recordType string, ipAddr string) {\n\t// 相同不修改\n\tif record.Value == ipAddr {\n\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\treturn\n\t}\n\tvar status TencentCloudStatus\n\trecord.Domain = domain.DomainName\n\trecord.SubDomain = domain.GetSubDomain()\n\trecord.RecordType = recordType\n\trecord.RecordLine = tc.getRecordLine(domain)\n\trecord.Value = ipAddr\n\trecord.TTL = tc.TTL\n\terr := tc.request(\n\t\t\"ModifyRecord\",\n\t\trecord,\n\t\t&status,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif status.Response.Error.Code == \"\" {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, status.Response.Error.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// getRecordList 获取域名的解析记录列表\n// DescribeRecordList https://cloud.tencent.com/document/api/1427/56166\nfunc (tc *TencentCloud) getRecordList(domain *config.Domain, recordType string) (result TencentCloudRecordListsResp, err error) {\n\trecord := TencentCloudRecord{\n\t\tDomain:     domain.DomainName,\n\t\tSubdomain:  domain.GetSubDomain(),\n\t\tRecordType: recordType,\n\t\tRecordLine: tc.getRecordLine(domain),\n\t}\n\terr = tc.request(\n\t\t\"DescribeRecordList\",\n\t\trecord,\n\t\t&result,\n\t)\n\n\treturn\n}\n\n// getRecordLine 获取记录线路，为空返回默认\nfunc (tc *TencentCloud) getRecordLine(domain *config.Domain) string {\n\tif domain.GetCustomParams().Has(\"RecordLine\") {\n\t\treturn domain.GetCustomParams().Get(\"RecordLine\")\n\t}\n\treturn \"默认\"\n}\n\n// request 统一请求接口\nfunc (tc *TencentCloud) request(action string, data interface{}, result interface{}) (err error) {\n\tjsonStr := make([]byte, 0)\n\tif data != nil {\n\t\tjsonStr, _ = json.Marshal(data)\n\t}\n\treq, err := http.NewRequest(\n\t\t\"POST\",\n\t\ttencentCloudEndPoint,\n\t\tbytes.NewBuffer(jsonStr),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\treq.Header.Set(\"X-TC-Version\", tencentCloudVersion)\n\n\tutil.TencentCloudSigner(tc.DNS.ID, tc.DNS.Secret, req, action, string(jsonStr), util.DnsPod)\n\n\tclient := tc.httpClient\n\tresp, err := client.Do(req)\n\terr = util.GetHTTPResponse(resp, err, result)\n\n\treturn\n}\n"
  },
  {
    "path": "dns/traffic_route.go",
    "content": "package dns\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strconv\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// TrafficRoute 火山引擎DNS服务\ntype TrafficRoute struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\n// TrafficRouteMeta 解析记录\ntype TrafficRouteMeta struct {\n\tZID      int    `json:\"ZID\"`      // 域名ID\n\tRecordID string `json:\"RecordID\"` // 解析记录ID\n\tHost     string `json:\"Host\"`     // 主机记录\n\tType     string `json:\"Type\"`     // 记录类型\n\tValue    string `json:\"Value\"`    // 记录值\n\tTTL      int    `json:\"TTL\"`      // TTL值\n\tLine     string `json:\"Line\"`     // 解析线路\n}\n\n// TrafficRouteResp API响应通用结构\ntype TrafficRouteResp struct {\n\tResponseMetadata struct {\n\t\tRequestId string `json:\"RequestId\"`\n\t\tAction    string `json:\"Action\"`\n\t\tVersion   string `json:\"Version\"`\n\t\tService   string `json:\"Service\"`\n\t\tRegion    string `json:\"Region\"`\n\t\tError     struct {\n\t\t\tCode    string `json:\"Code\"`\n\t\t\tMessage string `json:\"Message\"`\n\t\t} `json:\"Error\"`\n\t} `json:\"ResponseMetadata\"`\n\tResult struct {\n\t\t// 域名列表相关字段\n\t\tZones []struct {\n\t\t\tZID         int    `json:\"ZID\"`\n\t\t\tZoneName    string `json:\"ZoneName\"`\n\t\t\tRecordCount int    `json:\"RecordCount\"`\n\t\t} `json:\"Zones,omitempty\"`\n\t\tTotal int `json:\"Total,omitempty\"`\n\n\t\t// 解析记录相关字段\n\t\tRecords    []TrafficRouteMeta `json:\"Records,omitempty\"`\n\t\tTotalCount int                `json:\"TotalCount,omitempty\"`\n\n\t\t// 创建/更新记录相关字段\n\t\tRecordID string `json:\"RecordID,omitempty\"`\n\t\tStatus   bool   `json:\"Status,omitempty\"`\n\t} `json:\"Result\"`\n}\n\n// TrafficRouteListZonesParams ListZones查询参数\ntype TrafficRouteListZonesParams struct {\n\tKey string `json:\"Key,omitempty\"` // 获取包含特定关键字的域名(默认模糊搜索)\n}\n\n// TrafficRouteListZonesResp\ntype TrafficRouteListZonesResp struct {\n\tZID int `json:\"ZID\"` // 域名ID\n}\n\nfunc (tr *TrafficRoute) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\ttr.Domains.Ipv4Cache = ipv4cache\n\ttr.Domains.Ipv6Cache = ipv6cache\n\ttr.DNS = dnsConf.DNS\n\ttr.Domains.GetNewIp(dnsConf)\n\tif dnsConf.TTL == \"\" {\n\t\ttr.TTL = 600\n\t} else {\n\t\tttl, err := strconv.Atoi(dnsConf.TTL)\n\t\tif err != nil {\n\t\t\ttr.TTL = 600\n\t\t} else {\n\t\t\ttr.TTL = ttl\n\t\t}\n\t}\n\ttr.httpClient = dnsConf.GetHTTPClient()\n}\n\n// AddUpdateDomainRecords 添加或更新IPv4/IPv6记录\nfunc (tr *TrafficRoute) AddUpdateDomainRecords() config.Domains {\n\ttr.addUpdateDomainRecords(\"A\")\n\ttr.addUpdateDomainRecords(\"AAAA\")\n\treturn tr.Domains\n}\n\nfunc (tr *TrafficRoute) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := tr.Domains.GetNewIpResult(recordType)\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tfor _, domain := range domains {\n\t\tresp := TrafficRouteListZonesResp{}\n\t\ttr.getZID(domain, &resp)\n\t\tzoneID := resp.ZID\n\n\t\tvar recordResp TrafficRouteResp\n\t\ttr.request(\n\t\t\t\"GET\",\n\t\t\t\"ListRecords\",\n\t\t\tmap[string][]string{\n\t\t\t\t\"ZID\":        {strconv.Itoa(zoneID)},\n\t\t\t\t\"Type\":       {recordType},\n\t\t\t\t\"Host\":       {domain.GetSubDomain()},\n\t\t\t\t\"SearchMode\": {\"exact\"},\n\t\t\t\t\"PageNumber\": {\"1\"},\n\t\t\t\t\"PageSize\":   {\"500\"},\n\t\t\t},\n\t\t\t&recordResp,\n\t\t)\n\n\t\tfound := false\n\t\tfor _, record := range recordResp.Result.Records {\n\t\t\tif record.Type == recordType && record.Host == domain.GetSubDomain() {\n\t\t\t\ttr.modify(record, domain, ipAddr)\n\t\t\t\tfound = true\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif !found {\n\t\t\ttr.create(zoneID, domain, recordType, ipAddr)\n\t\t}\n\t}\n}\n\n// getZID 获取域名的ZID\nfunc (tr *TrafficRoute) getZID(domain *config.Domain, resp *TrafficRouteListZonesResp) {\n\tvar result TrafficRouteResp\n\terr := tr.request(\n\t\t\"GET\",\n\t\t\"ListZones\",\n\t\tmap[string][]string{\"Key\": {domain.DomainName}},\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif len(result.Result.Zones) == 0 {\n\t\tutil.Log(\"在DNS服务商中未找到域名: %s\", domain.DomainName)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tfor _, zone := range result.Result.Zones {\n\t\tif zone.ZoneName == domain.DomainName {\n\t\t\tresp.ZID = zone.ZID\n\t\t\treturn\n\t\t}\n\t}\n}\n\n// create 添加解析记录\nfunc (tr *TrafficRoute) create(zoneID int, domain *config.Domain, recordType, ipAddr string) {\n\trecord := &TrafficRouteMeta{\n\t\tZID:   zoneID,\n\t\tHost:  domain.GetSubDomain(),\n\t\tType:  recordType,\n\t\tValue: ipAddr,\n\t\tTTL:   tr.TTL,\n\t\tLine:  \"default\",\n\t}\n\n\tvar result TrafficRouteResp\n\terr := tr.request(\n\t\t\"POST\",\n\t\t\"CreateRecord\",\n\t\trecord,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif result.ResponseMetadata.Error.Code == \"\" {\n\t\tutil.Log(\"新增域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"新增域名解析 %s 失败! 异常信息: %s\", domain, result.ResponseMetadata.Error.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// modify 修改解析记录\nfunc (tr *TrafficRoute) modify(record TrafficRouteMeta, domain *config.Domain, ipAddr string) {\n\tif record.Value == ipAddr {\n\t\tutil.Log(\"IP %s 没有变化，域名 %s\", ipAddr, domain)\n\t\tdomain.UpdateStatus = config.UpdatedNothing\n\t\treturn\n\t}\n\n\trecord.Value = ipAddr\n\trecord.TTL = tr.TTL\n\n\tvar result TrafficRouteResp\n\terr := tr.request(\n\t\t\"POST\",\n\t\t\"UpdateRecord\",\n\t\trecord,\n\t\t&result,\n\t)\n\n\tif err != nil {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\treturn\n\t}\n\n\tif result.ResponseMetadata.Error.Code == \"\" {\n\t\tutil.Log(\"更新域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t} else {\n\t\tutil.Log(\"更新域名解析 %s 失败! 异常信息: %s\", domain, result.ResponseMetadata.Error.Message)\n\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t}\n}\n\n// parseRequestParams 解析请求参数\nfunc (tr *TrafficRoute) parseRequestParams(action string, data interface{}) (queryParams map[string][]string, jsonStr []byte, err error) {\n\tqueryParams = make(map[string][]string)\n\n\tswitch v := data.(type) {\n\tcase map[string][]string:\n\t\tqueryParams = v\n\t\tjsonStr = []byte{}\n\tcase *TrafficRouteMeta:\n\t\tjsonStr, _ = json.Marshal(v)\n\tdefault:\n\t\tif data != nil {\n\t\t\tjsonStr, _ = json.Marshal(data)\n\t\t}\n\t}\n\n\t// 根据不同action处理参数\n\tswitch action {\n\tcase \"ListZones\":\n\t\tif len(queryParams) == 0 && len(jsonStr) > 0 {\n\t\t\tvar params TrafficRouteListZonesParams\n\t\t\tif err = json.Unmarshal(jsonStr, &params); err == nil && params.Key != \"\" {\n\t\t\t\tqueryParams[\"Key\"] = []string{params.Key}\n\t\t\t}\n\t\t\tjsonStr = []byte{}\n\t\t}\n\tcase \"ListRecords\":\n\t\tif len(queryParams) == 0 && len(jsonStr) > 0 {\n\t\t\tvar params TrafficRouteListZonesResp\n\t\t\tif err = json.Unmarshal(jsonStr, &params); err == nil && params.ZID != 0 {\n\t\t\t\tqueryParams[\"ZID\"] = []string{strconv.Itoa(params.ZID)}\n\t\t\t}\n\t\t\tjsonStr = []byte{}\n\t\t}\n\t}\n\n\treturn\n}\n\n// request 统一请求接口\nfunc (tr *TrafficRoute) request(method string, action string, data interface{}, result interface{}) error {\n\tqueryParams, jsonStr, err := tr.parseRequestParams(action, data)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treq, err := util.TrafficRouteSigner(method, queryParams, map[string]string{}, tr.DNS.ID, tr.DNS.Secret, action, jsonStr)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tclient := tr.httpClient\n\tresp, err := client.Do(req)\n\treturn util.GetHTTPResponse(resp, err, result)\n}\n"
  },
  {
    "path": "dns/vercel.go",
    "content": "package dns\n\nimport (\n\t\"bytes\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\ntype Vercel struct {\n\tDNS        config.DNS\n\tDomains    config.Domains\n\tTTL        int\n\thttpClient *http.Client\n}\n\ntype ListExistingRecordsResponse struct {\n\tRecords []Record `json:\"records\"`\n}\n\ntype Record struct {\n\tID        string  `json:\"id\"` // 记录ID\n\tSlug      string  `json:\"slug\"`\n\tName      string  `json:\"name\"`  // 记录名称\n\tType      string  `json:\"type\"`  // 记录类型\n\tValue     string  `json:\"value\"` // 记录值\n\tCreator   string  `json:\"creator\"`\n\tCreated   int64   `json:\"created\"`\n\tUpdated   int64   `json:\"updated\"`\n\tCreatedAt int64   `json:\"createdAt\"`\n\tUpdatedAt int64   `json:\"updatedAt\"`\n\tTTL       int64   `json:\"ttl\"`\n\tComment   *string `json:\"comment,omitempty\"`\n}\n\nfunc (v *Vercel) Init(dnsConf *config.DnsConfig, ipv4cache *util.IpCache, ipv6cache *util.IpCache) {\n\tv.Domains.Ipv4Cache = ipv4cache\n\tv.Domains.Ipv6Cache = ipv6cache\n\tv.DNS = dnsConf.DNS\n\tv.Domains.GetNewIp(dnsConf)\n\n\t// Must be greater than 60\n\tttl, err := strconv.Atoi(dnsConf.TTL)\n\tif err != nil {\n\t\tttl = 60\n\t}\n\tif ttl < 60 {\n\t\tttl = 60\n\t}\n\tv.TTL = ttl\n\tv.httpClient = dnsConf.GetHTTPClient()\n}\n\nfunc (v *Vercel) AddUpdateDomainRecords() (domains config.Domains) {\n\tv.addUpdateDomainRecords(\"A\")\n\tv.addUpdateDomainRecords(\"AAAA\")\n\treturn v.Domains\n}\n\nfunc (v *Vercel) addUpdateDomainRecords(recordType string) {\n\tipAddr, domains := v.Domains.GetNewIpResult(recordType)\n\n\tif ipAddr == \"\" {\n\t\treturn\n\t}\n\n\tipAddr = strings.ToLower(ipAddr)\n\n\tvar (\n\t\trecords []Record\n\t\terr     error\n\t)\n\tfor _, domain := range domains {\n\t\trecords, err = v.listExistingRecords(domain)\n\t\tif err != nil {\n\t\t\tutil.Log(\"查询域名信息发生异常! %s\", err)\n\t\t\tcontinue\n\t\t}\n\n\t\tvar targetRecord *Record\n\t\tfor _, record := range records {\n\t\t\tif record.Name == domain.SubDomain {\n\t\t\t\ttargetRecord = &record\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif targetRecord == nil {\n\t\t\terr = v.createRecord(domain, recordType, ipAddr)\n\t\t} else {\n\t\t\tif strings.ToLower(targetRecord.Value) == ipAddr {\n\t\t\t\tutil.Log(\"你的IP %s 没有变化, 域名 %s\", ipAddr, domain)\n\t\t\t\tdomain.UpdateStatus = config.UpdatedNothing\n\t\t\t\tcontinue\n\t\t\t} else {\n\t\t\t\terr = v.updateRecord(targetRecord, recordType, ipAddr)\n\t\t\t}\n\t\t}\n\n\t\toperation := \"新增\"\n\t\tif targetRecord != nil {\n\t\t\toperation = \"更新\"\n\t\t}\n\t\tif err == nil {\n\t\t\tutil.Log(operation+\"域名解析 %s 成功! IP: %s\", domain, ipAddr)\n\t\t\tdomain.UpdateStatus = config.UpdatedSuccess\n\t\t} else {\n\t\t\tutil.Log(operation+\"域名解析 %s 失败! 异常信息: %s\", domain, err)\n\t\t\tdomain.UpdateStatus = config.UpdatedFailed\n\t\t}\n\t}\n}\n\nfunc (v *Vercel) listExistingRecords(domain *config.Domain) (records []Record, err error) {\n\tvar result ListExistingRecordsResponse\n\terr = v.request(http.MethodGet, \"https://api.vercel.com/v4/domains/\"+domain.DomainName+\"/records\", nil, &result)\n\tif err != nil {\n\t\treturn\n\t}\n\trecords = result.Records\n\treturn\n}\n\nfunc (v *Vercel) createRecord(domain *config.Domain, recordType string, recordValue string) (err error) {\n\terr = v.request(http.MethodPost, \"https://api.vercel.com/v2/domains/\"+domain.DomainName+\"/records\", map[string]interface{}{\n\t\t\"name\":    domain.SubDomain,\n\t\t\"type\":    recordType,\n\t\t\"value\":   recordValue,\n\t\t\"ttl\":     v.TTL,\n\t\t\"comment\": \"Created by ddns-go\",\n\t}, nil)\n\treturn\n}\n\nfunc (v *Vercel) updateRecord(record *Record, recordType string, recordValue string) (err error) {\n\terr = v.request(http.MethodPatch, \"https://api.vercel.com/v1/domains/records/\"+record.ID, map[string]interface{}{\n\t\t\"type\":  recordType,\n\t\t\"value\": recordValue,\n\t\t\"ttl\":   v.TTL,\n\t}, nil)\n\treturn\n}\n\nfunc (v *Vercel) request(method, api string, data, result interface{}) (err error) {\n\tvar payload []byte\n\tif data != nil {\n\t\tpayload, _ = json.Marshal(data)\n\t}\n\n\t// 如果设置了 ExtParam (TeamId)，添加查询参数\n\tif v.DNS.ExtParam != \"\" {\n\t\tif strings.Contains(api, \"?\") {\n\t\t\tapi = api + \"&teamId=\" + v.DNS.ExtParam\n\t\t} else {\n\t\t\tapi = api + \"?teamId=\" + v.DNS.ExtParam\n\t\t}\n\t}\n\n\treq, err := http.NewRequest(\n\t\tmethod,\n\t\tapi,\n\t\tbytes.NewBuffer(payload),\n\t)\n\tif err != nil {\n\t\treturn\n\t}\n\treq.Header.Set(\"Authorization\", \"Bearer \"+v.DNS.Secret)\n\treq.Header.Set(\"Content-Type\", \"application/json\")\n\n\tclient := v.httpClient\n\tresp, err := client.Do(req)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif resp.StatusCode != 200 {\n\t\treturn fmt.Errorf(\"Vercel API returned status code %d\", resp.StatusCode)\n\t}\n\tif result != nil {\n\t\terr = util.GetHTTPResponse(resp, err, result)\n\t}\n\treturn\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/jeessy2/ddns-go/v6\n\ngo 1.25.0\n\nrequire (\n\tgithub.com/kardianos/service v1.2.4\n\tgithub.com/wagslane/go-password-validator v0.3.0\n\tgolang.org/x/crypto v0.48.0\n\tgolang.org/x/net v0.51.0\n\tgopkg.in/yaml.v3 v3.0.1\n)\n\nrequire (\n\tgolang.org/x/sys v0.41.0 // indirect\n\tgolang.org/x/text v0.34.0\n)\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"embed\"\n\t\"errors\"\n\t\"flag\"\n\t\"fmt\"\n\t\"log\"\n\t\"net\"\n\t\"net/http\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"time\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/dns\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"github.com/jeessy2/ddns-go/v6/util/osutil\"\n\t\"github.com/jeessy2/ddns-go/v6/util/update\"\n\t\"github.com/jeessy2/ddns-go/v6/web\"\n\t\"github.com/kardianos/service\"\n)\n\n// ddns-go 版本\n// ddns-go version\nvar versionFlag = flag.Bool(\"v\", false, \"ddns-go version\")\n\n// 更新 ddns-go\nvar updateFlag = flag.Bool(\"u\", false, \"Upgrade ddns-go to the latest version\")\n\n// 监听地址\nvar listen = flag.String(\"l\", \":9876\", \"Listen address\")\n\n// 更新频率(秒)\nvar every = flag.Int(\"f\", 300, \"Update frequency(seconds)\")\n\n// 缓存次数\nvar ipCacheTimes = flag.Int(\"cacheTimes\", 5, \"Cache times\")\n\n// 服务管理\nvar serviceType = flag.String(\"s\", \"\", \"Service management (install|uninstall|restart)\")\n\n// 配置文件路径\nvar configFilePath = flag.String(\"c\", util.GetConfigFilePathDefault(), \"Custom configuration file path\")\n\n// Web 服务\nvar noWebService = flag.Bool(\"noweb\", false, \"No web service\")\n\n// 跳过验证证书\nvar skipVerify = flag.Bool(\"skipVerify\", false, \"Skip certificate verification\")\n\n// 自定义 DNS 服务器\nvar customDNS = flag.String(\"dns\", \"\", \"Custom DNS server address, example: 8.8.8.8\")\n\n// 重置密码\nvar newPassword = flag.String(\"resetPassword\", \"\", \"Reset password to the one entered\")\n\n// 后台运行\nvar daemonize = flag.Bool(\"d\", false, \"Run in background (daemon/detached)\")\n\n//go:embed static\nvar staticEmbeddedFiles embed.FS\n\n//go:embed favicon.ico\nvar faviconEmbeddedFile embed.FS\n\n// version\nvar version = \"DEV\"\n\nfunc main() {\n\tflag.Parse()\n\tif *versionFlag {\n\t\tfmt.Println(version)\n\t\treturn\n\t}\n\tif *updateFlag {\n\t\tupdate.Self(version)\n\t\treturn\n\t}\n\n\tif *daemonize && os.Getenv(\"DDNS_GO_DAEMON\") != \"1\" {\n\t\tif err := runAsDaemon(); err != nil {\n\t\t\tlog.Fatalf(\"Daemonize failed: %v\", err)\n\t\t}\n\t\treturn\n\t}\n\n\t// 安卓 go/src/time/zoneinfo_android.go 固定localLoc 为 UTC\n\tif runtime.GOOS == \"android\" {\n\t\tutil.FixTimezone()\n\t}\n\t// 检查监听地址\n\tif _, err := net.ResolveTCPAddr(\"tcp\", *listen); err != nil {\n\t\tlog.Fatalf(\"Parse listen address failed! Exception: %s\", err)\n\t}\n\t// 设置版本号\n\tos.Setenv(web.VersionEnv, version)\n\t// 设置配置文件路径\n\tif *configFilePath != \"\" {\n\t\tabsPath, _ := filepath.Abs(*configFilePath)\n\t\tos.Setenv(util.ConfigFilePathENV, absPath)\n\t}\n\t// 重置密码\n\tif *newPassword != \"\" {\n\t\tconf, err := config.GetConfigCached()\n\t\tif err == nil {\n\t\t\tconf.ResetPassword(*newPassword)\n\t\t} else {\n\t\t\tutil.Log(\"配置文件 %s 不存在, 可通过-c指定配置文件\", *configFilePath)\n\t\t}\n\t\treturn\n\t}\n\t// 设置跳过证书验证\n\tif *skipVerify {\n\t\tutil.SetInsecureSkipVerify()\n\t}\n\t// 设置自定义DNS\n\tif *customDNS != \"\" {\n\t\tutil.SetDNS(*customDNS)\n\t}\n\tos.Setenv(util.IPCacheTimesENV, strconv.Itoa(*ipCacheTimes))\n\tswitch *serviceType {\n\tcase \"install\":\n\t\tinstallService()\n\tcase \"uninstall\":\n\t\tuninstallService()\n\tcase \"restart\":\n\t\trestartService()\n\tdefault:\n\t\tif util.IsRunInDocker() || os.Getenv(\"DDNS_GO_DAEMON\") == \"1\" {\n\t\t\trun()\n\t\t} else {\n\t\t\ts := getService()\n\t\t\tstatus, _ := s.Status()\n\t\t\tif status != service.StatusUnknown {\n\t\t\t\t// 以服务方式运行\n\t\t\t\ts.Run()\n\t\t\t} else {\n\t\t\t\t// 非服务方式运行\n\t\t\t\tswitch s.Platform() {\n\t\t\t\tcase \"windows-service\":\n\t\t\t\t\tutil.Log(\"可使用 .\\\\ddns-go.exe -s install 安装服务运行\")\n\t\t\t\tdefault:\n\t\t\t\t\tutil.Log(\"可使用 sudo ./ddns-go -s install 安装服务运行\")\n\t\t\t\t}\n\t\t\t\trun()\n\t\t\t}\n\t\t}\n\t}\n}\n\nfunc run() {\n\t// 兼容之前的配置文件\n\tconf, _ := config.GetConfigCached()\n\tconf.CompatibleConfig()\n\t// 初始化语言\n\tutil.InitLogLang(conf.Lang)\n\n\tif !*noWebService {\n\t\tgo func() {\n\t\t\t// 启动web服务\n\t\t\terr := runWebServer()\n\t\t\tif err != nil {\n\t\t\t\tlog.Println(err)\n\t\t\t\ttime.Sleep(time.Minute)\n\t\t\t\tos.Exit(1)\n\t\t\t}\n\t\t}()\n\t}\n\n\t// 初始化备用DNS\n\tutil.InitBackupDNS(*customDNS, conf.Lang)\n\n\t// 等待网络连接\n\tutil.WaitInternet(dns.Addresses)\n\n\t// 定时运行\n\tdns.RunTimer(time.Duration(*every) * time.Second)\n}\n\nfunc staticFsFunc(writer http.ResponseWriter, request *http.Request) {\n\thttp.FileServer(http.FS(staticEmbeddedFiles)).ServeHTTP(writer, request)\n}\n\nfunc faviconFsFunc(writer http.ResponseWriter, request *http.Request) {\n\thttp.FileServer(http.FS(faviconEmbeddedFile)).ServeHTTP(writer, request)\n}\n\nfunc runWebServer() error {\n\t// 启动静态文件服务\n\thttp.HandleFunc(\"/static/\", web.AuthAssert(staticFsFunc))\n\thttp.HandleFunc(\"/favicon.ico\", web.AuthAssert(faviconFsFunc))\n\thttp.HandleFunc(\"/login\", web.AuthAssert(web.Login))\n\thttp.HandleFunc(\"/loginFunc\", web.AuthAssert(web.LoginFunc))\n\n\thttp.HandleFunc(\"/\", web.Auth(web.Writing))\n\thttp.HandleFunc(\"/save\", web.Auth(web.Save))\n\thttp.HandleFunc(\"/logs\", web.Auth(web.Logs))\n\thttp.HandleFunc(\"/clearLog\", web.Auth(web.ClearLog))\n\thttp.HandleFunc(\"/webhookTest\", web.Auth(web.WebhookTest))\n\thttp.HandleFunc(\"/logout\", web.Auth(web.Logout))\n\n\tutil.Log(\"监听 %s\", *listen)\n\n\tl, err := net.Listen(\"tcp\", *listen)\n\tif err != nil {\n\t\treturn errors.New(util.LogStr(\"监听端口发生异常, 请检查端口是否被占用! %s\", err))\n\t}\n\n\treturn http.Serve(l, nil)\n}\n\n// 以守护/分离进程方式运行（Unix 使用 setsid，Windows 使用 DETACHED_PROCESS）\nfunc runAsDaemon() error {\n\texe, err := os.Executable()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 过滤掉 -d 参数\n\targs := make([]string, 0, len(os.Args))\n\targs = append(args, exe)\n\tfor i := 1; i < len(os.Args); i++ {\n\t\tif os.Args[i] == \"-d\" {\n\t\t\tcontinue\n\t\t}\n\t\targs = append(args, os.Args[i])\n\t}\n\n\t// 重定向到系统空设备\n\tnullFile, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer nullFile.Close()\n\n\tproc, err := osutil.StartDetachedProcess(exe, args, nullFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn proc.Release()\n}\n\ntype program struct{}\n\nfunc (p *program) Start(s service.Service) error {\n\t// Start should not block. Do the actual work async.\n\tgo p.run()\n\treturn nil\n}\nfunc (p *program) run() {\n\trun()\n}\nfunc (p *program) Stop(s service.Service) error {\n\t// Stop should not block. Return with a few seconds.\n\treturn nil\n}\n\nfunc getService() service.Service {\n\toptions := make(service.KeyValue)\n\tvar depends []string\n\n\t// 确保服务等待网络就绪后再启动\n\tswitch service.ChosenSystem().String() {\n\tcase \"unix-systemv\":\n\t\toptions[\"SysvScript\"] = sysvScript\n\tcase \"windows-service\":\n\t\t// 将 Windows 服务的启动类型设为自动(延迟启动)\n\t\toptions[\"DelayedAutoStart\"] = true\n\tdefault:\n\t\t// 向 Systemd 添加网络依赖\n\t\tdepends = append(depends, \"Requires=network.target\",\n\t\t\t\"After=network-online.target\")\n\t}\n\n\tsvcConfig := &service.Config{\n\t\tName:         \"ddns-go\",\n\t\tDisplayName:  \"ddns-go\",\n\t\tDescription:  \"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...)\",\n\t\tArguments:    []string{\"-l\", *listen, \"-f\", strconv.Itoa(*every), \"-cacheTimes\", strconv.Itoa(*ipCacheTimes), \"-c\", *configFilePath},\n\t\tDependencies: depends,\n\t\tOption:       options,\n\t}\n\n\tif *noWebService {\n\t\tsvcConfig.Arguments = append(svcConfig.Arguments, \"-noweb\")\n\t}\n\n\tif *skipVerify {\n\t\tsvcConfig.Arguments = append(svcConfig.Arguments, \"-skipVerify\")\n\t}\n\n\tif *customDNS != \"\" {\n\t\tsvcConfig.Arguments = append(svcConfig.Arguments, \"-dns\", *customDNS)\n\t}\n\n\tprg := &program{}\n\ts, err := service.New(prg, svcConfig)\n\tif err != nil {\n\t\tlog.Fatalln(err)\n\t}\n\treturn s\n}\n\n// 卸载服务\nfunc uninstallService() {\n\ts := getService()\n\ts.Stop()\n\tif service.ChosenSystem().String() == \"unix-systemv\" {\n\t\tif _, err := exec.Command(\"/etc/init.d/ddns-go\", \"stop\").Output(); err != nil {\n\t\t\tlog.Println(err)\n\t\t}\n\t}\n\tif err := s.Uninstall(); err == nil {\n\t\tutil.Log(\"ddns-go 服务卸载成功\")\n\t} else {\n\t\tutil.Log(\"ddns-go 服务卸载失败, 异常信息: %s\", err)\n\t}\n}\n\n// 安装服务\nfunc installService() {\n\ts := getService()\n\n\tstatus, err := s.Status()\n\tif err != nil && status == service.StatusUnknown {\n\t\t// 服务未知，创建服务\n\t\tif err = s.Install(); err == nil {\n\t\t\ts.Start()\n\t\t\tutil.Log(\"安装 ddns-go 服务成功! 请打开浏览器并进行配置\")\n\t\t\tif service.ChosenSystem().String() == \"unix-systemv\" {\n\t\t\t\tif _, err := exec.Command(\"/etc/init.d/ddns-go\", \"enable\").Output(); err != nil {\n\t\t\t\t\tlog.Println(err)\n\t\t\t\t}\n\t\t\t\tif _, err := exec.Command(\"/etc/init.d/ddns-go\", \"start\").Output(); err != nil {\n\t\t\t\t\tlog.Println(err)\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn\n\t\t}\n\t\tutil.Log(\"安装 ddns-go 服务失败, 异常信息: %s\", err)\n\t}\n\n\tif status != service.StatusUnknown {\n\t\tutil.Log(\"ddns-go 服务已安装, 无需再次安装\")\n\t}\n}\n\n// 重启服务\nfunc restartService() {\n\ts := getService()\n\tstatus, err := s.Status()\n\tif err == nil {\n\t\tif status == service.StatusRunning {\n\t\t\tif err = s.Restart(); err == nil {\n\t\t\t\tutil.Log(\"重启 ddns-go 服务成功\")\n\t\t\t}\n\t\t} else if status == service.StatusStopped {\n\t\t\tif err = s.Start(); err == nil {\n\t\t\t\tutil.Log(\"启动 ddns-go 服务成功\")\n\t\t\t}\n\t\t}\n\t} else {\n\t\tutil.Log(\"ddns-go 服务未安装, 请先安装服务\")\n\t}\n}\n\nconst sysvScript = `#!/bin/sh /etc/rc.common\nDESCRIPTION=\"{{.Description}}\"\ncmd=\"{{.Path}}{{range .Arguments}} {{.|cmd}}{{end}}\"\nname=\"ddns-go\"\npid_file=\"/var/run/$name.pid\"\nstdout_log=\"/var/log/$name.log\"\nstderr_log=\"/var/log/$name.err\"\nSTART=99\nget_pid() {\n    cat \"$pid_file\"\n}\nis_running() {\n    [ -f \"$pid_file\" ] && cat /proc/$(get_pid)/stat > /dev/null 2>&1\n}\nstart() {\n\tif is_running; then\n\t\techo \"Already started\"\n\telse\n\t\techo \"Starting $name\"\n\t\t{{if .WorkingDirectory}}cd '{{.WorkingDirectory}}'{{end}}\n\t\t$cmd >> \"$stdout_log\" 2>> \"$stderr_log\" &\n\t\techo $! > \"$pid_file\"\n\t\tif ! is_running; then\n\t\t\techo \"Unable to start, see $stdout_log and $stderr_log\"\n\t\t\texit 1\n\t\tfi\n\tfi\n}\nstop() {\n\tif is_running; then\n\t\techo -n \"Stopping $name..\"\n\t\tkill $(get_pid)\n\t\tfor i in $(seq 1 10)\n\t\tdo\n\t\t\tif ! is_running; then\n\t\t\t\tbreak\n\t\t\tfi\n\t\t\techo -n \".\"\n\t\t\tsleep 1\n\t\tdone\n\t\techo\n\t\tif is_running; then\n\t\t\techo \"Not stopped; may still be shutting down or shutdown may have failed\"\n\t\t\texit 1\n\t\telse\n\t\t\techo \"Stopped\"\n\t\t\tif [ -f \"$pid_file\" ]; then\n\t\t\t\trm \"$pid_file\"\n\t\t\tfi\n\t\tfi\n\telse\n\t\techo \"Not running\"\n\tfi\n}\nrestart() {\n\tstop\n\tif is_running; then\n\t\techo \"Unable to stop, will not attempt to start\"\n\t\texit 1\n\tfi\n\tstart\n}\n`\n"
  },
  {
    "path": "static/common.css",
    "content": ":root {\n    color-scheme: light;\n    --bg-color: #f2f3f8;\n    --text-color: black;\n}\n\n[data-theme=\"dark\"] {\n    color-scheme: dark;\n    --bg-color: #22272e;\n    --text-color: #adbac7;\n}\n\nbody {\n    background-color: var(--bg-color) !important;\n}\n\n#mask {\n    background-color: #00000088;\n    height: 100%;\n    width: 100%;\n    position: absolute;\n    z-index: 1;\n}\n\n[data-theme='dark'] .form-control {\n    background-color: #1c2128 !important;\n    border-color: #444c56 !important;\n    color: var(--text-color) !important;\n}\n\n[data-theme='dark'] .row {\n    background-color: var(--bg-color);\n    color: var(--text-color);\n}\n\n.portlet {\n    display: -webkit-box;\n    display: flex;\n    -webkit-box-flex: 1;\n    flex-grow: 1;\n    -webkit-box-orient: vertical;\n    -webkit-box-direction: normal;\n    flex-direction: column;\n    box-shadow: 0px 0px 13px 3px rgba(82, 63, 105, 0.05);\n    background-color: #ffffff;\n    margin-bottom: 20px;\n    border-radius: 4px;\n}\n\n[data-theme='dark'] .portlet {\n    background-color: #32353b;\n    color: #adbac7;\n    border: 2px solid #444c56;\n    border-radius: 5px;\n    box-shadow: unset;\n}\n\n.portlet .portlet__head {\n    display: flex;\n    -webkit-box-align: stretch;\n    -webkit-box-pack: justify;\n    justify-content: space-between;\n    position: relative;\n    padding: 0 20px;\n    margin: 0;\n    border-bottom: 1px solid #ebedf2;\n    min-height: 60px;\n    border-top-left-radius: 4px;\n    border-top-right-radius: 4px;\n    align-items: center;\n    font-size: 1.2rem;\n    font-weight: 540;\n    color: #48465b;\n}\n\n[data-theme='dark'] .portlet .portlet__head {\n    border-bottom: 1px solid #444c56;\n    background-color: #2d333b !important;\n    color: #adbac7;\n}\n\n.portlet .portlet__body {\n    display: -webkit-box;\n    display: -ms-flexbox;\n    -webkit-box-orient: vertical;\n    -webkit-box-direction: normal;\n    -ms-flex-direction: column;\n    flex-direction: column;\n    padding: 20px;\n    border-radius: 4px;\n}\n\n[data-theme='dark'] .portlet__body {\n    background-color: #22272e !important;\n}\n\n.navbar {\n    color: #adbac7;\n    position: fixed !important;\n    width: 100vw;\n    z-index: 2;\n    height: 3.5rem;\n}\n\nmain {\n    position: relative;\n    padding-top: 3.5rem;\n    overflow: hidden;\n}\n\n[data-theme='dark'] .navbar {\n    background-image: linear-gradient(#2d333b, #22272e) !important;\n}\n\n\n[data-theme='dark'] .form {\n    background-color: #1c2128 !important;\n    color: #adbac7 !important;\n    border-radius: 5px !important;\n    border-color: #444c56 !important;\n}\n\n[data-theme='dark'] .form-group {\n    background-color: transparent !important;\n}\n\n#logsBtn {\n    position: relative;\n    margin-left: auto;\n    margin-right: 25px;\n}\n\n.unread:after {\n    content: '';\n    position: absolute;\n    top: 0;\n    right: 0;\n    width: 8px;\n    height: 8px;\n    border-radius: 50%;\n    background-color: #ff0000;\n}\n\n.theme-button {\n    background-color: transparent;\n    cursor: pointer;\n    font-size: 15px;\n    margin-right: 25px;\n}\n\n.theme-button:hover {\n    box-shadow: 0px 0px 15px #0d0d0dab;\n}\n\n.theme-button:active {\n    transform: scale(0.98);\n}\n\n#logs {\n    max-height: 50vh !important;\n    height: 600px !important;\n    margin-bottom: 10px;\n    overflow-y: auto;\n    font-size: 13px !important;\n    background-color: #f6f6f6;\n}\n\n.logs-panel {\n    z-index: 2;\n    background-color: rgb(255, 255, 255);\n    color: var(--text-color);\n    border-radius: 10px;\n    padding: 15px !important;\n    padding-bottom: 10px !important;\n    border: 1px solid #cbcbcb;\n    box-shadow: 0px 0px 13px 3px rgba(52, 52, 52, 0.226);\n}\n\n[data-theme='dark'] .logs-panel {\n    background-color: #22272e;\n    color: #adbac7;\n    border: 1px solid #444c56;\n    box-shadow: unset;\n}\n\n.col-md-6.logs-panel {\n    position: fixed;\n    left: 0;\n}\n\n#msg-container {\n    pointer-events: none;\n    z-index: 3;\n    position: fixed;\n    width: 100vw;\n    padding: 0 5vw;\n    display: flex;\n    flex-direction: column;\n    align-items: center;\n    top: 0;\n}\n\n#msg-container .msg {\n    pointer-events: all;\n    padding: 9px 12px;\n    text-align: center;\n    line-height: 1.5714285714285714;\n    border-radius: 8px;\n    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);\n    font-size: 14px;\n    background-color: #ffffff;\n    margin: 8px 0;\n    color: var(--text-color);\n    transition: all 0.2s ease-in;\n}\n\n[data-theme='dark'] #msg-container .msg {\n    background-color: #1f1f1f;\n}\n\n#msg-container .msg-fade {\n    opacity: 0;\n    transform: translateY(-1rem) scale(0.6);\n    transition: all 0.2s ease-in-out;\n}\n\n#msg-container .msg-icon {\n    margin-right: 8px;\n    line-height: 0;\n    text-align: center;\n    font-size: 16px;\n}\n\n.badge {\n    margin-right: 20px;\n    /* 给版本号添加右侧间距 */\n}\n\n.button-container {\n    padding: 0 !important;\n}\n\n.action-button {\n    flex: none;\n    padding: 4px 6px;\n    font-size: 14px;\n    color: white;\n    border: 1px solid white;\n    border-radius: 8px;\n    background-color: transparent;\n    text-align: center;\n    text-decoration: none;\n}\n\n.action-button:hover,\n.action-button:visited,\n.action-button:active,\n.action-button:focus {\n    color: white;\n    border-color: white;\n    background-color: transparent;\n    text-decoration: none;\n    outline: none;\n}\n\n.tooltip[x-placement^=\"top\"] .arrow,\n.tooltip[x-placement^=\"bottom\"] .arrow {\n    left: 50%;\n}\n\n.tooltip[x-placement^=\"left\"] .arrow,\n.tooltip[x-placement^=\"right\"] .arrow {\n    top: 50%;\n}\n\n.tooltip[x-placement^=\"top\"] .arrow::before,\n.tooltip[x-placement^=\"bottom\"] .arrow::before {\n    transform: translateX(-50%);\n}\n\n.tooltip[x-placement^=\"left\"] .arrow::before,\n.tooltip[x-placement^=\"right\"] .arrow::before {\n    transform: translateY(-50%);\n}\n\n/* Tooltip theme support */\n.tooltip .tooltip-inner {\n    background-color: #fff;\n    color: #000;\n    border: 1px solid #ccc;\n}\n\n.tooltip .arrow::before {\n    border-top-color: #fff;\n    border-bottom-color: #fff;\n    border-left-color: #fff;\n    border-right-color: #fff;\n}\n\n[data-theme=\"dark\"] .tooltip .tooltip-inner {\n    background-color: #2d333b;\n    color: #adbac7;\n    border: 1px solid #444c56;\n}\n\n[data-theme=\"dark\"] .tooltip.bs-tooltip-top .arrow::before {\n    border-top-color: #2d333b;\n}\n\n[data-theme=\"dark\"] .tooltip.bs-tooltip-bottom .arrow::before {\n    border-bottom-color: #2d333b;\n}\n\n[data-theme=\"dark\"] .tooltip.bs-tooltip-left .arrow::before {\n    border-left-color: #2d333b;\n}\n\n[data-theme=\"dark\"] .tooltip.bs-tooltip-right .arrow::before {\n    border-right-color: #2d333b;\n}"
  },
  {
    "path": "static/constant.js",
    "content": "const DNS_PROVIDERS = {\n  alidns: {\n    name: {\n      \"en\": \"Aliyun\",\n      \"zh-cn\": \"阿里云\",\n    },\n    idLabel: \"AccessKey ID\",\n    secretLabel: \"AccessKey Secret\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://ram.console.aliyun.com/manage/ak?spm=5176.12818093.nav-right.dak.488716d0mHaMgg'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://ram.console.aliyun.com/manage/ak?spm=5176.12818093.nav-right.dak.488716d0mHaMgg'>创建 AccessKey</a>\",\n    }\n  },\n  aliesa: {\n    name: {\n      \"en\": \"Aliyun ESA\",\n      \"zh-cn\": \"阿里云 ESA\",\n    },\n    idLabel: \"AccessKey ID\",\n    secretLabel: \"AccessKey Secret\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://ram.console.aliyun.com/manage/ak?spm=5176.12818093.nav-right.dak.488716d0mHaMgg'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://ram.console.aliyun.com/manage/ak?spm=5176.12818093.nav-right.dak.488716d0mHaMgg'>创建 AccessKey</a>\",\n    }\n  },\n  tencentcloud: {\n    name: {\n      \"en\": \"Tencent\",\n      \"zh-cn\": \"腾讯云\",\n    },\n    idLabel: \"SecretId\",\n    secretLabel: \"SecretKey\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.dnspod.cn/account/token/apikey'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.dnspod.cn/account/token/apikey'>创建腾讯云 API 密钥</a>\",\n    }\n  },\n  dnspod: {\n    name: {\n      \"en\": \"DnsPod\",\n    },\n    idLabel: \"ID\",\n    secretLabel: \"Token\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.dnspod.cn/account/token/token'>Create Token</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.dnspod.cn/account/token/token'>创建 DNSPod Token</a>\",\n    }\n  },\n  cloudflare: {\n    name: {\n      \"en\": \"Cloudflare\",\n    },\n    idLabel: \"\",\n    secretLabel: \"Token\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://dash.cloudflare.com/profile/api-tokens'>Create Token -> Edit Zone DNS (Use template)</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://dash.cloudflare.com/profile/api-tokens'>创建令牌 -> 编辑区域 DNS (使用模板)</a>\",\n    }\n  },\n  huaweicloud: {\n    name: {\n      \"en\": \"Huawei\",\n      \"zh-cn\": \"华为云\",\n    },\n    idLabel: \"Access Key Id\",\n    secretLabel: \"Secret Access Key\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.huaweicloud.com/iam/?locale=zh-cn#/mine/accessKey'>Create</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.huaweicloud.com/iam/?locale=zh-cn#/mine/accessKey'>新增访问密钥</a>\",\n    }\n  },\n  callback: {\n    name: {\n      \"en\": \"Callback\",\n    },\n    idLabel: \"URL\",\n    secretLabel: \"RequestBody\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://github.com/jeessy2/ddns-go/blob/master/README_EN.md#callback'>Callback</a> Support variables #{ip}, #{domain}, #{recordType}, #{ttl}\",\n      \"zh-cn\": \"<a target='_blank' href='https://github.com/jeessy2/ddns-go#callback'>自定义回调</a> 支持的变量 #{ip}, #{domain}, #{recordType}, #{ttl}\",\n    }\n  },\n  baiducloud: {\n    name: {\n      \"en\": \"Baidu\",\n      \"zh-cn\": \"百度云\",\n    },\n    idLabel: \"AccessKey ID\",\n    secretLabel: \"AccessKey Secret\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.bce.baidu.com/iam/?_=1651763238057#/iam/accesslist'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.bce.baidu.com/iam/?_=1651763238057#/iam/accesslist'>创建 AccessKey</a>\",\n    }\n  },\n  porkbun: {\n    name: {\n      \"en\": \"Porkbun\",\n    },\n    idLabel: \"API Key\",\n    secretLabel: \"Secret Key\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://porkbun.com/account/api'>Create Access</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://porkbun.com/account/api'>创建 Access</a>\",\n    }\n  },\n  godaddy: {\n    name: {\n      \"en\": \"GoDaddy\",\n    },\n    idLabel: \"Key\",\n    secretLabel: \"Secret\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://developer.godaddy.com/keys'>Create API KEY</a><br/><span style='color: #ff9800;'>⚠️ Note: GoDaddy API requires you to have 10 or more domains or a Pro plan</span>\",\n      \"zh-cn\": \"<a target='_blank' href='https://developer.godaddy.com/keys'>创建 API KEY</a><br/><span style='color: #ff9800;'>⚠️ 温馨提示：GoDaddy 现在需要拥有 10 个及以上的域名或 Pro Plan 才可以使用 API</span>\",\n    }\n  },\n  namecheap: {\n    name: {\n      \"en\": \"Namecheap\",\n    },\n    idLabel: \"\",\n    secretLabel: \"Password\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://www.namecheap.com/support/knowledgebase/article.aspx/36/11/how-do-i-start-using-dynamic-dns/'>How to get started</a> <span style='color: red'>Namecheap DDNS does not support updating IPv6</span>\",\n      \"zh-cn\": \"<a target='_blank' href='https://www.namecheap.com/support/knowledgebase/article.aspx/36/11/how-do-i-start-using-dynamic-dns/'>开启namecheap动态域名解析</a> <span style='color: red'>Namecheap DDNS 不支持更新 IPv6</span>\",\n    }\n  },\n  namesilo: {\n    name: {\n      \"en\": \"NameSilo\",\n    },\n    idLabel: \"\",\n    secretLabel: \"Password\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://www.namesilo.com/account/api-manager'>How to get started</a> <b>Please note that the TTL of namesilo is at least 1 hour</b>\",\n      \"zh-cn\": \"<a target='_blank' href='https://www.namesilo.com/account/api-manager'>开启namesilo动态域名解析</a> <b>请注意namesilo的TTL最低1小时</b>\",\n    }\n  },\n  vercel: {\n    name: {\n      \"en\": \"Vercel\",\n    },\n    idLabel: \"\",\n    secretLabel: \"Token\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://vercel.com/account/tokens'>Create Token</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://vercel.com/account/tokens'>创建令牌</a>\",\n    },\n    extParamLabel: \"Team ID\",\n    extParamHelpHtml: {\n      \"en\": \"Optional. If you are using a Vercel Team account, please fill in the Team ID\",\n      \"zh-cn\": \"可选项，如果您使用的是 Vercel 团队账户，请填写团队 ID\"\n    }\n  },\n  dynadot: {\n    name: {\n      \"en\": \"Dynadot\",\n    },\n    idLabel: \"\",\n    secretLabel: \"Password\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://www.dynadot.com/community/help/question/enable-DDNS'>How to get started</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://www.dynadot.com/community/help/question/enable-DDNS'>开启Dynadot动态域名解析</a>\",\n    }\n  },\n  trafficroute: {\n    name: {\n      \"en\": \"TrafficRoute\",\n      \"zh-cn\": \"火山引擎\",\n    },\n    idLabel: \"AccessKey\",\n    secretLabel: \"SecretAccessKey\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.volcengine.com/iam/keymanage/'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.volcengine.com/iam/keymanage/'>创建火山引擎 API 密钥</a>\",\n    }\n  },\n  dynv6: {\n    name: {\n      \"en\": \"Dynv6\",\n    },\n    idLabel: \"\",\n    secretLabel: \"Token\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://dynv6.com/keys'>Create Token</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://dynv6.com/keys'>创建令牌</a>\",\n    }\n  },\n  spaceship: {\n    name: {\n      \"en\": \"Spaceship\",\n    },\n    idLabel: \"API Key\",\n    secretLabel: \"API Secret\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://www.spaceship.com/application/api-manager/'>Create API Key</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://www.spaceship.com/application/api-manager/'>创建 API 密钥</a>\",\n    }\n  },\n  dnsla: {\n    name: {\n      \"en\": \"Dnsla\",\n      \"zh-cn\": \"Dnsla\",\n    },\n    idLabel: \"APIID\",\n    secretLabel: \"API密钥\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.dns.la/login?aksk=1'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.dns.la/login?aksk=1'>创建 AccessKey</a>\",\n    }\n  },\n  nowcn: {\n    name: {\n      \"en\": \"Nowcn\",\n      \"zh-cn\": \"时代互联\",\n    },\n    idLabel: \"auth-userid\",\n    secretLabel: \"api-key\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://www.now.cn/'>api-key</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://www.now.cn/'>获取 api-key</a>\",\n    }\n  },\n  eranet: {\n    name: {\n      \"en\": \"Eranet\",\n      \"zh-cn\": \"Eranet\",\n    },\n    idLabel: \"auth-userid\",\n    secretLabel: \"api-key\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://partner.eranet.com/admin/mode_Http_Api_detail.php'>api-key</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://partner.eranet.com/admin/mode_Http_Api_detail.php'>获取 api-key</a>\",\n    }\n  },\n  gcore: {\n    name: {\n      \"en\": \"Gcore\",\n    },\n    idLabel: \"\",\n    secretLabel: \"API Token\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://portal.gcore.com/accounts/profile/api-tokens/create'>Create API Token</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://portal.gcore.com/accounts/profile/api-tokens/create'>创建 API Token</a>\",\n    }\n  },\n  edgeone: {\n    name: {\n      \"en\": \"Edgeone\",\n      \"zh-cn\": \"Edgeone\",\n    },\n    idLabel: \"SecretId\",\n    secretLabel: \"SecretKey\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://console.cloud.tencent.com/cam/capi'>Create AccessKey</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://console.cloud.tencent.com/cam/capi'>创建腾讯云 API 密钥</a>\",\n    }\n  },\n  nsone: {\n    name: {\n      \"en\": \"IBM NS1 Connect\",\n      \"zh-cn\": \"IBM NS1 Connect\",\n    },\n    idLabel: \"\",\n    secretLabel: \"API Key\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://my.nsone.net/#/account/settings/keys'>Create API Key</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://my.nsone.net/#/account/settings/keys'>创建 API 密钥</a>\",\n    }\n  },\n  name_com: {\n    name: {\n      \"en\": \"name.com\",\n      \"zh-cn\": \"name.com\",\n    },\n    idLabel: \"username\",\n    secretLabel: \"token\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://www.name.com/zh-cn/account/settings/api'>name.com Create API Token</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://www.name.com/zh-cn/account/settings/api'>name.com 创建 API Token</a>\",\n    }\n  },\n  rainyun: {\n    name: {\n      \"en\": \"Rainyun\",\n      \"zh-cn\": \"雨云\",\n    },\n    idLabel: \"Domain ID\",\n    secretLabel: \"API Key\",\n    helpHtml: {\n      \"en\": \"<a target='_blank' href='https://app.rainyun.com/apps/domain/manage'>Get Domain ID</a>\" +\n        \" <a target='_blank' href='https://app.rainyun.com/account/settings/api-key'>Get API Token</a>\",\n      \"zh-cn\": \"<a target='_blank' href='https://app.rainyun.com/apps/domain/manage'>获取 Domain ID</a>\" +\n        \" <a target='_blank' href='https://app.rainyun.com/account/settings/api-key'>获取 API Token</a>\",\n    }\n  },\n};\n\nconst SVG_CODE = {\n  success: `<svg viewBox=\"64 64 896 896\" focusable=\"false\" data-icon=\"check-circle\" width=\"1em\" height=\"1em\" fill=\"#52c41a\" aria-hidden=\"true\"><path d=\"M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm193.5 301.7l-210.6 292a31.8 31.8 0 01-51.7 0L318.5 484.9c-3.8-5.3 0-12.7 6.5-12.7h46.9c10.2 0 19.9 4.9 25.9 13.3l71.2 98.8 157.2-218c6-8.3 15.6-13.3 25.9-13.3H699c6.5 0 10.3 7.4 6.5 12.7z\"></path></svg>`,\n  info: `<svg viewBox=\"64 64 896 896\" focusable=\"false\" data-icon=\"info-circle\" width=\"1em\" height=\"1em\" fill=\"#1677ff\" aria-hidden=\"true\"><path d=\"M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm32 664c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V456c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272zm-32-344a48.01 48.01 0 010-96 48.01 48.01 0 010 96z\"></path></svg>`,\n  warning: '<svg viewBox=\"64 64 896 896\" focusable=\"false\" data-icon=\"exclamation-circle\" width=\"1em\" height=\"1em\" fill=\"#faad14\" aria-hidden=\"true\"><path d=\"M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm-32 232c0-4.4 3.6-8 8-8h48c4.4 0 8 3.6 8 8v272c0 4.4-3.6 8-8 8h-48c-4.4 0-8-3.6-8-8V296zm32 440a48.01 48.01 0 010-96 48.01 48.01 0 010 96z\"></path></svg>',\n  error: '<svg viewBox=\"64 64 896 896\" focusable=\"false\" data-icon=\"close-circle\" width=\"1em\" height=\"1em\" fill=\"#ff4d4f\" aria-hidden=\"true\"><path d=\"M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm165.4 618.2l-66-.3L512 563.4l-99.3 118.4-66.1.3c-4.4 0-8-3.5-8-8 0-1.9.7-3.7 1.9-5.2l130.1-155L340.5 359a8.32 8.32 0 01-1.9-5.2c0-4.4 3.6-8 8-8l66.1.3L512 464.6l99.3-118.4 66-.3c4.4 0 8 3.5 8 8 0 1.9-.7 3.7-1.9 5.2L553.5 514l130 155c1.2 1.5 1.9 3.3 1.9 5.2 0 4.4-3.6 8-8 8z\"></path></svg>'\n}\n"
  },
  {
    "path": "static/i18n.js",
    "content": "const I18N_MAP = {\n  'Logs': {\n    'en': 'Logs',\n    'zh-cn': '日志'\n  },\n  'Save': {\n    'en': 'Save',\n    'zh-cn': '保存'\n  },\n  'Config:': {\n    'en': 'Config:',\n    'zh-cn': '配置切换:'\n  },\n  'Add': {\n    'en': 'Add',\n    'zh-cn': '添加'\n  },\n  'Rename': {\n    'en': 'Rename',\n    'zh-cn': '重命名'\n  },\n  'RenameHelp': {\n    'en': 'Enter a new name:',\n    'zh-cn': '输入新名称：'\n  },\n  'Delete': {\n    'en': 'Delete',\n    'zh-cn': '删除'\n  },\n  'DNS Provider': {\n    'en': 'DNS Provider',\n    'zh-cn': 'DNS服务商'\n  },\n  'Create AccessKey': {\n    'en': 'Create AccessKey',\n    'zh-cn': '创建 AccessKey'\n  },\n  'Auto': {\n    'en': 'Auto',\n    'zh-cn': '自动'\n  },\n  '1s': {\n    'en': '1s',\n    'zh-cn': '1秒'\n  },\n  '5s': {\n    'en': '5s',\n    'zh-cn': '5秒'\n  },\n  '10s': {\n    'en': '10s',\n    'zh-cn': '10秒'\n  },\n  '1m': {\n    'en': '1m',\n    'zh-cn': '1分钟'\n  },\n  '2m': {\n    'en': '2m',\n    'zh-cn': '2分钟'\n  },\n  '10m': {\n    'en': '10m',\n    'zh-cn': '10分钟'\n  },\n  '30m': {\n    'en': '30m',\n    'zh-cn': '30分钟'\n  },\n  '1h': {\n    'en': '1h',\n    'zh-cn': '1小时'\n  },\n  'ttlHelp': {\n    'en': 'You can modify it if the account supports a smaller TTL. The TTL will only be updated when the IP changes',\n    'zh-cn': '如账号支持更小的 TTL, 可修改。IP 有变化时才会更新TTL'\n  },\n  'Enabled': {\n    'en': 'Enabled',\n    'zh-cn': '是否启用'\n  },\n  'Get IP method': {\n    'en': 'Get IP method',\n    'zh-cn': '获取 IP 方式'\n  },\n  'By api': {\n    'en': 'By api',\n    'zh-cn': '通过接口获取'\n  },\n  'By network card': {\n    'en': 'By network card',\n    'zh-cn': '通过网卡获取'\n  },\n  'By command': {\n    'en': 'By command',\n    'zh-cn': '通过命令获取'\n  },\n  'domainsHelp': {\n    'en': `\n      Enter one domain per line.\n      If the domain is unregistrable, manually separate it into a subdomain and a root domain by using a colon. e.g. <code>www:domain.example.com</code><br />\n\n      Support for <a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go/wiki/传递自定义参数\">custom parameters</a> (Simplified Chinese)\n    `,\n    'zh-cn': `\n      每行一个域名。\n      如果域名不可注册，请使用冒号手动将其分为子域名和根域名。如 <code>www:domain.example.com</code><br />\n      支持<a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go/wiki/传递自定义参数\">自定义参数</a>\n    `\n  },\n  'Regular exp.': {\n    'en': 'Regular exp.',\n    'zh-cn': '匹配正则表达式'\n  },\n  'regHelp': {\n    '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',\n    'zh-cn': '可使用 @1 指定第一个IPv6地址, @2 指定第二个IPv6地址... 也可使用正则表达式匹配指定的IPv6地址, 留空则不启用'\n  },\n  'Others': {\n    'en': 'Others',\n    'zh-cn': '其他'\n  },\n  'Deny from WAN': {\n    'en': 'Deny from WAN',\n    'zh-cn': '禁止公网访问'\n  },\n  'NotAllowWanAccessHelp': {\n    'en': 'Enable to deny access from the public network',\n    'zh-cn': '启用后禁止从公网访问此页面'\n  },\n  'Username': {\n    'en': 'Username',\n    'zh-cn': '用户名'\n  },\n  'accountHelp': {\n    'en': 'Username/Password is required',\n    'zh-cn': '必须输入用户名/密码'\n  },\n  'passwordHelp': {\n    'en': 'If you need to change the password, please enter it here',\n    'zh-cn': '如需修改密码，请在此处输入新密码'\n  },\n  'Password': {\n    'en': 'Password',\n    'zh-cn': '密码'\n  },\n  'WebhookURLHelp': {\n    'en': `\n      <a\n        target=\"blank\"\n        href=\"https://github.com/jeessy2/ddns-go/blob/master/README_EN.md#webhook\"\n      >Click to get more info</a\n      ><br />\n      Support variables #{ipv4Addr}, #{ipv4Result},\n      #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains}\n    `,\n    'zh-cn': `\n      <a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go#webhook\">点击参考官方 Webhook 说明</a>\n      <br />\n      支持的变量 #{ipv4Addr}, #{ipv4Result}, #{ipv4Domains}, #{ipv6Addr}, #{ipv6Result}, #{ipv6Domains}\n    `\n  },\n  'WebhookRequestBodyHelp': {\n    'en': 'If RequestBody is empty, it is a GET request, otherwise it is a POST request. Supported variables are the same as above',\n    'zh-cn': '如果 RequestBody 为空, 则为 GET 请求, 否则为 POST 请求。支持的变量同上'\n  },\n  'WebhookHeadersHelp': {\n    'en': 'One header per line, such as: Authorization: Bearer API_KEY',\n    'zh-cn': '一行一个Header, 如: Authorization: Bearer API_KEY'\n  },\n  'Try it': {\n    'en': 'Try it',\n    'zh-cn': '模拟测试Webhook'\n  },\n  'Clear': {\n    'en': 'Clear',\n    'zh-cn': '清空'\n  },\n  'OK': {\n    'en': 'OK',\n    'zh-cn': '确定'\n  },\n  \"Ipv4UrlHelp\": {\n    'en': \"https://api.ipify.org, https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson\",\n    'zh-cn': \"https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://v4.yinghualuo.cn/bejson\"\n  },\n  \"Ipv6UrlHelp\": {\n    'en': \"https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson\",\n    'zh-cn': \"https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson\"\n  },\n  \"Ipv4NetInterfaceHelp\": {\n    'en': \"Get IPv4 address through network card\",\n    'zh-cn': \"通过网卡获取IPv4\"\n  },\n  \"Ipv6NetInterfaceHelp\": {\n    'en': \"If you do not specify a matching regular expression, the first IPv6 address will be used by default\",\n    'zh-cn': \"如不指定匹配正则表达式，将默认使用第一个 IPv6 地址\"\n  },\n  \"Ipv4CmdHelp\": {\n    'en': \"Get IPv4 through command, only use the first matching IPv4 address of standard output(stdout). Such as: ip -4 addr show eth1\",\n    'zh-cn': `\n      通过命令获取IPv4, 仅使用标准输出(stdout)的第一个匹配的 IPv4 地址。如: ip -4 addr show eth1\n      <a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go/wiki/通过命令获取IP参考\">点击参考更多</a>\n    `\n  },\n  \"Ipv6CmdHelp\": {\n    'en': \"Get IPv6 through command, only use the first matching IPv6 address of standard output(stdout). Such as: ip -6 addr show eth1\",\n    'zh-cn': `\n      通过命令获取IPv6, 仅使用标准输出(stdout)的第一个匹配的 IPv6 地址。如: ip -6 addr show eth1\n      <a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go/wiki/通过命令获取IP参考\">点击参考更多</a>\n    `\n  },\n  \"NetInterfaceEmptyHelp\": {\n    'en': '<span style=\"color: red\">No available network card found</span>',\n    'zh-cn': '<span style=\"color: red\">没有找到可用的网卡</span>'\n  },\n  \"Http Interface\": {\n    'en': 'Http Interface',\n    'zh-cn': 'HTTP 请求网卡'\n  },\n  \"Default\": {\n    'en': 'Default',\n    'zh-cn': '默认'\n  },\n  \"HttpInterfaceHelp\": {\n    'en': 'Bind HTTP requests to a specific network interface (similar to curl --interface). Leave empty to use the default.',\n    'zh-cn': '发送 HTTP 请求时绑定指定网卡（类似 curl --interface）。留空则使用默认网卡。'\n  },\n  \"Login\": {\n    'en': 'Login',\n    'zh-cn': '登录'\n  },\n  \"LoginInit\": {\n    'en': 'Login and configure as an administrator account',\n    'zh-cn': '登录并配置为管理员账号'\n  },\n  \"Logout\": {\n    'en': 'Logout',\n    'zh-cn': '注销'\n  },\n  \"webhookTestTooltip\": {\n    'en': 'Send a fake data to the Webhook URL immediately to test if the Webhook is working properly',\n    'zh-cn': '立即发送一条假数据到Webhook URL，用于测试Webhook是否正常工作'\n  },\n  \"themeTooltip\": {\n    'en': 'Click: Switch theme<br>Long press: Restore auto mode',\n    'zh-cn': '单击：切换明暗主题<br>长按：恢复自动跟随系统'\n  },\n  \"extParamHelp\": {\n    'en': 'Optional. If you are using a Vercel Team account, please fill in the Team ID',\n    'zh-cn': '可选项，如果您使用的是 Vercel 团队账户，请填写团队 ID'\n  },\n};\n\nconst LANG = localStorage.getItem('lang') || (navigator.language || navigator.browserLanguage).replaceAll('_', '-').toLowerCase();\n\nconst getLocalLang = (langs) => {\n  // 优先取地区语言\n  if (langs.includes(LANG)) {\n    return LANG;\n  }\n  // 其次取表示语言\n  if (langs.includes(LANG.split('-')[0])) {\n    return LANG.split('-')[0];\n  }\n  // 再取表示语言相同的地区语言\n  for (const l of langs) {\n    if (l.split('-')[0] === LANG.split('-')[0]) {\n      return l;\n    }\n  }\n  // 无法匹配则取英文\n  return 'en';\n}\n\n// 支持两种调用方式：\n// 1. 文本在I18N字典中的key，如\"hello\"\n// 2. 语言字符串字典，{en: \"hello\", zh: \"你好\"}\nconst i18n = (keyOrLangDict) => {\n  let key = keyOrLangDict;\n  let langDict = keyOrLangDict;\n  if (typeof keyOrLangDict === 'string') {\n    langDict = I18N_MAP[keyOrLangDict];\n  } else {\n    key = null;\n  }\n  if (!langDict) {\n    console.warn(`i18n: No translation for key \"${key}\"`);\n    return key;\n  }\n  const lang = getLocalLang(Object.keys(langDict));\n  if (lang in langDict) {\n    return langDict[lang];\n  }\n  console.warn(`i18n: No such language \"${lang}\" in langDict ${langDict}`);\n  return key;\n}\n\nconst convertDom = (dom = document) => {\n  dom.querySelectorAll('[data-i18n]').forEach(el => {\n    const key = el.dataset.i18n;\n    el.textContent = i18n(key);\n  });\n  dom.querySelectorAll('[data-i18n-html]').forEach(el => {\n    const key = el.dataset.i18nHtml;\n    el.innerHTML = i18n(key);\n  });\n  dom.querySelectorAll('[data-i18n-attr]').forEach(el => {\n    el.dataset.i18nAttr.split(',').forEach(item => {\n      let [attr, key] = item.split(':');\n      attr = attr.trim();\n      key = key || el.getAttribute(attr);\n      el.setAttribute(attr, i18n(key));\n    });\n  });\n}\n\ndocument.addEventListener('DOMContentLoaded', () => { convertDom(); });"
  },
  {
    "path": "static/theme-button.css",
    "content": "/* From https://css.gg */\n\n.gg-dark-mode {\n    box-sizing: border-box;\n    position: relative;\n    display: block;\n    transform: scale(var(--ggs, 1));\n    border: 2px solid;\n    border-radius: 100px;\n    width: 20px;\n    height: 20px\n}\n\n.gg-dark-mode::after,\n.gg-dark-mode::before {\n    content: \"\";\n    box-sizing: border-box;\n    position: absolute;\n    display: block\n}\n\n.gg-dark-mode::before {\n    border: 5px solid;\n    border-top-left-radius: 100px;\n    border-bottom-left-radius: 100px;\n    border-right: 0;\n    width: 9px;\n    height: 18px;\n    top: -1px;\n    left: -1px\n}\n\n.gg-dark-mode::after {\n    border: 4px solid;\n    border-top-right-radius: 100px;\n    border-bottom-right-radius: 100px;\n    border-left: 0;\n    width: 4px;\n    height: 8px;\n    right: 4px;\n    top: 4px\n}\n"
  },
  {
    "path": "static/theme.js",
    "content": "function updateColorSchemeMeta(isDark) {\n  const meta = document.querySelector('meta[name=\"color-scheme\"]');\n  if (meta) {\n    meta.setAttribute('content', isDark ? 'dark' : 'light');\n  }\n}\n\nfunction toggleTheme(write = false) {\n  const docEle = document.documentElement;\n  if (docEle.getAttribute(\"data-theme\") === \"dark\") {\n    docEle.removeAttribute(\"data-theme\");\n    updateColorSchemeMeta(false);\n    write && localStorage.setItem(\"theme\", \"light\");\n  } else {\n    docEle.setAttribute(\"data-theme\", \"dark\");\n    updateColorSchemeMeta(true);\n    write && localStorage.setItem(\"theme\", \"dark\");\n  }\n}\n\nconst theme = localStorage.getItem(\"theme\") ??\n  (window.matchMedia(\"(prefers-color-scheme: dark)\").matches\n    ? \"dark\"\n    : \"light\");\n\nif (theme === \"dark\") {\n  toggleTheme();\n}\n\n// 长按重置功能\nlet pressTimer = null;\nlet isLongPress = false;\n\nconst button = document.getElementById(\"themeButton\");\n\nfunction startPress() {\n  isLongPress = false;\n  // 800ms后触发长按\n  pressTimer = setTimeout(() => {\n    isLongPress = true;\n\n    // 清除用户偏好，恢复自动模式\n    localStorage.removeItem(\"theme\");\n\n    // 立即同步系统主题状态\n    const systemIsDark = window.matchMedia(\"(prefers-color-scheme: dark)\").matches;\n    const currentlyDark = document.documentElement.getAttribute(\"data-theme\") === \"dark\";\n    if (systemIsDark !== currentlyDark) {\n      toggleTheme();\n    }\n\n    // 显示成功提示\n    showMessage({\n      content: i18n({\n        \"en\": \"Theme has been restored to auto mode\",\n        \"zh-cn\": \"主题已恢复自动跟随系统\"\n      }),\n      type: \"success\",\n      duration: 2000\n    });\n  }, 800);\n}\n\nfunction endPress() {\n  clearTimeout(pressTimer);\n  // 短按才执行切换\n  if (!isLongPress) {\n    toggleTheme(true);\n  }\n}\n\nfunction cancelPress() {\n  clearTimeout(pressTimer);\n}\n\n// 鼠标事件\nbutton.addEventListener('mousedown', startPress);\nbutton.addEventListener('mouseup', endPress);\nbutton.addEventListener('mouseleave', cancelPress);\n\n// 触摸事件（移动设备）\nbutton.addEventListener('touchstart', (e) => {\n  e.preventDefault(); // 防止触发点击\n  startPress();\n});\nbutton.addEventListener('touchmove', cancelPress);\nbutton.addEventListener('touchend', endPress);\nbutton.addEventListener('touchcancel', cancelPress);\n\n// 系统主题变化监听器\n// 仅在自动模式下响应（即用户未手动设置偏好时）\nwindow.matchMedia(\"(prefers-color-scheme: dark)\").addEventListener(\"change\", (e) => {\n  if (!localStorage.getItem(\"theme\")) {\n    // 只有在没有用户偏好时才自动切换\n    const shouldBeDark = e.matches;\n    const currentlyDark = document.documentElement.getAttribute(\"data-theme\") === \"dark\";\n    if (shouldBeDark !== currentlyDark) {\n      toggleTheme();\n    }\n  }\n});\n"
  },
  {
    "path": "static/tooltips.js",
    "content": "class Tooltip {\n  constructor(element, triggers) {\n    this.$element = element;\n    this.$tooltip = null;\n    this.originalTitle = '';\n    this._bindEvents(triggers);\n  }\n\n  _createTooltipElement(options) {\n    const title = options.title || this.$element.dataset.title || this.originalTitle;\n    if (!title) {\n      return;\n    }\n    const useHtml = options.hasOwnProperty('html') ? options.html : this.$element.dataset.html === 'true';\n    let placement = options.placement || this.$element.dataset.placement || 'auto';\n    if (placement === 'auto') {\n      const rect = this.$element.getBoundingClientRect();\n      const viewportWidth = window.innerWidth || document.documentElement.clientWidth;\n      const viewportHeight = window.innerHeight || document.documentElement.clientHeight;\n      const space = {\n        top: rect.top,\n        bottom: viewportHeight - rect.bottom,\n        left: rect.left,\n        right: viewportWidth - rect.right\n      };\n      placement = Object.keys(space).reduce((a, b) => space[a] > space[b] ? a : b);\n    }\n    this.$tooltip = html2Element(`\n      <div class=\"tooltip bs-tooltip-${placement}\"\n        x-placement=\"${placement}\"\n        style=\"will-change: transform;\"\n        role=\"tooltip\"\n      >\n        <div class=\"arrow\"></div>\n        <div class=\"tooltip-inner\"></div>\n      </div>\n    `)\n    if (useHtml) {\n      this.$tooltip.querySelector('.tooltip-inner').innerHTML = title\n    } else {\n      this.$tooltip.querySelector('.tooltip-inner').textContent = title\n    }\n  }\n\n  _updatePosition() {\n    const elRect = this.$element.getBoundingClientRect()\n    const bodyRect = document.body.getBoundingClientRect()\n    const tooltipRect = this.$tooltip.getBoundingClientRect()\n    const placement = this.$tooltip.getAttribute('x-placement')\n\n    let left, top;\n    \n    switch(placement) {\n      case 'top':\n        left = elRect.left + (elRect.width - tooltipRect.width) / 2\n        top = elRect.top - tooltipRect.height - 8\n        break\n      case 'bottom':\n        left = elRect.left + (elRect.width - tooltipRect.width) / 2\n        top = elRect.bottom + 8\n        break\n      case 'left':\n        left = elRect.left - tooltipRect.width - 8\n        top = elRect.top + (elRect.height - tooltipRect.height) / 2\n        break\n      case 'right':\n        left = elRect.right + 8\n        top = elRect.top + (elRect.height - tooltipRect.height) / 2\n        break\n    }\n\n    // 考虑滚动条的影响\n    left = left - bodyRect.left\n    top = top - bodyRect.top\n    \n    this.$tooltip.style.left = `${left}px`\n    this.$tooltip.style.top = `${top}px`\n  }\n\n  async show(options = {}) {\n    if (this.$tooltip) {\n      this.$tooltip.remove();\n    }\n    if (this.$element.title) {\n      this.originalTitle = this.$element.title;\n      this.$element.title = '';\n    }\n    this._createTooltipElement(options);\n    if (!this.$tooltip) {\n      return;\n    }\n    document.body.appendChild(this.$tooltip);\n    await delay(0);\n    if (!this.$tooltip) {\n      return;\n    }\n    this._updatePosition();\n    this.$tooltip.classList.add('show');\n  }\n\n  async hide() {\n    if (this.originalTitle && !this.$element.title) {\n      this.$element.title = this.originalTitle;\n    }\n    if (!this.$tooltip) {\n      return;\n    }\n    this.$tooltip.classList.remove('show');\n    await delay(200);\n    if (!this.$tooltip) {\n      return;\n    }\n    this.$tooltip.remove();\n    this.$tooltip = null;\n  }\n\n  _bindEvents(triggers) {\n    let state = 0;\n    const _enter = () => {\n      state += 1;\n      this.show();\n    };\n    const _leave = () => {\n      state -= 1;\n      if (state <= 0) {\n        this.hide();\n      }\n    };\n    if (!triggers) {\n      triggers = (this.$element.dataset.trigger || 'hover focus').split(' ');\n    }\n    triggers.forEach(trigger => {\n      switch(trigger) {\n        case 'hover':\n          this.$element.addEventListener('mouseenter', _enter);\n          this.$element.addEventListener('mouseleave', _leave);\n          break;\n        case 'focus':\n          this.$element.addEventListener('focusin', _enter);\n          this.$element.addEventListener('focusout', _leave);\n          break;\n        case 'click':\n          this.$element.addEventListener('click', () => {\n            if (this.$tooltip) {\n              this.hide();\n            } else {\n              this.show();\n            }\n          });\n          break;\n        case 'manual':\n          break;\n        default:\n          console.warn(`Unknown trigger: ${trigger}`);\n      }\n    });\n  }\n}\n\n// 初始化所有带data-tooltip属性的元素\nconst initTooltips = () => {\n  window.tooltips = {};\n  document.querySelectorAll('[data-toggle=\"tooltip\"]').forEach(element => {\n    let key = element.dataset.tooltipKey || element.id;\n    if (!key) {\n      key = crypto.randomUUID();\n      element.dataset.tooltipKey = key;\n    }\n    window.tooltips[key] = new Tooltip(element);\n  });\n};\n\n// 页面加载完成后初始化\ndocument.addEventListener('DOMContentLoaded', initTooltips); "
  },
  {
    "path": "static/utils.js",
    "content": "const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms))\n\nconst html2Element = (htmlString) => {\n  const doc = new DOMParser().parseFromString(htmlString, 'text/html')\n  return doc.body.firstElementChild\n}\n\n// 在页面顶部显示一行消息，并在若干秒后自动消失\nconst showMessage = async (msgObj) => {\n  // 填充默认值\n  msgObj = Object.assign({\n    type: 'info',\n    content: '',\n    html: false,\n    duration: 3000\n  }, msgObj)\n  // 当前是否有消息容器\n  let $container = document.getElementById('msg-container')\n  if (!$container) {\n    // 创建消息容器\n    $container = html2Element('<div id=\"msg-container\"></div>')\n    document.body.appendChild($container)\n  }\n  // 创建消息元素\n  const $msg = html2Element('<div class=\"msg msg-fade\"></div>')\n  // 创建两个span，用于显示消息的图标和内容\n  const $content = html2Element('<span></span>')\n\n  // 填充内容，根据html属性决定使用text还是html\n  if (msgObj.html) {\n    $content.innerHTML = msgObj.content\n  } else {\n    $content.textContent = msgObj.content\n  }\n  // 根据消息类型设置图标\n  $msg.innerHTML = `<span class=\"msg-icon\">${SVG_CODE[msgObj.type]}</span>`\n  $msg.appendChild($content)\n  $container.appendChild($msg)\n  // 确保动画生效\n  await delay(0)\n  $msg.classList.remove('msg-fade')\n  // 等待动画结束\n  await delay(200)\n  // 销毁函数\n  const destroy = async () => {\n    // 增加消失动画\n    $msg.classList.add('msg-fade')\n    // 动画结束后移除元素\n    await delay(200)\n    $msg.remove()\n    // 如果容器中没有消息了，移除容器\n    if (!$container.children.length) {\n      $container.remove()\n    }\n  }\n  // 如果duration为0，则不自动消失\n  if (msgObj.duration === 0) {\n    return destroy\n  }\n  // 自动消失计时器\n  let timer = setTimeout(destroy, msgObj.duration)\n  // 注册鼠标事件，鼠标移入时取消自动消失\n  $msg.addEventListener('mouseenter', () => {\n    clearTimeout(timer)\n  })\n  // 鼠标移出时重新计时\n  $msg.addEventListener('mouseleave', () => {\n    timer = setTimeout(destroy, msgObj.duration)\n  })\n  return destroy\n}\n\nconst request = {\n  baseURL: './',\n  parse: async function(resp) {\n    const text = await resp.text()\n    try {\n      return JSON.parse(text)\n    } catch (e) {\n      return text\n    }\n  },\n  stringify: function(dict) {\n    const result = []\n    for (let key in dict) {\n      if (!dict.hasOwnProperty(key)) {\n        continue\n      }\n      // 所有空值将被删除\n      if (String(dict[key])) {\n        result.push(`${key}=${encodeURIComponent(dict[key])}`)\n      }\n    }\n    return result.join('&')\n  },\n  get: async function(path, data, parseFunc) {\n    const response = await fetch(`${this.baseURL}${path}?${this.stringify(data)}`)\n    if (response.redirected) {\n      window.location.href = response.url\n    }\n    return await (parseFunc||this.parse)(response)\n  },\n  post: async function(path, data, parseFunc) {\n    if (typeof data === 'object') {\n      data = JSON.stringify(data)\n    }\n    const response = await fetch(`${this.baseURL}${path}`, {\n      method: 'POST',\n      body: data\n    })\n    if (response.redirected) {\n      window.location.href = response.url\n    }\n    return await (parseFunc||this.parse)(response)\n  }\n}"
  },
  {
    "path": "util/aliyun_signer.go",
    "content": "package util\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/md5\"\n\t\"crypto/sha1\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"hash\"\n\t\"io\"\n\t\"net/url\"\n)\n\n// https://github.com/rosbit/aliyun-sign/blob/master/aliyun-sign.go\n\nvar (\n\tsignMethodMap = map[string]func() hash.Hash{\n\t\t\"HMAC-SHA1\":   sha1.New,\n\t\t\"HMAC-SHA256\": sha256.New,\n\t\t\"HMAC-MD5\":    md5.New,\n\t}\n)\n\nfunc HmacSign(signMethod string, httpMethod string, appKeySecret string, vals url.Values) (signature []byte) {\n\tkey := []byte(appKeySecret + \"&\")\n\n\tvar h hash.Hash\n\tif method, ok := signMethodMap[signMethod]; ok {\n\t\th = hmac.New(method, key)\n\t} else {\n\t\th = hmac.New(sha1.New, key)\n\t}\n\tmakeDataToSign(h, httpMethod, vals)\n\treturn h.Sum(nil)\n}\n\nfunc HmacSignToB64(signMethod string, httpMethod string, appKeySecret string, vals url.Values) (signature string) {\n\treturn base64.StdEncoding.EncodeToString(HmacSign(signMethod, httpMethod, appKeySecret, vals))\n}\n\ntype strToEnc struct {\n\ts string\n\te bool\n}\n\nfunc makeDataToSign(w io.Writer, httpMethod string, vals url.Values) {\n\tin := make(chan *strToEnc)\n\tgo func() {\n\t\tin <- &strToEnc{s: httpMethod}\n\t\tin <- &strToEnc{s: \"&\"}\n\t\tin <- &strToEnc{s: \"/\", e: true}\n\t\tin <- &strToEnc{s: \"&\"}\n\t\tin <- &strToEnc{s: vals.Encode(), e: true}\n\t\tclose(in)\n\t}()\n\n\tspecialUrlEncode(in, w)\n}\n\nvar (\n\tencTilde = \"%7E\"         // '~' -> \"%7E\"\n\tencBlank = []byte(\"%20\") // ' ' -> \"%20\"\n\ttilde    = []byte(\"~\")\n)\n\nfunc specialUrlEncode(in <-chan *strToEnc, w io.Writer) {\n\tfor s := range in {\n\t\tif !s.e {\n\t\t\tio.WriteString(w, s.s)\n\t\t\tcontinue\n\t\t}\n\n\t\tl := len(s.s)\n\t\tfor i := 0; i < l; {\n\t\t\tch := s.s[i]\n\n\t\t\tswitch ch {\n\t\t\tcase '%':\n\t\t\t\tif encTilde == s.s[i:i+3] {\n\t\t\t\t\tw.Write(tilde)\n\t\t\t\t\ti += 3\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\t\tfallthrough\n\t\t\tcase '*', '/', '&', '=':\n\t\t\t\tfmt.Fprintf(w, \"%%%02X\", ch)\n\t\t\tcase '+':\n\t\t\t\tw.Write(encBlank)\n\t\t\tdefault:\n\t\t\t\tfmt.Fprintf(w, \"%c\", ch)\n\t\t\t}\n\n\t\t\ti += 1\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/aliyun_signer_util.go",
    "content": "package util\n\nimport (\n\t\"net/url\"\n\t\"strconv\"\n\t\"time\"\n)\n\n// AliyunSigner AliyunSigner\nfunc AliyunSigner(accessKeyID, accessSecret string, params *url.Values, httpMethod string, apiVersion string) {\n\t// 公共参数\n\tparams.Set(\"SignatureMethod\", \"HMAC-SHA1\")\n\tparams.Set(\"SignatureNonce\", strconv.FormatInt(time.Now().UnixNano(), 10))\n\tparams.Set(\"AccessKeyId\", accessKeyID)\n\tparams.Set(\"SignatureVersion\", \"1.0\")\n\tparams.Set(\"Timestamp\", time.Now().UTC().Format(\"2006-01-02T15:04:05Z\"))\n\tparams.Set(\"Format\", \"JSON\")\n\tparams.Set(\"Version\", apiVersion)\n\tparams.Set(\"Signature\", HmacSignToB64(\"HMAC-SHA1\", httpMethod, accessSecret, *params))\n}\n"
  },
  {
    "path": "util/andriod_time.go",
    "content": "package util\n\nimport (\n\t\"os/exec\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc FixTimezone() {\n\tout, err := exec.Command(\"/system/bin/getprop\", \"persist.sys.timezone\").Output()\n\tif err != nil {\n\t\treturn\n\t}\n\ttimeZone, err := time.LoadLocation(strings.TrimSpace(string(out)))\n\tif err != nil {\n\t\treturn\n\t}\n\ttime.Local = timeZone\n}\n"
  },
  {
    "path": "util/baidu_signer.go",
    "content": "package util\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"strings\"\n\t\"time\"\n)\n\n// https://cloud.baidu.com/doc/Reference/s/Njwvz1wot\n\nconst (\n\tBaiduDateFormat  = \"2006-01-02T15:04:05Z\"\n\texpirationPeriod = \"1800\"\n)\n\nfunc HmacSha256Hex(secret, message string) string {\n\tkey := []byte(secret)\n\n\th := hmac.New(sha256.New, key)\n\th.Write([]byte(message))\n\tsha := hex.EncodeToString(h.Sum(nil))\n\treturn sha\n}\n\nfunc BaiduCanonicalURI(r *http.Request) string {\n\tpatterns := strings.Split(r.URL.Path, \"/\")\n\tvar uri []string\n\tfor _, v := range patterns {\n\t\turi = append(uri, escape(v))\n\t}\n\turlpath := strings.Join(uri, \"/\")\n\tif len(urlpath) == 0 || urlpath[len(urlpath)-1] != '/' {\n\t\turlpath = urlpath + \"/\"\n\t}\n\treturn urlpath[0 : len(urlpath)-1]\n}\n\n// BaiduSigner set Authorization header\nfunc BaiduSigner(accessKeyID, accessSecret string, r *http.Request) {\n\t//format: bce-auth-v1/{accessKeyId}/{timestamp}/{expirationPeriodInSeconds}\n\tauthStringPrefix := \"bce-auth-v1/\" + accessKeyID + \"/\" + time.Now().UTC().Format(BaiduDateFormat) + \"/\" + expirationPeriod\n\tbaiduCanonicalURL := BaiduCanonicalURI(r)\n\n\t//format: HTTP Method + \"\\n\" + CanonicalURI + \"\\n\" + CanonicalQueryString + \"\\n\" + CanonicalHeaders\n\t//由于仅仅调用三个POST接口且不会更改，这里CanonicalQueryString和CanonicalHeaders直接写死\n\tCanonicalReq := fmt.Sprintf(\"%s\\n%s\\n%s\\n%s\", r.Method, baiduCanonicalURL, \"\", \"host:bcd.baidubce.com\")\n\n\tsigningKey := HmacSha256Hex(accessSecret, authStringPrefix)\n\tsignature := HmacSha256Hex(signingKey, CanonicalReq)\n\n\t//format: authStringPrefix/{signedHeaders}/{signature}\n\tauthString := authStringPrefix + \"/host/\" + signature\n\tr.Header.Set(HeaderAuthorization, authString)\n}\n"
  },
  {
    "path": "util/bcrypt.go",
    "content": "package util\n\nimport (\n\t\"golang.org/x/crypto/bcrypt\"\n)\n\n// HashPassword 密码哈希\nfunc HashPassword(password string) (string, error) {\n\thashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn string(hashedPassword), nil\n}\n\n// PasswordOK 检查密码\nfunc PasswordOK(hashedPassword, password string) bool {\n\terr := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))\n\treturn err == nil\n}\n\n// IsHashedPassword 是否是哈希密码\nfunc IsHashedPassword(password string) bool {\n\t_, err := bcrypt.Cost([]byte(password))\n\treturn err == nil\n}\n"
  },
  {
    "path": "util/copy_url_params.go",
    "content": "package util\n\nimport \"net/url\"\n\nfunc CopyUrlParams(src url.Values, dest url.Values, keys []string) {\n\tif keys == nil || len(keys) == 0 {\n\t\tfor key := range src {\n\t\t\tdest.Set(key, src.Get(key))\n\t\t}\n\t} else {\n\t\tfor _, key := range keys {\n\t\t\tval := src.Get(key)\n\t\t\tif val != \"\" {\n\t\t\t\tdest.Set(key, val)\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/docker_util.go",
    "content": "package util\n\nimport \"os\"\n\n// DockerEnvFile Docker容器中包含的文件\nconst DockerEnvFile string = \"/.dockerenv\"\n\n// IsRunInDocker 是否在docker中运行\nfunc IsRunInDocker() bool {\n\t_, err := os.Stat(DockerEnvFile)\n\treturn err == nil\n}\n"
  },
  {
    "path": "util/escape.go",
    "content": "// based on https://github.com/golang/go/blob/master/src/net/url/url.go\n// Copyright 2009 The Go Authors. All rights reserved.\n// Use of this source code is governed by a BSD-style\n// license that can be found in the LICENSE file.\n\npackage util\n\nfunc shouldEscape(c byte) bool {\n\tif 'A' <= c && c <= 'Z' || 'a' <= c && c <= 'z' || '0' <= c && c <= '9' || c == '_' || c == '-' || c == '~' || c == '.' {\n\t\treturn false\n\t}\n\treturn true\n}\nfunc escape(s string) string {\n\thexCount := 0\n\tfor i := 0; i < len(s); i++ {\n\t\tc := s[i]\n\t\tif shouldEscape(c) {\n\t\t\thexCount++\n\t\t}\n\t}\n\n\tif hexCount == 0 {\n\t\treturn s\n\t}\n\n\tt := make([]byte, len(s)+2*hexCount)\n\tj := 0\n\tfor i := 0; i < len(s); i++ {\n\t\tswitch c := s[i]; {\n\t\tcase shouldEscape(c):\n\t\t\tt[j] = '%'\n\t\t\tt[j+1] = \"0123456789ABCDEF\"[c>>4]\n\t\t\tt[j+2] = \"0123456789ABCDEF\"[c&15]\n\t\t\tj += 3\n\t\tdefault:\n\t\t\tt[j] = s[i]\n\t\t\tj++\n\t\t}\n\t}\n\treturn string(t)\n}\n"
  },
  {
    "path": "util/http_client_util.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"crypto/tls\"\n\t\"fmt\"\n\t\"net\"\n\t\"net/http\"\n\t\"time\"\n)\n\nvar dialer = &net.Dialer{\n\tTimeout:   30 * time.Second,\n\tKeepAlive: 30 * time.Second,\n}\n\nvar defaultTransport = &http.Transport{\n\t// from http.DefaultTransport\n\tProxy: http.ProxyFromEnvironment,\n\tDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\treturn dialer.DialContext(ctx, network, address)\n\t},\n\tForceAttemptHTTP2:     true,\n\tMaxIdleConns:          100,\n\tIdleConnTimeout:       90 * time.Second,\n\tTLSHandshakeTimeout:   10 * time.Second,\n\tExpectContinueTimeout: 1 * time.Second,\n}\n\n// insecureSkipVerify 全局TLS验证跳过标志\nvar insecureSkipVerify bool\n\n// CreateHTTPClient Create Default HTTP Client\nfunc CreateHTTPClient() *http.Client {\n\treturn &http.Client{\n\t\tTimeout:   30 * time.Second,\n\t\tTransport: defaultTransport,\n\t}\n}\n\n// GetLocalAddrFromInterface 根据网卡名称获取本地IP地址\nfunc GetLocalAddrFromInterface(ifaceName string) (string, error) {\n\tiface, err := net.InterfaceByName(ifaceName)\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"找不到网卡 %s: %v\", ifaceName, err)\n\t}\n\taddrs, err := iface.Addrs()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"获取网卡 %s 地址失败: %v\", ifaceName, err)\n\t}\n\tfor _, addr := range addrs {\n\t\tif ipNet, ok := addr.(*net.IPNet); ok && ipNet.IP.IsGlobalUnicast() {\n\t\t\treturn ipNet.IP.String(), nil\n\t\t}\n\t}\n\treturn \"\", fmt.Errorf(\"网卡 %s 没有可用的单播地址\", ifaceName)\n}\n\n// CreateHTTPClientWithInterface 创建绑定指定网卡的HTTP客户端\nfunc CreateHTTPClientWithInterface(ifaceName string) *http.Client {\n\tif ifaceName == \"\" {\n\t\treturn CreateHTTPClient()\n\t}\n\tlocalIP, err := GetLocalAddrFromInterface(ifaceName)\n\tif err != nil {\n\t\tLog(\"绑定网卡失败, 将使用默认网卡. 网卡: %s, 错误: %s\", ifaceName, err)\n\t\treturn CreateHTTPClient()\n\t}\n\tlocalAddr := &net.TCPAddr{IP: net.ParseIP(localIP)}\n\tboundDialer := &net.Dialer{\n\t\tTimeout:   30 * time.Second,\n\t\tKeepAlive: 30 * time.Second,\n\t\tLocalAddr: localAddr,\n\t}\n\ttransport := &http.Transport{\n\t\tProxy: http.ProxyFromEnvironment,\n\t\tDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\t\treturn boundDialer.DialContext(ctx, network, address)\n\t\t},\n\t\tForceAttemptHTTP2:     true,\n\t\tMaxIdleConns:          100,\n\t\tIdleConnTimeout:       90 * time.Second,\n\t\tTLSHandshakeTimeout:   10 * time.Second,\n\t\tExpectContinueTimeout: 1 * time.Second,\n\t}\n\tif insecureSkipVerify {\n\t\ttransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}\n\t}\n\treturn &http.Client{\n\t\tTimeout:   30 * time.Second,\n\t\tTransport: transport,\n\t}\n}\n\nvar noProxyTcp4Transport = &http.Transport{\n\t// no proxy\n\t// DisableKeepAlives\n\tDisableKeepAlives: true,\n\t// tcp4\n\tDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\treturn dialer.DialContext(ctx, \"tcp4\", address)\n\t},\n\t// from http.DefaultTransport\n\tForceAttemptHTTP2:     true,\n\tMaxIdleConns:          100,\n\tIdleConnTimeout:       90 * time.Second,\n\tTLSHandshakeTimeout:   10 * time.Second,\n\tExpectContinueTimeout: 1 * time.Second,\n}\n\nvar noProxyTcp6Transport = &http.Transport{\n\t// no proxy\n\t// DisableKeepAlives\n\tDisableKeepAlives: true,\n\t// tcp6\n\tDialContext: func(ctx context.Context, network, address string) (net.Conn, error) {\n\t\treturn dialer.DialContext(ctx, \"tcp6\", address)\n\t},\n\t// from http.DefaultTransport\n\tForceAttemptHTTP2:     true,\n\tMaxIdleConns:          100,\n\tIdleConnTimeout:       90 * time.Second,\n\tTLSHandshakeTimeout:   10 * time.Second,\n\tExpectContinueTimeout: 1 * time.Second,\n}\n\n// CreateNoProxyHTTPClient Create NoProxy HTTP Client\nfunc CreateNoProxyHTTPClient(network string) *http.Client {\n\tif network == \"tcp6\" {\n\t\treturn &http.Client{\n\t\t\tTimeout:   30 * time.Second,\n\t\t\tTransport: noProxyTcp6Transport,\n\t\t}\n\t}\n\n\treturn &http.Client{\n\t\tTimeout:   30 * time.Second,\n\t\tTransport: noProxyTcp4Transport,\n\t}\n}\n\n// SetInsecureSkipVerify 将所有 http.Transport 的 InsecureSkipVerify 设置为 true\nfunc SetInsecureSkipVerify() {\n\tinsecureSkipVerify = true\n\ttransports := []*http.Transport{defaultTransport, noProxyTcp4Transport, noProxyTcp6Transport}\n\n\tfor _, transport := range transports {\n\t\ttransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}\n\t}\n}\n"
  },
  {
    "path": "util/http_util.go",
    "content": "package util\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n)\n\n// GetHTTPResponse 处理HTTP结果，返回序列化的json\nfunc GetHTTPResponse(resp *http.Response, err error, result interface{}) error {\n\tbody, err := GetHTTPResponseOrg(resp, err)\n\n\tif err == nil {\n\t\t// log.Println(string(body))\n\t\tif len(body) != 0 {\n\t\t\terr = json.Unmarshal(body, &result)\n\t\t}\n\t}\n\n\treturn err\n\n}\n\n// GetHTTPResponseOrg 处理HTTP结果，返回byte\nfunc GetHTTPResponseOrg(resp *http.Response, err error) ([]byte, error) {\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tdefer resp.Body.Close()\n\tlr := io.LimitReader(resp.Body, 1024000)\n\tbody, err := io.ReadAll(lr)\n\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\t// 300及以上状态码都算异常\n\tif resp.StatusCode >= 300 {\n\t\terr = fmt.Errorf(\"%s\", LogStr(\"返回内容: %s ,返回状态码: %d\", string(body), resp.StatusCode))\n\t}\n\n\treturn body, err\n}\n"
  },
  {
    "path": "util/huawei_signer.go",
    "content": "// HWS API Gateway Signature\n// based on https://github.com/datastream/aws/blob/master/signv4.go\n// Copyright (c) 2014, Xianjie\n\npackage util\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"fmt\"\n\t\"io\"\n\t\"net/http\"\n\t\"sort\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst (\n\tBasicDateFormat     = \"20060102T150405Z\"\n\tAlgorithm           = \"SDK-HMAC-SHA256\"\n\tHeaderXDate         = \"X-Sdk-Date\"\n\tHeaderHost          = \"host\"\n\tHeaderAuthorization = \"Authorization\"\n\tHeaderContentSha256 = \"X-Sdk-Content-Sha256\"\n)\n\nfunc hmacsha256(key []byte, data string) ([]byte, error) {\n\th := hmac.New(sha256.New, []byte(key))\n\tif _, err := h.Write([]byte(data)); err != nil {\n\t\treturn nil, err\n\t}\n\treturn h.Sum(nil), nil\n}\n\n// Build a CanonicalRequest from a regular request string\n//\n// CanonicalRequest =\n//\n//\tHTTPRequestMethod + '\\n' +\n//\tCanonicalURI + '\\n' +\n//\tCanonicalQueryString + '\\n' +\n//\tCanonicalHeaders + '\\n' +\n//\tSignedHeaders + '\\n' +\n//\tHexEncode(Hash(RequestPayload))\nfunc CanonicalRequest(r *http.Request, signedHeaders []string) (string, error) {\n\tvar hexencode string\n\tvar err error\n\tif hex := r.Header.Get(HeaderContentSha256); hex != \"\" {\n\t\thexencode = hex\n\t} else {\n\t\tdata, err := RequestPayload(r)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t\thexencode, err = HexEncodeSHA256Hash(data)\n\t\tif err != nil {\n\t\t\treturn \"\", err\n\t\t}\n\t}\n\treturn 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\n}\n\n// CanonicalURI returns request uri\nfunc CanonicalURI(r *http.Request) string {\n\tpatterns := strings.Split(r.URL.Path, \"/\")\n\tvar uri []string\n\tfor _, v := range patterns {\n\t\turi = append(uri, escape(v))\n\t}\n\turlpath := strings.Join(uri, \"/\")\n\tif len(urlpath) == 0 || urlpath[len(urlpath)-1] != '/' {\n\t\turlpath = urlpath + \"/\"\n\t}\n\treturn urlpath\n}\n\n// CanonicalQueryString\nfunc CanonicalQueryString(r *http.Request) string {\n\tvar keys []string\n\tquery := r.URL.Query()\n\tfor key := range query {\n\t\tkeys = append(keys, key)\n\t}\n\tsort.Strings(keys)\n\tvar a []string\n\tfor _, key := range keys {\n\t\tk := escape(key)\n\t\tsort.Strings(query[key])\n\t\tfor _, v := range query[key] {\n\t\t\tkv := fmt.Sprintf(\"%s=%s\", k, escape(v))\n\t\t\ta = append(a, kv)\n\t\t}\n\t}\n\tqueryStr := strings.Join(a, \"&\")\n\tr.URL.RawQuery = queryStr\n\treturn queryStr\n}\n\n// CanonicalHeaders\nfunc CanonicalHeaders(r *http.Request, signerHeaders []string) string {\n\tvar a []string\n\theader := make(map[string][]string)\n\tfor k, v := range r.Header {\n\t\theader[strings.ToLower(k)] = v\n\t}\n\tfor _, key := range signerHeaders {\n\t\tvalue := header[key]\n\t\tif strings.EqualFold(key, HeaderHost) {\n\t\t\tvalue = []string{r.Host}\n\t\t}\n\t\tsort.Strings(value)\n\t\tfor _, v := range value {\n\t\t\ta = append(a, key+\":\"+strings.TrimSpace(v))\n\t\t}\n\t}\n\treturn fmt.Sprintf(\"%s\\n\", strings.Join(a, \"\\n\"))\n}\n\n// SignedHeaders\nfunc SignedHeaders(r *http.Request) []string {\n\tvar a []string\n\tfor key := range r.Header {\n\t\ta = append(a, strings.ToLower(key))\n\t}\n\tsort.Strings(a)\n\treturn a\n}\n\n// RequestPayload\nfunc RequestPayload(r *http.Request) ([]byte, error) {\n\tif r.Body == nil {\n\t\treturn []byte(\"\"), nil\n\t}\n\tb, err := io.ReadAll(r.Body)\n\tif err != nil {\n\t\treturn []byte(\"\"), err\n\t}\n\tr.Body = io.NopCloser(bytes.NewBuffer(b))\n\treturn b, err\n}\n\n// Create a \"String to Sign\".\nfunc StringToSign(canonicalRequest string, t time.Time) (string, error) {\n\thash := sha256.New()\n\t_, err := hash.Write([]byte(canonicalRequest))\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\treturn fmt.Sprintf(\"%s\\n%s\\n%x\",\n\t\tAlgorithm, t.UTC().Format(BasicDateFormat), hash.Sum(nil)), nil\n}\n\n// Create the HWS Signature.\nfunc SignStringToSign(stringToSign string, signingKey []byte) (string, error) {\n\thm, err := hmacsha256(signingKey, stringToSign)\n\treturn fmt.Sprintf(\"%x\", hm), err\n}\n\n// HexEncodeSHA256Hash returns hexcode of sha256\nfunc HexEncodeSHA256Hash(body []byte) (string, error) {\n\thash := sha256.New()\n\tif body == nil {\n\t\tbody = []byte(\"\")\n\t}\n\t_, err := hash.Write(body)\n\treturn fmt.Sprintf(\"%x\", hash.Sum(nil)), err\n}\n\n// Get the finalized value for the \"Authorization\" header. The signature parameter is the output from SignStringToSign\nfunc AuthHeaderValue(signature, accessKey string, signedHeaders []string) string {\n\treturn fmt.Sprintf(\"%s Access=%s, SignedHeaders=%s, Signature=%s\", Algorithm, accessKey, strings.Join(signedHeaders, \";\"), signature)\n}\n\n// Signature HWS meta\ntype Signer struct {\n\tKey    string\n\tSecret string\n}\n\n// SignRequest set Authorization header\nfunc (s *Signer) Sign(r *http.Request) error {\n\tvar t time.Time\n\tvar err error\n\tvar dt string\n\tif dt = r.Header.Get(HeaderXDate); dt != \"\" {\n\t\tt, err = time.Parse(BasicDateFormat, dt)\n\t}\n\tif err != nil || dt == \"\" {\n\t\tt = time.Now()\n\t\tr.Header.Set(HeaderXDate, t.UTC().Format(BasicDateFormat))\n\t}\n\tsignedHeaders := SignedHeaders(r)\n\tcanonicalRequest, err := CanonicalRequest(r, signedHeaders)\n\tif err != nil {\n\t\treturn err\n\t}\n\tstringToSign, err := StringToSign(canonicalRequest, t)\n\tif err != nil {\n\t\treturn err\n\t}\n\tsignature, err := SignStringToSign(stringToSign, []byte(s.Secret))\n\tif err != nil {\n\t\treturn err\n\t}\n\tauthValue := AuthHeaderValue(signature, s.Key, signedHeaders)\n\tr.Header.Set(HeaderAuthorization, authValue)\n\treturn nil\n}\n"
  },
  {
    "path": "util/ip_cache.go",
    "content": "package util\n\nimport (\n\t\"os\"\n\t\"strconv\"\n)\n\nconst IPCacheTimesENV = \"DDNS_IP_CACHE_TIMES\"\n\n// IpCache 上次IP缓存\ntype IpCache struct {\n\tAddr          string // 缓存地址\n\tTimes         int    // 剩余次数\n\tTimesFailedIP int    // 获取ip失败的次数\n}\n\nvar ForceCompareGlobal = true\n\nfunc (d *IpCache) Check(newAddr string) bool {\n\tif newAddr == \"\" {\n\t\treturn true\n\t}\n\t// 地址改变 或 达到剩余次数\n\tif d.Addr != newAddr || d.Times <= 1 {\n\t\tIPCacheTimes, err := strconv.Atoi(os.Getenv(IPCacheTimesENV))\n\t\tif err != nil {\n\t\t\tIPCacheTimes = 5\n\t\t}\n\t\td.Addr = newAddr\n\t\td.Times = IPCacheTimes + 1\n\t\treturn true\n\t}\n\td.Addr = newAddr\n\td.Times--\n\treturn false\n}\n"
  },
  {
    "path": "util/messages.go",
    "content": "package util\n\nimport (\n\t\"log\"\n\t\"strings\"\n\n\t\"golang.org/x/text/language\"\n\t\"golang.org/x/text/message\"\n)\n\nvar logLang = language.English\nvar logPrinter = message.NewPrinter(logLang)\n\nfunc init() {\n\n\tmessage.SetString(language.English, \"可使用 .\\\\ddns-go.exe -s install 安装服务运行\", \"You can use '.\\\\ddns-go.exe -s install' to install service\")\n\tmessage.SetString(language.English, \"可使用 sudo ./ddns-go -s install 安装服务运行\", \"You can use 'sudo ./ddns-go -s install' to install service\")\n\tmessage.SetString(language.English, \"监听 %s\", \"Listening on %s\")\n\tmessage.SetString(language.English, \"配置文件已保存在: %s\", \"Config file has been saved to: %s\")\n\n\tmessage.SetString(language.English, \"你的IP %s 没有变化, 域名 %s\", \"Your's IP %s has not changed! Domain: %s\")\n\tmessage.SetString(language.English, \"新增域名解析 %s 成功! IP: %s\", \"Added domain %s successfully! IP: %s\")\n\tmessage.SetString(language.English, \"新增域名解析 %s 失败! 异常信息: %s\", \"Failed to add domain %s! Result: %s\")\n\n\tmessage.SetString(language.English, \"更新域名解析 %s 成功! IP: %s\", \"Updated domain %s successfully! IP: %s\")\n\tmessage.SetString(language.English, \"更新域名解析 %s 失败! 异常信息: %s\", \"Failed to updated domain %s! Result: %s\")\n\n\tmessage.SetString(language.English, \"你的IPv4未变化, 未触发 %s 请求\", \"Your's IPv4 has not changed, %s request has not been triggered\")\n\tmessage.SetString(language.English, \"你的IPv6未变化, 未触发 %s 请求\", \"Your's IPv6 has not changed, %s request has not been triggered\")\n\tmessage.SetString(language.English, \"Namecheap 不支持更新 IPv6\", \"Namecheap does not support IPv6\")\n\n\tmessage.SetString(language.English, \"dynadot仅支持单域名配置，多个域名请添加更多配置\", \"dynadot only supports single domain configuration, please add more configurations\")\n\n\t// http_util\n\tmessage.SetString(language.English, \"异常信息: %s\", \"Exception: %s\")\n\tmessage.SetString(language.English, \"查询域名信息发生异常! %s\", \"Failed to query domain info! %s\")\n\tmessage.SetString(language.English, \"返回内容: %s ,返回状态码: %d\", \"Response body: %s ,Response status code: %d\")\n\tmessage.SetString(language.English, \"通过接口获取IPv4失败! 接口地址: %s\", \"Failed to get IPv4 from %s\")\n\tmessage.SetString(language.English, \"通过接口获取IPv6失败! 接口地址: %s\", \"Failed to get IPv6 from %s\")\n\tmessage.SetString(language.English, \"将不会触发Webhook, 仅在第 3 次失败时触发一次Webhook, 当前失败次数：%d\", \"Webhook will not be triggered, only trigger once when the third failure, current failure times: %d\")\n\tmessage.SetString(language.English, \"在DNS服务商中未找到根域名: %s\", \"Root domain not found in DNS provider: %s\")\n\n\t// webhook\n\tmessage.SetString(language.English, \"Webhook配置中的URL不正确\", \"Webhook url is incorrect\")\n\tmessage.SetString(language.English, \"Webhook中的 RequestBody JSON 无效\", \"Webhook RequestBody JSON is invalid\")\n\tmessage.SetString(language.English, \"Webhook调用成功! 返回数据：%s\", \"Successfully called Webhook! Response body: %s\")\n\tmessage.SetString(language.English, \"Webhook调用失败! 异常信息：%s\", \"Failed to call Webhook! Exception: %s\")\n\tmessage.SetString(language.English, \"Webhook Header不正确: %s\", \"Webhook header is invalid: %s\")\n\tmessage.SetString(language.English, \"请输入Webhook的URL\", \"Please enter the Webhook url\")\n\n\t// callback\n\tmessage.SetString(language.English, \"Callback的URL不正确\", \"Callback url is incorrect\")\n\tmessage.SetString(language.English, \"Callback调用成功, 域名: %s, IP: %s, 返回数据: %s\", \"Successfully called Callback! Domain: %s, IP: %s, Response body: %s\")\n\tmessage.SetString(language.English, \"Callback调用失败, 异常信息: %s\", \"Failed to call Callback! Exception: %s\")\n\n\t// save\n\tmessage.SetString(language.English, \"必须输入用户名/密码\", \"Username/Password is required\")\n\tmessage.SetString(language.English, \"密码不安全！尝试使用更复杂的密码\", \"Password is not secure! Try using a more complex password\")\n\tmessage.SetString(language.English, \"数据解析失败, 请刷新页面重试\", \"Data parsing failed, please refresh the page and try again\")\n\tmessage.SetString(language.English, \"第 %s 个配置未填写域名\", \"The %s config does not fill in the domain\")\n\n\t// config\n\tmessage.SetString(language.English, \"从网卡获得IPv4失败\", \"Failed to get IPv4 from network card\")\n\tmessage.SetString(language.English, \"从网卡中获得IPv4失败! 网卡名: %s\", \"Failed to get IPv4 from network card! Network card name: %s\")\n\tmessage.SetString(language.English, \"获取IPv4结果失败! 接口: %s ,返回值: %s\", \"Failed to get IPv4 result! Interface: %s ,Result: %s\")\n\tmessage.SetString(language.English, \"获取%s结果失败! 未能成功执行命令：%s, 错误：%q, 退出状态码：%s\", \"Failed to get %s result! Command: %s, Error: %q, Exit status code: %s\")\n\tmessage.SetString(language.English, \"获取%s结果失败! 命令: %s, 标准输出: %q\", \"Failed to get %s result! Command: %s, Stdout: %q\")\n\tmessage.SetString(language.English, \"从网卡获得IPv6失败\", \"Failed to get IPv6 from network card\")\n\tmessage.SetString(language.English, \"从网卡中获得IPv6失败! 网卡名: %s\", \"Failed to get IPv6 from network card! Network card name: %s\")\n\tmessage.SetString(language.English, \"获取IPv6结果失败! 接口: %s ,返回值: %s\", \"Failed to get IPv6 result! Interface: %s ,Result: %s\")\n\tmessage.SetString(language.English, \"未找到第 %d 个IPv6地址! 将使用第一个IPv6地址\", \"%dth IPv6 address not found! Will use the first IPv6 address\")\n\tmessage.SetString(language.English, \"IPv6匹配表达式 %s 不正确! 最小从1开始\", \"IPv6 match expression %s is incorrect! Minimum start from 1\")\n\tmessage.SetString(language.English, \"IPv6将使用正则表达式 %s 进行匹配\", \"IPv6 will use regular expression %s for matching\")\n\tmessage.SetString(language.English, \"匹配成功! 匹配到地址: %s\", \"Match successfully! Matched address: %s\")\n\tmessage.SetString(language.English, \"没有匹配到任何一个IPv6地址, 将使用第一个地址\", \"No IPv6 address matched, will use the first address\")\n\tmessage.SetString(language.English, \"未能获取IPv4地址, 将不会更新\", \"Failed to get IPv4 address, will not update\")\n\tmessage.SetString(language.English, \"未能获取IPv6地址, 将不会更新\", \"Failed to get IPv6 address, will not update\")\n\n\t// domains\n\tmessage.SetString(language.English, \"域名: %s 不正确\", \"The domain %s is incorrect\")\n\tmessage.SetString(language.English, \"域名: %s 解析失败\", \"The domain %s resolution failed\")\n\tmessage.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.\")\n\tmessage.SetString(language.English, \"IPv6未改变, 将等待 %d 次后与DNS服务商进行比对\", \"IPv6 has not changed, will wait %d times to compare with DNS provider\")\n\tmessage.SetString(language.English, \"IPv4未改变, 将等待 %d 次后与DNS服务商进行比对\", \"IPv4 has not changed, will wait %d times to compare with DNS provider\")\n\n\tmessage.SetString(language.English, \"本机DNS异常! 将默认使用 %s, 可参考文档通过 -dns 自定义 DNS 服务器\", \"Local DNS exception! Will use %s by default, you can use -dns to customize DNS server\")\n\tmessage.SetString(language.English, \"等待网络连接: %s\", \"Waiting for network connection: %s\")\n\tmessage.SetString(language.English, \"%s 后重试...\", \"Retry after %s\")\n\tmessage.SetString(language.English, \"网络已连接\", \"The network is connected\")\n\n\t// main\n\tmessage.SetString(language.English, \"监听端口发生异常, 请检查端口是否被占用! %s\", \"Port listening failed, please check if the port is occupied! %s\")\n\tmessage.SetString(language.English, \"ddns-go 服务卸载成功\", \"ddns-go service uninstalled successfully\")\n\tmessage.SetString(language.English, \"ddns-go 服务卸载失败, 异常信息: %s\", \"ddns-go service uninstallation failed, Exception: %s\")\n\tmessage.SetString(language.English, \"安装 ddns-go 服务成功! 请打开浏览器并进行配置\", \"Installed ddns-go service successfully! Please open the browser and configure it\")\n\tmessage.SetString(language.English, \"安装 ddns-go 服务失败, 异常信息: %s\", \"Failed to install ddns-go service, Exception: %s\")\n\tmessage.SetString(language.English, \"ddns-go 服务已安装, 无需再次安装\", \"ddns-go service has been installed, no need to install again\")\n\tmessage.SetString(language.English, \"重启 ddns-go 服务成功\", \"restarted ddns-go service successfully\")\n\tmessage.SetString(language.English, \"启动 ddns-go 服务成功\", \"started ddns-go service successfully\")\n\tmessage.SetString(language.English, \"ddns-go 服务未安装, 请先安装服务\", \"ddns-go service is not installed, please install the service first\")\n\n\t// webhook通知\n\tmessage.SetString(language.English, \"未改变\", \"no changed\")\n\tmessage.SetString(language.English, \"失败\", \"failed\")\n\tmessage.SetString(language.English, \"成功\", \"success\")\n\n\t// Login\n\tmessage.SetString(language.English, \"%q 配置文件为空, 超过3小时禁止从公网访问\", \"%q configuration file is empty, public network access is prohibited for more than 3 hours\")\n\tmessage.SetString(language.English, \"%q 被禁止从公网访问\", \"%q is prohibited from accessing the public network\")\n\tmessage.SetString(language.English, \"%q 帐号密码不正确\", \"%q username or password is incorrect\")\n\tmessage.SetString(language.English, \"%q 登录成功\", \"%q login successfully\")\n\tmessage.SetString(language.English, \"用户名或密码错误\", \"Username or password is incorrect\")\n\tmessage.SetString(language.English, \"登录失败次数过多，请稍后再试\", \"Too many login failures, please try again later\")\n\tmessage.SetString(language.English, \"用户名 %s 的密码已重置成功! 请重启ddns-go\", \"The password of username %s has been reset successfully! Please restart ddns-go\")\n\tmessage.SetString(language.English, \"需在 %s 之前完成用户名密码设置,请重启ddns-go\", \"Need to complete the username and password setting before %s, please restart ddns-go\")\n\tmessage.SetString(language.English, \"配置文件 %s 不存在, 可通过-c指定配置文件\", \"Config file %s does not exist, you can specify the configuration file through -c\")\n\n}\n\nfunc Log(key string, args ...interface{}) {\n\tlog.Println(LogStr(key, args...))\n}\n\nfunc LogStr(key string, args ...interface{}) string {\n\treturn logPrinter.Sprintf(key, args...)\n}\n\nfunc InitLogLang(lang string) string {\n\tnewLang := language.English\n\tif strings.HasPrefix(lang, \"zh\") {\n\t\tnewLang = language.Chinese\n\t}\n\tif newLang != logLang {\n\t\tlogLang = newLang\n\t\tlogPrinter = message.NewPrinter(logLang)\n\t}\n\treturn logLang.String()\n}\n"
  },
  {
    "path": "util/net.go",
    "content": "package util\n\nimport (\n\t\"net\"\n\t\"net/http\"\n\t\"strings\"\n)\n\n// IsPrivateNetwork 是否为私有地址\n// https://en.wikipedia.org/wiki/Private_network\nfunc IsPrivateNetwork(remoteAddr string) bool {\n\t// removing optional port from remoteAddr\n\tif strings.HasPrefix(remoteAddr, \"[\") { // ipv6\n\t\tif index := strings.LastIndex(remoteAddr, \"]\"); index != -1 {\n\t\t\tremoteAddr = remoteAddr[1:index]\n\t\t} else {\n\t\t\treturn false\n\t\t}\n\t} else { // ipv4\n\t\tif index := strings.LastIndex(remoteAddr, \":\"); index != -1 {\n\t\t\tremoteAddr = remoteAddr[:index]\n\t\t}\n\t}\n\n\tif ip := net.ParseIP(remoteAddr); ip != nil {\n\t\treturn ip.IsLoopback() || // 127/8, ::1\n\t\t\tip.IsPrivate() || // 10/8, 172.16/12, 192.168/16, fc00::/7\n\t\t\tip.IsLinkLocalUnicast() // 169.254/16, fe80::/10\n\t}\n\n\treturn false\n}\n\n// GetRequestIPStr get IP string from request\nfunc GetRequestIPStr(r *http.Request) (addr string) {\n\taddr = \"Remote: \" + r.RemoteAddr\n\tif r.Header.Get(\"X-Real-IP\") != \"\" {\n\t\taddr = addr + \" ,Real-IP: \" + r.Header.Get(\"X-Real-IP\")\n\t}\n\tif r.Header.Get(\"X-Forwarded-For\") != \"\" {\n\t\taddr = addr + \" ,Forwarded-For: \" + r.Header.Get(\"X-Forwarded-For\")\n\t}\n\treturn addr\n}\n"
  },
  {
    "path": "util/net_resolver.go",
    "content": "package util\n\nimport (\n\t\"context\"\n\t\"net\"\n\t\"net/url\"\n\t\"strings\"\n\n\t\"golang.org/x/text/language\"\n)\n\n// BackupDNS will be used if DNS error occurs.\nvar BackupDNS = []string{\"1.1.1.1\", \"8.8.8.8\", \"9.9.9.9\", \"223.5.5.5\"}\n\nfunc InitBackupDNS(customDNS, lang string) {\n\tif customDNS != \"\" {\n\t\tBackupDNS = []string{customDNS}\n\t\treturn\n\t}\n\n\tif lang == language.Chinese.String() {\n\t\tBackupDNS = []string{\"223.5.5.5\", \"114.114.114.114\", \"119.29.29.29\"}\n\t}\n\n}\n\n// SetDNS sets the dialer.Resolver to use the given DNS server.\nfunc SetDNS(dns string) {\n\n\tif !strings.Contains(dns, \"://\") {\n\t\tdns = \"udp://\" + dns\n\t}\n\tsvrParse, _ := url.Parse(dns)\n\n\tvar network string\n\tswitch strings.ToLower(svrParse.Scheme) {\n\tcase \"tcp\":\n\t\tnetwork = \"tcp\"\n\tdefault:\n\t\tnetwork = \"udp\"\n\t}\n\n\tif svrParse.Port() == \"\" {\n\t\tdns = net.JoinHostPort(svrParse.Host, \"53\")\n\t} else {\n\t\tdns = svrParse.Host\n\t}\n\n\tdialer.Resolver = &net.Resolver{\n\t\tPreferGo: true,\n\t\tDial: func(ctx context.Context, _, address string) (net.Conn, error) {\n\t\t\treturn net.Dial(network, dns)\n\t\t},\n\t}\n}\n\n// LookupHost looks up the host based on the given URL using the dialer.Resolver.\n// A wrapper for [net.Resolver.LookupHost].\nfunc LookupHost(url string) error {\n\tname := toHostname(url)\n\n\t_, err := dialer.Resolver.LookupHost(context.Background(), name)\n\treturn err\n}\n"
  },
  {
    "path": "util/net_resolver_test.go",
    "content": "package util\n\nimport \"testing\"\n\nconst (\n\ttestDNS = \"1.1.1.1\"\n\ttestURL = \"https://cloudflare.com\"\n)\n\nfunc TestSetDNS(t *testing.T) {\n\tSetDNS(testDNS)\n\n\tif dialer.Resolver == nil {\n\t\tt.Error(\"Failed to set dialer.Resolver\")\n\t}\n}\n\nfunc TestLookupHost(t *testing.T) {\n\tt.Run(\"Valid URL\", func(t *testing.T) {\n\t\tif err := LookupHost(testURL); err != nil {\n\t\t\tt.Errorf(\"Expected nil error, got %v\", err)\n\t\t}\n\t})\n\n\tt.Run(\"Invalid URL\", func(t *testing.T) {\n\t\tif err := LookupHost(\"invalidurl\"); err == nil {\n\t\t\tt.Error(\"Expected error, got nil\")\n\t\t}\n\t})\n\n\tt.Run(\"After SetDNS\", func(t *testing.T) {\n\t\tSetDNS(testDNS)\n\n\t\tif err := LookupHost(testURL); err != nil {\n\t\t\tt.Errorf(\"Expected nil error, got %v\", err)\n\t\t}\n\t})\n}\n"
  },
  {
    "path": "util/net_test.go",
    "content": "package util\n\nimport (\n\t\"net/http\"\n\t\"testing\"\n)\n\n// TestIsPrivateNetwork 测试是否为私有地址\nfunc TestIsPrivateNetwork(t *testing.T) {\n\n\tdata := map[string]bool{\n\t\t\"127.0.0.1\":         true, // listen on default port\n\t\t\"127.0.0.1:9876\":    true,\n\t\t\"[::1]\":             true,\n\t\t\"[::1]:9876\":        true,\n\t\t\"192.168.1.18:9876\": true,\n\t\t\"172.16.1.18:9876\":  true,\n\t\t\"10.1.1.18:9876\":    true,\n\t\t\"[fe80::1]:9876\":    true,\n\t\t\"[fd00::1]:9876\":    true,\n\t\t\"100.0.0.1\":         false,\n\t\t\"100.0.0.1:9876\":    false,\n\t\t\"[2409::1]\":         false,\n\t\t\"[2409::1]:9876\":    false,\n\t\t\"223.5.5.5:9876\":    false,\n\t}\n\n\tfor key, value := range data {\n\t\tif IsPrivateNetwork(key) != value {\n\t\t\tt.Errorf(\"%s 校验失败\\n\", key)\n\t\t}\n\n\t}\n}\n\n// test get request IP string from request\nfunc TestGetRequestIPStr(t *testing.T) {\n\treq := http.Request{RemoteAddr: \"192.168.1.1\", Header: http.Header{}}\n\treq.Header.Set(\"X-Real-IP\", \"10.0.0.1\")\n\treq.Header.Set(\"X-Forwarded-For\", \"10.0.0.2\")\n\tif GetRequestIPStr(&req) != \"Remote: 192.168.1.1 ,Real-IP: 10.0.0.1 ,Forwarded-For: 10.0.0.2\" {\n\t\tt.Errorf(\"GetRequestIPStr failed\")\n\t}\n}\n"
  },
  {
    "path": "util/ordinal.go",
    "content": "package util\n\nimport (\n\t\"strconv\"\n\n\t\"golang.org/x/text/language\"\n)\n\n// Ordinal returns the ordinal format of the given number.\n//\n// See also: https://github.com/dustin/go-humanize/blob/master/ordinals.go\nfunc Ordinal(x int, lang string) string {\n\ts := strconv.Itoa(x)\n\n\t// Chinese doesn't require an ordinal\n\tif lang == language.Chinese.String() {\n\t\treturn s\n\t}\n\n\tsuffix := \"th\"\n\tswitch x % 10 {\n\tcase 1:\n\t\tif x%100 != 11 {\n\t\t\tsuffix = \"st\"\n\t\t}\n\tcase 2:\n\t\tif x%100 != 12 {\n\t\t\tsuffix = \"nd\"\n\t\t}\n\tcase 3:\n\t\tif x%100 != 13 {\n\t\t\tsuffix = \"rd\"\n\t\t}\n\t}\n\treturn s + suffix\n}\n"
  },
  {
    "path": "util/ordinal_test.go",
    "content": "package util\n\nimport \"testing\"\n\nfunc TestOrdinal(t *testing.T) {\n\tlang := \"en\"\n\n\ttests := []struct {\n\t\tname string\n\t\tgot  string\n\t\twant string\n\t}{\n\t\t{\"0\", Ordinal(0, lang), \"0th\"},\n\t\t{\"1\", Ordinal(1, lang), \"1st\"},\n\t\t{\"2\", Ordinal(2, lang), \"2nd\"},\n\t\t{\"3\", Ordinal(3, lang), \"3rd\"},\n\t\t{\"4\", Ordinal(4, lang), \"4th\"},\n\t\t{\"10\", Ordinal(10, lang), \"10th\"},\n\t\t{\"11\", Ordinal(11, lang), \"11th\"},\n\t\t{\"12\", Ordinal(12, lang), \"12th\"},\n\t\t{\"13\", Ordinal(13, lang), \"13th\"},\n\t\t{\"21\", Ordinal(21, lang), \"21st\"},\n\t\t{\"32\", Ordinal(32, lang), \"32nd\"},\n\t\t{\"43\", Ordinal(43, lang), \"43rd\"},\n\t\t{\"101\", Ordinal(101, lang), \"101st\"},\n\t\t{\"102\", Ordinal(102, lang), \"102nd\"},\n\t\t{\"103\", Ordinal(103, lang), \"103rd\"},\n\t\t{\"211\", Ordinal(211, lang), \"211th\"},\n\t\t{\"212\", Ordinal(212, lang), \"212th\"},\n\t\t{\"213\", Ordinal(213, lang), \"213th\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tif tt.got != tt.want {\n\t\t\tt.Errorf(\"On %s, Expected %s, but got %s\", tt.name, tt.want, tt.got)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/osutil/daemon_unix.go",
    "content": "//go:build !windows\n\npackage osutil\n\nimport (\n\t\"os\"\n\t\"syscall\"\n)\n\n// StartDetachedProcess starts a process detached from terminal on Unix-like systems.\nfunc StartDetachedProcess(exe string, args []string, nullFile *os.File) (*os.Process, error) {\n\tattr := &os.ProcAttr{\n\t\tEnv:   append(os.Environ(), \"DDNS_GO_DAEMON=1\"),\n\t\tFiles: []*os.File{nullFile, nullFile, nullFile},\n\t\tSys:   &syscall.SysProcAttr{Setsid: true},\n\t}\n\treturn os.StartProcess(exe, args, attr)\n}\n"
  },
  {
    "path": "util/osutil/daemon_win32.go",
    "content": "//go:build windows\n\npackage osutil\n\nimport (\n\t\"os\"\n\t\"syscall\"\n)\n\n// StartDetachedProcess starts a process detached from console on Windows.\nfunc StartDetachedProcess(exe string, args []string, nullFile *os.File) (*os.Process, error) {\n\tconst (\n\t\tDETACHED_PROCESS         = 0x00000008\n\t\tCREATE_NEW_PROCESS_GROUP = 0x00000200\n\t\tCREATE_NO_WINDOW         = 0x08000000\n\t)\n\n\tattr := &os.ProcAttr{\n\t\tEnv:   append(os.Environ(), \"DDNS_GO_DAEMON=1\"),\n\t\tFiles: []*os.File{nullFile, nullFile, nullFile},\n\t\tSys:   &syscall.SysProcAttr{CreationFlags: DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW, HideWindow: true},\n\t}\n\n\treturn os.StartProcess(exe, args, attr)\n}\n"
  },
  {
    "path": "util/semver/version.go",
    "content": "// Based on https://github.com/Masterminds/semver/blob/v3.2.1/version.go\n\npackage semver\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"regexp\"\n\t\"strconv\"\n\t\"strings\"\n)\n\n// 在 init() 中创建的正则表达式的编译版本被缓存在这里，这样\n// 它只需要被创建一次。\nvar versionRegex *regexp.Regexp\n\n// semVerRegex 是用于解析语义化版本的正则表达式。\nconst semVerRegex string = `v?([0-9]+)(\\.[0-9]+)?(\\.[0-9]+)?` +\n\t`(-([0-9A-Za-z\\-]+(\\.[0-9A-Za-z\\-]+)*))?` +\n\t`(\\+([0-9A-Za-z\\-]+(\\.[0-9A-Za-z\\-]+)*))?`\n\n// Version 表示单独的语义化版本。\ntype Version struct {\n\tmajor, minor, patch uint64\n}\n\nfunc init() {\n\tversionRegex = regexp.MustCompile(\"^\" + semVerRegex + \"$\")\n}\n\n// NewVersion 解析给定的版本并返回 Version 实例，如果\n// 无法解析该版本则返回错误。如果版本是类似于 SemVer 的版本，则会\n// 尝试将其转换为 SemVer。\nfunc NewVersion(v string) (*Version, error) {\n\tm := versionRegex.FindStringSubmatch(v)\n\tif m == nil {\n\t\treturn nil, fmt.Errorf(\"the %s, it's not a semantic version\", v)\n\t}\n\n\tsv := &Version{}\n\n\tvar err error\n\tsv.major, err = strconv.ParseUint(m[1], 10, 64)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"解析版本号时出错：%s\", err)\n\t}\n\n\tif m[2] != \"\" {\n\t\tsv.minor, err = strconv.ParseUint(strings.TrimPrefix(m[2], \".\"), 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"解析版本号时出错：%s\", err)\n\t\t}\n\t} else {\n\t\tsv.minor = 0\n\t}\n\n\tif m[3] != \"\" {\n\t\tsv.patch, err = strconv.ParseUint(strings.TrimPrefix(m[3], \".\"), 10, 64)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"解析版本号时出错：%s\", err)\n\t\t}\n\t} else {\n\t\tsv.patch = 0\n\t}\n\n\treturn sv, nil\n}\n\n// String 将 Version 对象转换为字符串。\n// 注意，如果原始版本包含前缀 v，则转换后的版本将不包含 v。\n// 根据规范，语义版本不包含前缀 v，而在实现上则是可选的。\nfunc (v Version) String() string {\n\tvar buf bytes.Buffer\n\n\tfmt.Fprintf(&buf, \"%d.%d.%d\", v.major, v.minor, v.patch)\n\n\treturn buf.String()\n}\n\n// GreaterThan 测试一个版本是否大于另一个版本。\nfunc (v *Version) GreaterThan(o *Version) bool {\n\treturn v.compare(o) > 0\n}\n\n// GreaterThanOrEqual 测试一个版本是否大于或等于另一个版本。\nfunc (v *Version) GreaterThanOrEqual(o *Version) bool {\n\treturn v.compare(o) >= 0\n}\n\n// compare 比较当前版本与另一个版本。如果当前版本小于另一个版本则返回 -1；如果两个版本相等则返回 0；如果当前版本大于另一个版本，则返回 1。\n//\n// 版本比较是基于 X.Y.Z 格式进行的。\nfunc (v *Version) compare(o *Version) int {\n\t// 比较主版本号、次版本号和修订号。如果\n\t// 发现差异则返回比较结果。\n\tif d := compareSegment(v.major, o.major); d != 0 {\n\t\treturn d\n\t}\n\tif d := compareSegment(v.minor, o.minor); d != 0 {\n\t\treturn d\n\t}\n\tif d := compareSegment(v.patch, o.patch); d != 0 {\n\t\treturn d\n\t}\n\n\treturn 0\n}\n\nfunc compareSegment(v, o uint64) int {\n\tif v < o {\n\t\treturn -1\n\t}\n\tif v > o {\n\t\treturn 1\n\t}\n\n\treturn 0\n}\n"
  },
  {
    "path": "util/semver/version_test.go",
    "content": "// Based on https://github.com/Masterminds/semver/blob/v3.2.1/version_test.go\n\npackage semver\n\nimport \"testing\"\n\nfunc TestNewVersion(t *testing.T) {\n\ttests := []struct {\n\t\tversion string\n\t\terr     bool\n\t}{\n\t\t{\"1.2.3\", false},\n\t\t{\"1.2.3+test.01\", false},\n\t\t{\"1.2.3-alpha.-1\", false},\n\t\t{\"v1.2.3\", false},\n\t\t{\"1.0\", false},\n\t\t{\"v1.0\", false},\n\t\t{\"1\", false},\n\t\t{\"v1\", false},\n\t\t{\"1.2.beta\", true},\n\t\t{\"v1.2.beta\", true},\n\t\t{\"foo\", true},\n\t\t{\"1.2-5\", false},\n\t\t{\"v1.2-5\", false},\n\t\t{\"1.2-beta.5\", false},\n\t\t{\"v1.2-beta.5\", false},\n\t\t{\"\\n1.2\", true},\n\t\t{\"\\nv1.2\", true},\n\t\t{\"1.2.0-x.Y.0+metadata\", false},\n\t\t{\"v1.2.0-x.Y.0+metadata\", false},\n\t\t{\"1.2.0-x.Y.0+metadata-width-hyphen\", false},\n\t\t{\"v1.2.0-x.Y.0+metadata-width-hyphen\", false},\n\t\t{\"1.2.3-rc1-with-hyphen\", false},\n\t\t{\"v1.2.3-rc1-with-hyphen\", false},\n\t\t{\"1.2.3.4\", true},\n\t\t{\"v1.2.3.4\", true},\n\t\t{\"1.2.2147483648\", false},\n\t\t{\"1.2147483648.3\", false},\n\t\t{\"2147483648.3.0\", false},\n\n\t\t// Due to having 4 parts these should produce an error. See\n\t\t// https://github.com/Masterminds/semver/issues/185 for the reason for\n\t\t// these tests.\n\t\t{\"12.3.4.1234\", true},\n\t\t{\"12.23.4.1234\", true},\n\t\t{\"12.3.34.1234\", true},\n\n\t\t// The SemVer spec in a pre-release expects to allow [0-9A-Za-z-].\n\t\t{\"20221209-update-renovatejson-v4\", false},\n\t}\n\n\tfor _, tc := range tests {\n\t\t_, err := NewVersion(tc.version)\n\t\tif tc.err && err == nil {\n\t\t\tt.Fatalf(\"expected error for version: %s\", tc.version)\n\t\t} else if !tc.err && err != nil {\n\t\t\tt.Fatalf(\"error for version %s: %s\", tc.version, err)\n\t\t}\n\t}\n}\n\nfunc TestParts(t *testing.T) {\n\tv, err := NewVersion(\"1.2.3\")\n\tif err != nil {\n\t\tt.Error(\"Error parsing version 1.2.3\")\n\t}\n\n\tif v.major != 1 {\n\t\tt.Error(\"major returning wrong value\")\n\t}\n\tif v.minor != 2 {\n\t\tt.Error(\"minor returning wrong value\")\n\t}\n\tif v.patch != 3 {\n\t\tt.Error(\"patch returning wrong value\")\n\t}\n}\n\nfunc TestCoerceString(t *testing.T) {\n\ttests := []struct {\n\t\tversion  string\n\t\texpected string\n\t}{\n\t\t{\"1.2.3\", \"1.2.3\"},\n\t\t{\"v1.2.3\", \"1.2.3\"},\n\t\t{\"1.0\", \"1.0.0\"},\n\t\t{\"v1.0\", \"1.0.0\"},\n\t\t{\"1\", \"1.0.0\"},\n\t\t{\"v1\", \"1.0.0\"},\n\t}\n\n\tfor _, tc := range tests {\n\t\tv, err := NewVersion(tc.version)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version %s\", tc)\n\t\t}\n\n\t\ts := v.String()\n\t\tif s != tc.expected {\n\t\t\tt.Errorf(\"Error generating string. Expected '%s' but got '%s'\", tc.expected, s)\n\t\t}\n\t}\n}\n\nfunc TestCompare(t *testing.T) {\n\ttests := []struct {\n\t\tv1       string\n\t\tv2       string\n\t\texpected int\n\t}{\n\t\t{\"1.2.3\", \"1.5.1\", -1},\n\t\t{\"2.2.3\", \"1.5.1\", 1},\n\t\t{\"2.2.3\", \"2.2.2\", 1},\n\t}\n\n\tfor _, tc := range tests {\n\t\tv1, err := NewVersion(tc.v1)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version: %s\", err)\n\t\t}\n\n\t\tv2, err := NewVersion(tc.v2)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version: %s\", err)\n\t\t}\n\n\t\ta := v1.compare(v2)\n\t\te := tc.expected\n\t\tif a != e {\n\t\t\tt.Errorf(\n\t\t\t\t\"Comparison of '%s' and '%s' failed. Expected '%d', got '%d'\",\n\t\t\t\ttc.v1, tc.v2, e, a,\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc TestGreaterThan(t *testing.T) {\n\ttests := []struct {\n\t\tv1       string\n\t\tv2       string\n\t\texpected bool\n\t}{\n\t\t{\"1.2.3\", \"1.5.1\", false},\n\t\t{\"2.2.3\", \"1.5.1\", true},\n\t\t{\"3.2-beta\", \"3.2-beta\", false},\n\t\t{\"3.2.0-beta.1\", \"3.2.0-beta.5\", false},\n\t\t{\"7.43.0-SNAPSHOT.99\", \"7.43.0-SNAPSHOT.103\", false},\n\t\t{\"7.43.0-SNAPSHOT.99\", \"7.43.0-SNAPSHOT.BAR\", false},\n\t}\n\n\tfor _, tc := range tests {\n\t\tv1, err := NewVersion(tc.v1)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version: %s\", err)\n\t\t}\n\n\t\tv2, err := NewVersion(tc.v2)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version: %s\", err)\n\t\t}\n\n\t\ta := v1.GreaterThan(v2)\n\t\te := tc.expected\n\t\tif a != e {\n\t\t\tt.Errorf(\n\t\t\t\t\"Comparison of '%s' and '%s' failed. Expected '%t', got '%t'\",\n\t\t\t\ttc.v1, tc.v2, e, a,\n\t\t\t)\n\t\t}\n\t}\n}\n\nfunc TestGreaterThanOrEqual(t *testing.T) {\n\ttests := []struct {\n\t\tv1       string\n\t\tv2       string\n\t\texpected bool\n\t}{\n\t\t{\"1.2.3\", \"1.5.1\", false},\n\t\t{\"2.2.3\", \"1.5.1\", true},\n\t\t{\"3.2-beta\", \"3.2-beta\", true},\n\t\t{\"3.2-beta.4\", \"3.2-beta.2\", true},\n\t\t{\"7.43.0-SNAPSHOT.FOO\", \"7.43.0-SNAPSHOT.103\", true},\n\t}\n\n\tfor _, tc := range tests {\n\t\tv1, err := NewVersion(tc.v1)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version: %s\", err)\n\t\t}\n\n\t\tv2, err := NewVersion(tc.v2)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"Error parsing version: %s\", err)\n\t\t}\n\n\t\ta := v1.GreaterThanOrEqual(v2)\n\t\te := tc.expected\n\t\tif a != e {\n\t\t\tt.Errorf(\n\t\t\t\t\"Comparison of '%s' and '%s' failed. Expected '%t', got '%t'\",\n\t\t\t\ttc.v1, tc.v2, e, a,\n\t\t\t)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "util/string.go",
    "content": "package util\n\nimport (\n\t\"net/url\"\n\t\"strings\"\n)\n\n// WriteString creates a new string using [strings.Builder].\nfunc WriteString(strs ...string) string {\n\tvar b strings.Builder\n\tfor _, str := range strs {\n\t\tb.WriteString(str)\n\t}\n\n\treturn b.String()\n}\n\n// toHostname normalizes a URL with a https scheme to just its hostname.\n//\n// See also:\n//\n//   - https://github.com/moby/moby/blob/v25.0.3/registry/auth.go#L132\nfunc toHostname(url string) string {\n\tstripped := url\n\tstripped = strings.TrimPrefix(stripped, \"https://\")\n\n\treturn strings.Split(stripped, \"/\")[0]\n}\n\n// SplitLines splits a string into lines by '\\r\\n' or '\\n'.\nfunc SplitLines(s string) []string {\n\tif strings.Contains(s, \"\\r\\n\") {\n\t\treturn strings.Split(s, \"\\r\\n\")\n\t}\n\n\treturn strings.Split(s, \"\\n\")\n}\n\nfunc PercentEncode(value string) string {\n\tif value == \"\" {\n\t\treturn \"\"\n\t}\n\t// 使用Go标准库进行URL编码\n\tencoded := url.QueryEscape(value)\n\t// 按照RFC3986规则调整编码\n\tencoded = strings.ReplaceAll(encoded, \"+\", \"%20\")\n\tencoded = strings.ReplaceAll(encoded, \"*\", \"%2A\")\n\tencoded = strings.ReplaceAll(encoded, \"%7E\", \"~\")\n\treturn encoded\n}\n"
  },
  {
    "path": "util/string_test.go",
    "content": "package util\n\nimport \"testing\"\n\nfunc TestWriteString(t *testing.T) {\n\ttests := []struct {\n\t\tinput    []string\n\t\texpected string\n\t}{\n\t\t{[]string{\"hello\", \"world\"}, \"helloworld\"},\n\t\t{[]string{\"\", \"test\"}, \"test\"},\n\t\t{[]string{\"hello\", \" \", \"world\"}, \"hello world\"},\n\t\t{[]string{\"\"}, \"\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tresult := WriteString(tt.input...)\n\t\tif result != tt.expected {\n\t\t\tt.Errorf(\"Expected %s, but got %s\", tt.expected, result)\n\t\t}\n\t}\n}\n\nfunc TestToHostname(t *testing.T) {\n\ttests := []struct {\n\t\tname     string\n\t\tinput    string\n\t\texpected string\n\t}{\n\t\t{\"With https scheme\", \"https://www.example.com\", \"www.example.com\"},\n\t\t{\"With path\", \"www.example.com/path\", \"www.example.com\"},\n\t\t{\"With https scheme and path\", \"https://www.example.com/path\", \"www.example.com\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := toHostname(tt.input)\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Errorf(\"Expected %s, but got %s\", tt.expected, result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "util/tencent_cloud_signer.go",
    "content": "package util\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"net/http\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n)\n\nfunc sha256hex(s string) string {\n\tb := sha256.Sum256([]byte(s))\n\treturn hex.EncodeToString(b[:])\n}\n\nfunc tencentCloudHmacsha256(s, key string) string {\n\thashed := hmac.New(sha256.New, []byte(key))\n\thashed.Write([]byte(s))\n\treturn string(hashed.Sum(nil))\n}\n\nconst (\n\tDnsPod  = \"dnspod\"\n\tEdgeOne = \"teo\"\n)\n\n// TencentCloudSigner 腾讯云签名方法 v3 https://cloud.tencent.com/document/api/1427/56189#Golang\nfunc TencentCloudSigner(secretId string, secretKey string, r *http.Request, action string, payload string, service string) {\n\talgorithm := \"TC3-HMAC-SHA256\"\n\thost := WriteString(service, \".tencentcloudapi.com\")\n\ttimestamp := time.Now().Unix()\n\ttimestampStr := strconv.FormatInt(timestamp, 10)\n\n\t// step 1: build canonical request string\n\tcanonicalHeaders := WriteString(\"content-type:application/json\\nhost:\", host, \"\\nx-tc-action:\", strings.ToLower(action), \"\\n\")\n\tsignedHeaders := \"content-type;host;x-tc-action\"\n\thashedRequestPayload := sha256hex(payload)\n\tcanonicalRequest := WriteString(\"POST\\n/\\n\\n\", canonicalHeaders, \"\\n\", signedHeaders, \"\\n\", hashedRequestPayload)\n\n\t// step 2: build string to sign\n\tdate := time.Unix(timestamp, 0).UTC().Format(\"2006-01-02\")\n\tcredentialScope := WriteString(date, \"/\", service, \"/tc3_request\")\n\thashedCanonicalRequest := sha256hex(canonicalRequest)\n\tstring2sign := WriteString(algorithm, \"\\n\", timestampStr, \"\\n\", credentialScope, \"\\n\", hashedCanonicalRequest)\n\n\t// step 3: sign string\n\tsecretDate := tencentCloudHmacsha256(date, WriteString(\"TC3\", secretKey))\n\tsecretService := tencentCloudHmacsha256(service, secretDate)\n\tsecretSigning := tencentCloudHmacsha256(\"tc3_request\", secretService)\n\tsignature := hex.EncodeToString([]byte(tencentCloudHmacsha256(string2sign, secretSigning)))\n\n\t// step 4: build authorization\n\tauthorization := WriteString(algorithm, \" Credential=\", secretId, \"/\", credentialScope, \", SignedHeaders=\", signedHeaders, \", Signature=\", signature)\n\n\tr.Header.Add(\"Authorization\", authorization)\n\tr.Header.Set(\"Host\", host)\n\tr.Header.Set(\"X-TC-Action\", action)\n\tr.Header.Add(\"X-TC-Timestamp\", timestampStr)\n}\n"
  },
  {
    "path": "util/termux.go",
    "content": "package util\n\nimport \"os\"\n\n// isTermux 是否在 Termux 中运行\n//\n// https://wiki.termux.com/wiki/Getting_started\nfunc isTermux() bool {\n\treturn os.Getenv(\"PREFIX\") == \"/data/data/com.termux/files/usr\"\n}\n"
  },
  {
    "path": "util/termux_test.go",
    "content": "package util\n\nimport (\n\t\"os\"\n\t\"testing\"\n)\n\n// TestIsTermux 测试在或不在 Termux 中运行都能正确判断\nfunc TestIsTermux(t *testing.T) {\n\t// 模拟在 Termux 中运行\n\tos.Setenv(\"PREFIX\", \"/data/data/com.termux/files/usr\")\n\n\tif !isTermux() {\n\t\tt.Error(\"期待 isTermux 返回 true，但得到 false。\")\n\t}\n\n\t// 清除 PREFIX 变量，模拟不在 Termux 中运行\n\tos.Unsetenv(\"PREFIX\")\n\n\tif isTermux() {\n\t\tt.Error(\"期待 isTermux 返回 false，但得到 true。\")\n\t}\n}\n"
  },
  {
    "path": "util/token.go",
    "content": "package util\n\nimport (\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/base64\"\n\t\"fmt\"\n\t\"math/rand\"\n\t\"time\"\n)\n\n// GenerateToken 生成Token\nfunc GenerateToken(username string) string {\n\tkey := []byte(generateRandomKey())\n\th := hmac.New(sha256.New, key)\n\tmsg := fmt.Sprintf(\"%s%d\", username, time.Now().Unix())\n\th.Write([]byte(msg))\n\treturn base64.StdEncoding.EncodeToString(h.Sum(nil))\n}\n\n// generateRandomKey 生成随机密钥\nfunc generateRandomKey() string {\n\t// 设置随机种子\n\tsource := rand.NewSource(time.Now().UnixNano())\n\trandom := rand.New(source)\n\n\t// 生成随机的64位整数\n\trandomNumber := random.Uint64()\n\n\treturn fmt.Sprint(randomNumber)\n}\n"
  },
  {
    "path": "util/traffic_route_signer.go",
    "content": "package util\n\nimport (\n\t\"bytes\"\n\t\"crypto/hmac\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"fmt\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"strings\"\n\t\"time\"\n)\n\nconst Version = \"2018-08-01\"\nconst Service = \"DNS\"\nconst Region = \"cn-north-1\"\nconst Host = \"open.volcengineapi.com\"\n\n// 第一步：准备辅助函数。\n// sha256非对称加密\nfunc hmacSHA256(key []byte, content string) []byte {\n\tmac := hmac.New(sha256.New, key)\n\tmac.Write([]byte(content))\n\treturn mac.Sum(nil)\n}\n\n// sha256 hash算法\nfunc hashSHA256(content []byte) string {\n\th := sha256.New()\n\th.Write(content)\n\treturn hex.EncodeToString(h.Sum(nil))\n}\n\n// 第二步：准备需要用到的结构体定义。\n// 签算请求结构体\ntype RequestParam struct {\n\tBody      []byte\n\tMethod    string\n\tDate      time.Time\n\tPath      string\n\tHost      string\n\tQueryList url.Values\n}\n\n// 身份证明结构体\ntype Credentials struct {\n\tAccessKeyID     string\n\tSecretAccessKey string\n\tService         string\n\tRegion          string\n}\n\n// 签算结果结构体\ntype SignRequest struct {\n\tXDate          string\n\tHost           string\n\tContentType    string\n\tXContentSha256 string\n\tAuthorization  string\n}\n\n// 第三步：创建一个 DNS 的 API 请求函数。签名计算的过程包含在该函数中。\nfunc TrafficRouteSigner(method string, query map[string][]string, header map[string]string, ak string, sk string, action string, body []byte) (*http.Request, error) {\n\t// 第四步：在requestDNS中，创建一个 HTTP 请求实例。\n\t// 创建 HTTP 请求实例。该实例会在后续用到。\n\trequest, _ := http.NewRequest(method, \"https://\"+Host+\"/\", bytes.NewReader(body))\n\turlVales := url.Values{}\n\tfor k, v := range query {\n\t\turlVales[k] = v\n\t}\n\turlVales[\"Action\"] = []string{action}\n\turlVales[\"Version\"] = []string{Version}\n\trequest.URL.RawQuery = urlVales.Encode()\n\tfor k, v := range header {\n\t\trequest.Header.Set(k, v)\n\t}\n\t// 第五步：创建身份证明。其中的 Service 和 Region 字段是固定的。ak 和 sk 分别代表 AccessKeyID 和 SecretAccessKey。同时需要初始化签名结构体。一些签名计算时需要的属性也在这里处理。\n\t// 初始化身份证明\n\tcredential := Credentials{\n\t\tAccessKeyID:     ak,\n\t\tSecretAccessKey: sk,\n\t\tService:         Service,\n\t\tRegion:          Region,\n\t}\n\t// 初始化签名结构体\n\trequestParam := RequestParam{\n\t\tBody:      body,\n\t\tHost:      request.Host,\n\t\tPath:      \"/\",\n\t\tMethod:    request.Method,\n\t\tDate:      time.Now().UTC(),\n\t\tQueryList: request.URL.Query(),\n\t}\n\t// 第六步：接下来开始计算签名。在计算签名前，先准备好用于接收签算结果的 signResult 变量，并设置一些参数。\n\t// 初始化签名结果的结构体\n\txDate := requestParam.Date.Format(\"20060102T150405Z\")\n\tshortXDate := xDate[:8]\n\tXContentSha256 := hashSHA256(requestParam.Body)\n\tcontentType := \"application/json\"\n\tsignResult := SignRequest{\n\t\tHost:           requestParam.Host, // 设置Host\n\t\tXContentSha256: XContentSha256,    // 加密body\n\t\tXDate:          xDate,             // 设置标准化时间\n\t\tContentType:    contentType,       // 设置Content-Type 为 application/json\n\t}\n\t// 第七步：计算 Signature 签名。\n\tsignedHeadersStr := strings.Join([]string{\"content-type\", \"host\", \"x-content-sha256\", \"x-date\"}, \";\")\n\tcanonicalRequestStr := strings.Join([]string{\n\t\trequestParam.Method,\n\t\trequestParam.Path,\n\t\trequest.URL.RawQuery,\n\t\tstrings.Join([]string{\"content-type:\" + contentType, \"host:\" + requestParam.Host, \"x-content-sha256:\" + XContentSha256, \"x-date:\" + xDate}, \"\\n\"),\n\t\t\"\",\n\t\tsignedHeadersStr,\n\t\tXContentSha256,\n\t}, \"\\n\")\n\thashedCanonicalRequest := hashSHA256([]byte(canonicalRequestStr))\n\tcredentialScope := strings.Join([]string{shortXDate, credential.Region, credential.Service, \"request\"}, \"/\")\n\tstringToSign := strings.Join([]string{\n\t\t\"HMAC-SHA256\",\n\t\txDate,\n\t\tcredentialScope,\n\t\thashedCanonicalRequest,\n\t}, \"\\n\")\n\tkDate := hmacSHA256([]byte(credential.SecretAccessKey), shortXDate)\n\tkRegion := hmacSHA256(kDate, credential.Region)\n\tkService := hmacSHA256(kRegion, credential.Service)\n\tkSigning := hmacSHA256(kService, \"request\")\n\tsignature := hex.EncodeToString(hmacSHA256(kSigning, stringToSign))\n\tsignResult.Authorization = fmt.Sprintf(\"HMAC-SHA256 Credential=%s, SignedHeaders=%s, Signature=%s\", credential.AccessKeyID+\"/\"+credentialScope, signedHeadersStr, signature)\n\t// 第八步：将 Signature 签名写入HTTP Header 中，并发送 HTTP 请求。\n\t// 设置经过签名的5个HTTP Header\n\trequest.Header.Set(\"Host\", signResult.Host)\n\trequest.Header.Set(\"Content-Type\", signResult.ContentType)\n\trequest.Header.Set(\"X-Date\", signResult.XDate)\n\trequest.Header.Set(\"X-Content-Sha256\", signResult.XContentSha256)\n\trequest.Header.Set(\"Authorization\", signResult.Authorization)\n\n\treturn request, nil\n}\n"
  },
  {
    "path": "util/update/apply.go",
    "content": "// Based on https://github.com/inconshreveable/go-update/blob/7a872911e5b39953310f0a04161f0d50c7e63755/apply.go\n\npackage update\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"runtime\"\n)\n\n// apply 使用给定的 io.Reader 的内容来更新 targetPath 的可执行文件。\n//\n// apply 执行以下操作以确保安全的跨平台更新：\n//\n// 1. 创建新文件 /path/to/target.new，并将更新文件的内容写入其中\n//\n// 2. 将 /path/to/target 重命名为 /path/to/target.old\n//\n// 3. 将 /path/to/target.new 重命名为 /path/to/target\n//\n// 4.如果最终的重命名成功，删除 /path/to/target.old 并返回无错误。\n//\n// 5. 如果最终重命名失败，尝试通过将 /path/to/target.old 重命名会\n// /path/to/target 进行回滚。\n//\n// 如果回滚操作失败，文件系统将处于不一致状态（第 4 步和第 5 步之间），\n// 既没有新的可执行文件，并且旧的可执行文件无法移动回其原始位置。在这种情况下，\n// 应该通知用户这个坏消息，并要求他们手动恢复。\nfunc apply(update io.Reader, targetPath string) error {\n\tnewBytes, err := io.ReadAll(update)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 获取可执行文件所在的目录\n\tupdateDir := filepath.Dir(targetPath)\n\tfilename := filepath.Base(targetPath)\n\n\t// 将新二进制的内容复制到新可执行文件中。\n\tnewPath := filepath.Join(updateDir, fmt.Sprintf(\"%s.new\", filename))\n\tfp, err := os.OpenFile(newPath, os.O_CREATE|os.O_WRONLY, 0755)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer fp.Close()\n\n\t_, err = io.Copy(fp, bytes.NewReader(newBytes))\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 如果我们不调用 fp.Close()，Windows 将不允许我们移动新可执行文件。\n\t// 因为文件仍处于 \"in use\"（使用中）状态。\n\tfp.Close()\n\n\t// 这是我们将要移动可执行文件的位置，以便可以将更新的文件替代进来\n\toldPath := filepath.Join(updateDir, fmt.Sprintf(\"%s.old\", filename))\n\n\t// 删除任何现有的就执行文件 - 这在 Windows 上是必要的，原因有两个：\n\t// 1. 成功更新后，Windows 无法删除 .old 文件，因为进程仍在运行\n\t// 2. 如果目标文件已存在，Windows 重命名操作将失败\n\t_ = os.Remove(oldPath)\n\n\t// 将现有的可执行文件移到同一目录下的新文件中\n\terr = os.Rename(targetPath, oldPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t// 将新可执行文件移到目标位置\n\terr = os.Rename(newPath, targetPath)\n\n\tif err != nil {\n\t\t// 移动失败\n\t\t//\n\t\t// 文件系统现在处于不良状态。我们已成功将现有的二进制文件移动到新位置，\n\t\t// 但无法将新二进制文件移动到原来的位置。这意味着当前可执行文件的位置上没有文件！\n\t\t// 尝试通过将旧二进制文件恢复到原始路径来回滚。\n\t\trerr := os.Rename(oldPath, targetPath)\n\t\tif rerr != nil {\n\t\t\treturn err\n\t\t}\n\n\t\treturn err\n\t}\n\n\t// 移动成功，删除旧的二进制文件\n\terr = os.Remove(oldPath)\n\tif err != nil {\n\t\tif runtime.GOOS == \"windows\" {\n\t\t\t// Windows 无法删除 .old 文件，因为进程仍在运行。删除会提示 \"Access is denied\"。\n\t\t\t// 因此，启动外部进程来删除旧的二进制文件。\n\t\t\t// 外部进程会等待一会以确保进程已退出。\n\t\t\t//\n\t\t\t// https://stackoverflow.com/a/73585620\n\t\t\texec.Command(\"cmd.exe\", \"/c\", \"ping 127.0.0.1 -n 2 > NUL & del \"+oldPath).Start()\n\t\t\treturn nil\n\t\t}\n\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "util/update/apply_test.go",
    "content": "// Based on https://github.com/inconshreveable/go-update/blob/7a872911e5b39953310f0a04161f0d50c7e63755/apply_test.go\n\npackage update\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"testing\"\n)\n\nvar (\n\toldFile = []byte{0xDE, 0xAD, 0xBE, 0xEF}\n\tnewFile = []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}\n)\n\nfunc cleanup(path string) {\n\tos.Remove(path)\n\tos.Remove(fmt.Sprintf(\"%s.new\", path))\n}\n\n// we write with a separate name for each test so that we can run them in parallel\nfunc writeOldFile(path string, t *testing.T) {\n\tif err := os.WriteFile(path, oldFile, 0777); err != nil {\n\t\tt.Fatalf(\"Failed to write file for testing preparation: %v\", err)\n\t}\n\tif _, err := os.Stat(path); err != nil {\n\t\tt.Fatalf(\"Failed to stat file for testing preparation: %v\", err)\n\t}\n}\n\nfunc validateUpdate(path string, err error, t *testing.T) {\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to update: %v\", err)\n\t}\n\n\tbuf, err := os.ReadFile(path)\n\tif err != nil {\n\t\tt.Fatalf(\"Failed to read file post-update: %v\", err)\n\t}\n\n\tif !bytes.Equal(buf, newFile) {\n\t\tt.Fatalf(\"File was not updated! Bytes read: %v, Bytes expected: %v\", buf, newFile)\n\t}\n}\n\nfunc TestApply(t *testing.T) {\n\tt.Parallel()\n\n\tfName := \"TestApply\"\n\tdefer cleanup(fName)\n\twriteOldFile(fName, t)\n\n\terr := apply(bytes.NewReader(newFile), fName)\n\tvalidateUpdate(fName, err, t)\n}\n"
  },
  {
    "path": "util/update/arch.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/arch.go\n\npackage update\n\nimport (\n\t\"fmt\"\n\t\"runtime\"\n)\n\nconst (\n\tminARM = 5\n\tmaxARM = 7\n)\n\n// generateAdditionalArch 可以根据 CPU 类型使用\nfunc generateAdditionalArch() []string {\n\tif runtime.GOARCH == \"arm\" && goarm >= minARM && goarm <= maxARM {\n\t\tadditionalArch := make([]string, 0, maxARM-minARM)\n\t\tfor v := goarm; v >= minARM; v-- {\n\t\t\tadditionalArch = append(additionalArch, fmt.Sprintf(\"armv%d\", v))\n\t\t}\n\t\treturn additionalArch\n\t}\n\tif runtime.GOARCH == \"amd64\" {\n\t\treturn []string{\"x86_64\"}\n\t}\n\tif runtime.GOARCH == \"riscv64\" {\n\t\treturn []string{\"riscv64\"}\n\t}\n\treturn []string{}\n}\n"
  },
  {
    "path": "util/update/arm.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/arm.go\n\npackage update\n\nimport (\n\t// unsafe 用于从 runtime 包中获取私有变量\n\t_ \"unsafe\"\n)\n\n//go:linkname goarm runtime.goarm\nvar goarm uint8\n"
  },
  {
    "path": "util/update/decompress.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/decompress.go\n\npackage update\n\nimport (\n\t\"archive/tar\"\n\t\"archive/zip\"\n\t\"bytes\"\n\t\"compress/gzip\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"path/filepath\"\n\t\"strings\"\n)\n\nvar fileTypes = map[string]func(src io.Reader, cmd string) (io.Reader, error){\n\t\".zip\":    unzip,\n\t\".tar.gz\": untar,\n}\n\n// decompressCommand 解压缩给定源。从 'url' 参数中自动检测存档和压缩格式，'url' 参数表示 asset 的 URL，\n// 或简单的文件名（带扩展名）。\n// 返回 reader，用于读取解压缩后与 'cmd' 相应的命令。支持 '.zip' 和 '.tar.gz'\n//\n// 可能返回以下封装过的错误：\n//   - errCannotDecompressFile\n//   - errExecutableNotFoundInArchive\nfunc decompressCommand(src io.Reader, url, cmd string) (io.Reader, error) {\n\tfor ext, decompress := range fileTypes {\n\t\tif strings.HasSuffix(url, ext) {\n\t\t\treturn decompress(src, cmd)\n\t\t}\n\t}\n\tlog.Print(\"It's not a compressed file, skip decompressing\")\n\treturn src, nil\n}\n\nfunc unzip(src io.Reader, cmd string) (io.Reader, error) {\n\t// 解压 Zip 格式时需要文件大小。\n\t// 因此我们需要先将 HTTP 响应读取到缓冲区中。\n\tbuf, err := io.ReadAll(src)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w zip 文件: %v\", errCannotDecompressFile, err)\n\t}\n\n\tr := bytes.NewReader(buf)\n\tz, err := zip.NewReader(r, r.Size())\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w zip 文件: %s\", errCannotDecompressFile, err)\n\t}\n\n\tfor _, file := range z.File {\n\t\t_, name := filepath.Split(file.Name)\n\t\tif !file.FileInfo().IsDir() && matchExecutableName(cmd, name) {\n\t\t\treturn file.Open()\n\t\t}\n\t}\n\n\treturn nil, fmt.Errorf(\"在 zip 文件中%w：%q\", errExecutableNotFoundInArchive, cmd)\n}\n\nfunc untar(src io.Reader, cmd string) (io.Reader, error) {\n\tgz, err := gzip.NewReader(src)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"%w tar.gz 文件: %s\", errCannotDecompressFile, err)\n\t}\n\n\tt := tar.NewReader(gz)\n\tfor {\n\t\th, err := t.Next()\n\t\tif errors.Is(err, io.EOF) {\n\t\t\tbreak\n\t\t}\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"%w tar.gz 文件：%s\", errCannotDecompressFile, err)\n\t\t}\n\t\t_, name := filepath.Split(h.Name)\n\t\tif matchExecutableName(cmd, name) {\n\t\t\treturn t, nil\n\t\t}\n\t}\n\treturn nil, fmt.Errorf(\"在 tar.gz 文件中%w：%q\", errExecutableNotFoundInArchive, cmd)\n}\n\nfunc matchExecutableName(cmd, target string) bool {\n\treturn cmd == target || cmd+\".exe\" == target\n}\n"
  },
  {
    "path": "util/update/decompress_test.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/decompress_test.go\n\npackage update\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"strings\"\n\t\"testing\"\n)\n\nvar buf = []byte{'a', 'b', 'c'}\n\nfunc TestCompressionNotRequired(t *testing.T) {\n\twant := bytes.NewReader(buf)\n\tr, err := decompressCommand(want, \"https://github.com/foo/bar/releases/download/v1.2.3/foo\", \"foo\")\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\thave, err := io.ReadAll(r)\n\tif err != nil {\n\t\tt.Fatal(err)\n\t}\n\n\tif !bytes.Equal(buf, have) {\n\t\tt.Errorf(\"expected %v, got %v\", buf, have)\n\t}\n}\n\nfunc TestMatchExecutableName(t *testing.T) {\n\ttestData := []struct {\n\t\tcmd    string\n\t\ttarget string\n\t\tfound  bool\n\t}{\n\t\t{\"gostuff\", \"gostuff\", true},\n\t\t{\"gostuff\", \"gostuff_linux_x86_64\", false},\n\t\t{\"gostuff\", \"gostuff_darwin_amd64\", false},\n\t\t{\"gostuff\", \"gostuff.exe\", true},\n\t\t{\"gostuff\", \"gostuff_windows_amd64.exe\", false},\n\t}\n\n\tfor _, testItem := range testData {\n\t\tt.Run(testItem.target, func(t *testing.T) {\n\t\t\tif matchExecutableName(testItem.cmd, testItem.target) != testItem.found {\n\t\t\t\tt.Errorf(\"Expected '%t' but got '%t'\", testItem.found, matchExecutableName(testItem.cmd, testItem.target))\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestErrorFromReader(t *testing.T) {\n\textensions := []string{\n\t\t\"zip\",\n\t\t\"tar.gz\",\n\t}\n\n\tfor _, extension := range extensions {\n\t\tt.Run(extension, func(t *testing.T) {\n\t\t\treader, err := decompressCommand(bytes.NewReader(buf), \"foo.\"+extension, \"foo.\"+extension)\n\t\t\tif err != nil {\n\t\t\t\tif !strings.Contains(err.Error(), errCannotDecompressFile.Error()) {\n\t\t\t\t\tt.Fatalf(\"Expected error: EOF, got: %v\", err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t_, err = io.ReadAll(reader)\n\t\t\t\tif err == nil {\n\t\t\t\t\tt.Fatalf(\"An error is expected but got nil.\")\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "util/update/detect.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/detect.go\n\npackage update\n\nimport (\n\t\"fmt\"\n\t\"log\"\n\t\"runtime\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/util/semver\"\n)\n\n// detectLatest 尝试从源提供者获取版本信息。\nfunc detectLatest(repo string) (latest *Latest, found bool, err error) {\n\trel, err := getLatest(repo)\n\tif err != nil {\n\t\treturn nil, false, err\n\t}\n\n\tasset, ver, found := findAsset(rel)\n\tif !found {\n\t\treturn nil, false, nil\n\t}\n\n\treturn newLatest(asset, ver), true, nil\n}\n\n// findAsset 返回最新的 asset\nfunc findAsset(rel *Release) (*Asset, *semver.Version, bool) {\n\t// 将检测到的架构放在列表的末尾，对于 ARM 来说这是可以的。\n\t// 因为附加的架构比通用架构更准确\n\tfor _, arch := range append(generateAdditionalArch(), runtime.GOARCH) {\n\t\tasset, version, found := findAssetForArch(arch, rel)\n\t\tif found {\n\t\t\treturn asset, version, found\n\t\t}\n\t}\n\n\treturn nil, nil, false\n}\n\nfunc findAssetForArch(arch string, rel *Release,\n) (asset *Asset, version *semver.Version, found bool) {\n\tvar release *Release\n\n\t// 从 release 列表中查找最新的版本。\n\t// GitHub API 返回的列表按照创建日期的顺序排列。\n\tif a, v, ok := findAssetFromRelease(rel, getSuffixes(arch)); ok {\n\t\tversion = v\n\t\tasset = a\n\t\trelease = rel\n\t}\n\n\tif release == nil {\n\t\tlog.Printf(\"Cannot find any release for %s/%s\", runtime.GOOS, runtime.GOARCH)\n\t\treturn nil, nil, false\n\t}\n\n\treturn asset, version, true\n}\n\nfunc findAssetFromRelease(rel *Release, suffixes []string) (*Asset, *semver.Version, bool) {\n\tif rel == nil {\n\t\tlog.Print(\"There is no source release information\")\n\t\treturn nil, nil, false\n\t}\n\n\t// 如果无法解析版本文本，则表示该文本不符合语义化版本规范，应该跳过。\n\tver, err := semver.NewVersion(rel.tagName)\n\tif err != nil {\n\t\tlog.Printf(\"Cannot parse semantic version: %s\", rel.tagName)\n\t\treturn nil, nil, false\n\t}\n\n\tfor _, asset := range rel.assets {\n\t\tif assetMatchSuffixes(asset.name, suffixes) {\n\t\t\treturn &asset, ver, true\n\t\t}\n\t}\n\n\tlog.Printf(\"Can't find suitable asset in release %s\", rel.tagName)\n\treturn nil, nil, false\n}\n\nfunc assetMatchSuffixes(name string, suffixes []string) bool {\n\tfor _, suffix := range suffixes {\n\t\tif strings.HasSuffix(name, suffix) { // 需要版本、架构等\n\t\t\t// 假设唯一的构件被匹配（或者第一个匹配将足够）\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// getSuffixes 返回所有要与 asset 进行检查的候选后缀\n//\n// TODO: 由于缺失获取 MIPS 架构 float 的方法，所以目前无法正确获取 MIPS 架构的后缀。\nfunc getSuffixes(arch string) []string {\n\tsuffixes := make([]string, 0)\n\tfor _, ext := range []string{\".zip\", \".tar.gz\"} {\n\t\tsuffix := fmt.Sprintf(\"%s_%s%s\", runtime.GOOS, arch, ext)\n\t\tsuffixes = append(suffixes, suffix)\n\t}\n\treturn suffixes\n}\n"
  },
  {
    "path": "util/update/errors.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/errors.go\n\npackage update\n\nimport \"errors\"\n\nvar (\n\terrCannotDecompressFile        = errors.New(\"failed to decompress\")\n\terrExecutableNotFoundInArchive = errors.New(\"executable not found\")\n)\n"
  },
  {
    "path": "util/update/latest.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/release.go\n\npackage update\n\nimport \"github.com/jeessy2/ddns-go/v6/util/semver\"\n\n// Latest 表示当前操作系统和架构的最新 release asset。\ntype Latest struct {\n\t//Name 是 asset 的文件名\n\tName string\n\t// URL 是 release 上传文件的 URL\n\tURL string\n\t// version 是解析后的 *Version\n\tVersion *semver.Version\n}\n\nfunc newLatest(asset *Asset, ver *semver.Version) *Latest {\n\tlatest := &Latest{\n\t\tName:    asset.name,\n\t\tURL:     asset.url,\n\t\tVersion: ver,\n\t}\n\n\treturn latest\n}\n"
  },
  {
    "path": "util/update/package.go",
    "content": "package update\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"log\"\n\t\"os\"\n\t\"runtime\"\n\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n\t\"github.com/jeessy2/ddns-go/v6/util/semver\"\n)\n\n// Self 更新 ddns-go 到最新版本（如果可用）。\nfunc Self(version string) {\n\t// 如果不为语义化版本立即退出\n\tv, err := semver.NewVersion(version)\n\tif err != nil {\n\t\tlog.Printf(\"Cannot update because: %v\", err)\n\t\treturn\n\t}\n\n\tlatest, found, err := detectLatest(\"jeessy2/ddns-go\")\n\tif err != nil {\n\t\tlog.Printf(\"Error happened when detecting latest version: %v\", err)\n\t\treturn\n\t}\n\tif !found {\n\t\tlog.Printf(\"Cannot find any release for %s/%s\", runtime.GOOS, runtime.GOARCH)\n\t\treturn\n\t}\n\tif v.GreaterThanOrEqual(latest.Version) {\n\t\tlog.Printf(\"Current version (%s) is the latest\", version)\n\t\treturn\n\t}\n\n\texe, err := os.Executable()\n\tif err != nil {\n\t\tlog.Printf(\"Cannot find executable path: %v\", err)\n\t\treturn\n\t}\n\n\tif err = to(latest.URL, latest.Name, exe); err != nil {\n\t\tlog.Printf(\"Error happened when updating binary: %v\", err)\n\t\treturn\n\t}\n\n\tlog.Printf(\"Success update to v%s\", latest.Version.String())\n}\n\n// to 从 assetURL 下载可执行文件，并用下载的文件替换当前的可执行文件。\n// 这个函数是用于更新二进制文件的低级 API。因为它不使用源提供者，而是直接通过 HTTP 从 URL 下载 asset 。\n// 所以这个函数不能用于更新私有仓库的 release。\n// cmdPath 是命令可执行文件的文件路径。\nfunc to(assetURL, assetFileName, cmdPath string) error {\n\tsrc, err := downloadAssetFromURL(assetURL)\n\tif err != nil {\n\t\treturn err\n\t}\n\tdefer src.Close()\n\treturn decompressAndUpdate(src, assetFileName, cmdPath)\n}\n\nfunc downloadAssetFromURL(url string) (rc io.ReadCloser, err error) {\n\tclient := util.CreateHTTPClient()\n\tresp, err := client.Get(url)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"could not download release from %s: %v\", url, err)\n\t}\n\tif resp.StatusCode >= 300 {\n\t\tresp.Body.Close()\n\t\treturn nil, fmt.Errorf(\"could not download release from %s. Response code: %d\", url, resp.StatusCode)\n\t}\n\n\treturn resp.Body, nil\n}\n"
  },
  {
    "path": "util/update/release.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/github_release.go\n// and https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/github_source.go\n\npackage update\n\nimport (\n\t\"fmt\"\n\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\ntype Release struct {\n\ttagName string\n\tassets  []Asset\n}\n\ntype Asset struct {\n\tname string\n\turl  string\n}\n\n// ReleaseResp 表示仓库中的 GitHub release 和 asset。\ntype ReleaseResp struct {\n\tTagName string `json:\"tag_name,omitempty\"`\n\tAssets  []struct {\n\t\tName               string `json:\"name,omitempty\"`\n\t\tBrowserDownloadURL string `json:\"browser_download_url,omitempty\"`\n\t} `json:\"assets,omitempty\"`\n}\n\n// getLatest 列出仓库的最新 release 并返回包装过的 Release\n//\n// GitHub API 文档：https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release\nfunc getLatest(repo string) (*Release, error) {\n\tu := fmt.Sprintf(\"https://api.github.com/repos/%s/releases/latest\", repo)\n\n\tclient := util.CreateHTTPClient()\n\tresp, err := client.Get(u)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tvar result ReleaseResp\n\terr = util.GetHTTPResponse(resp, err, &result)\n\tif err != nil {\n\t\tutil.Log(\"异常信息: %s\", err)\n\t\treturn nil, err\n\t}\n\n\treturn newRelease(&result), err\n}\n\nfunc newRelease(from *ReleaseResp) *Release {\n\trelease := &Release{\n\t\ttagName: from.TagName,\n\t\tassets:  make([]Asset, len(from.Assets)),\n\t}\n\tfor i, fromAsset := range from.Assets {\n\t\trelease.assets[i] = Asset{\n\t\t\tname: fromAsset.Name,\n\t\t\turl:  fromAsset.BrowserDownloadURL,\n\t\t}\n\t}\n\treturn release\n}\n"
  },
  {
    "path": "util/update/update.go",
    "content": "// Based on https://github.com/creativeprojects/go-selfupdate/blob/v1.1.1/update.go\n\npackage update\n\nimport (\n\t\"io\"\n\t\"path/filepath\"\n)\n\nfunc decompressAndUpdate(src io.Reader, assetName, cmdPath string) error {\n\t_, cmd := filepath.Split(cmdPath)\n\tasset, err := decompressCommand(src, assetName, cmd)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn apply(asset, cmdPath)\n}\n"
  },
  {
    "path": "util/user.go",
    "content": "package util\r\n\r\nimport (\r\n\t\"os\"\r\n)\r\n\r\nconst ConfigFilePathENV = \"DDNS_CONFIG_FILE_PATH\"\r\n\r\n// GetConfigFilePath 获得配置文件路径\r\nfunc GetConfigFilePath() string {\r\n\tconfigFilePath := os.Getenv(ConfigFilePathENV)\r\n\tif configFilePath != \"\" {\r\n\t\treturn configFilePath\r\n\t}\r\n\treturn GetConfigFilePathDefault()\r\n}\r\n\r\n// GetConfigFilePathDefault 获得默认的配置文件路径\r\nfunc GetConfigFilePathDefault() string {\r\n\tdir, err := os.UserHomeDir()\r\n\tif err != nil {\r\n\t\t// log.Println(\"Getting Home directory failed: \", err)\r\n\t\treturn \"../.ddns_go_config.yaml\"\r\n\t}\r\n\treturn dir + string(os.PathSeparator) + \".ddns_go_config.yaml\"\r\n}\r\n"
  },
  {
    "path": "util/wait_internet.go",
    "content": "package util\n\nimport (\n\t\"strings\"\n\t\"time\"\n)\n\n// Wait blocks until the Internet is connected.\n//\n// See also:\n//\n//   - https://stackoverflow.com/a/50058255\n//   - https://github.com/ddev/ddev/blob/v1.22.7/pkg/globalconfig/global_config.go#L776\nfunc WaitInternet(addresses []string) {\n\tdelay := time.Second * 5\n\tretryTimes := 0\n\tfailed := false\n\n\tfor {\n\t\tfor _, addr := range addresses {\n\n\t\t\terr := LookupHost(addr)\n\t\t\t// Internet is connected.\n\t\t\tif err == nil {\n\t\t\t\tif failed {\n\t\t\t\t\tLog(\"网络已连接\")\n\t\t\t\t}\n\t\t\t\treturn\n\t\t\t}\n\n\t\t\tfailed = true\n\t\t\tLog(\"等待网络连接: %s\", err)\n\t\t\tLog(\"%s 后重试...\", delay)\n\n\t\t\tif isDNSErr(err) || retryTimes > 0 {\n\t\t\t\tdns := BackupDNS[retryTimes%len(BackupDNS)]\n\t\t\t\tLog(\"本机DNS异常! 将默认使用 %s, 可参考文档通过 -dns 自定义 DNS 服务器\", dns)\n\t\t\t\tSetDNS(dns)\n\t\t\t\tretryTimes = retryTimes + 1\n\t\t\t}\n\n\t\t\ttime.Sleep(delay)\n\t\t}\n\t}\n}\n\n// isDNSErr checks if the error is caused by DNS.\nfunc isDNSErr(e error) bool {\n\treturn strings.Contains(e.Error(), \"[::1]:53: read: connection refused\")\n}\n"
  },
  {
    "path": "web/auth.go",
    "content": "package web\n\nimport (\n\t\"net/http\"\n\t\"time\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// ViewFunc func\ntype ViewFunc func(http.ResponseWriter, *http.Request)\n\n// Auth 验证Token是否已经通过\nfunc Auth(f ViewFunc) ViewFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\t\tcookieInWeb, err := r.Cookie(cookieName)\n\t\tif err != nil {\n\t\t\thttp.Redirect(w, r, \"./login\", http.StatusTemporaryRedirect)\n\t\t\treturn\n\t\t}\n\n\t\tconf, _ := config.GetConfigCached()\n\n\t\t// 禁止公网访问\n\t\tif conf.NotAllowWanAccess {\n\t\t\tif !util.IsPrivateNetwork(r.RemoteAddr) {\n\t\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t\t\tutil.Log(\"%q 被禁止从公网访问\", util.GetRequestIPStr(r))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\t// 验证token\n\t\tif cookieInSystem.Value != \"\" &&\n\t\t\tcookieInSystem.Value == cookieInWeb.Value &&\n\t\t\tcookieInSystem.Expires.After(time.Now()) {\n\t\t\tf(w, r) // 执行被装饰的函数\n\t\t\treturn\n\t\t}\n\n\t\thttp.Redirect(w, r, \"./login\", http.StatusTemporaryRedirect)\n\t}\n}\n\n// AuthAssert 保护静态等文件不被公网访问\nfunc AuthAssert(f ViewFunc) ViewFunc {\n\treturn func(w http.ResponseWriter, r *http.Request) {\n\n\t\tconf, err := config.GetConfigCached()\n\n\t\t// 配置文件为空, 启动时间超过3小时禁止从公网访问\n\t\tif err != nil &&\n\t\t\ttime.Since(startTime) > time.Duration(3*time.Hour) && !util.IsPrivateNetwork(r.RemoteAddr) {\n\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t\tutil.Log(\"%q 配置文件为空, 超过3小时禁止从公网访问\", util.GetRequestIPStr(r))\n\t\t\treturn\n\t\t}\n\n\t\t// 禁止公网访问\n\t\tif conf.NotAllowWanAccess {\n\t\t\tif !util.IsPrivateNetwork(r.RemoteAddr) {\n\t\t\t\tw.WriteHeader(http.StatusForbidden)\n\t\t\t\tutil.Log(\"%q 被禁止从公网访问\", util.GetRequestIPStr(r))\n\t\t\t\treturn\n\t\t\t}\n\t\t}\n\n\t\tf(w, r) // 执行被装饰的函数\n\n\t}\n}\n"
  },
  {
    "path": "web/login.go",
    "content": "package web\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"net/url\"\n\t\"time\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n//go:embed login.html\nvar loginEmbedFile embed.FS\n\n// CookieName cookie name\nconst cookieName = \"token\"\n\n// CookieInSystem only one cookie\nvar cookieInSystem = &http.Cookie{}\n\n// 服务启动时间\nvar startTime = time.Now()\n\n// 保存限制时间\nconst saveLimit = time.Duration(30) * time.Minute\n\n// 登录失败锁定时间\nconst loginFailLockDuration = time.Duration(30) * time.Minute\n\n// 登录检测\ntype loginDetect struct {\n\tfailedTimes uint32       // 失败次数\n\tticker      *time.Ticker // 定时器\n}\n\nvar ld = &loginDetect{ticker: time.NewTicker(5 * time.Minute)}\n\n// Login login page\nfunc Login(writer http.ResponseWriter, request *http.Request) {\n\ttmpl, err := template.ParseFS(loginEmbedFile, \"login.html\")\n\tif err != nil {\n\t\tfmt.Println(\"Error happened..\")\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\n\tconf, _ := config.GetConfigCached()\n\n\terr = tmpl.Execute(writer, struct {\n\t\tEmptyUser bool // 未填写用户名和密码\n\t}{\n\t\tEmptyUser: conf.Username == \"\" && conf.Password == \"\",\n\t})\n\tif err != nil {\n\t\tfmt.Println(\"Error happened..\")\n\t\tfmt.Println(err)\n\t}\n}\n\n// LoginFunc login func\nfunc LoginFunc(w http.ResponseWriter, r *http.Request) {\n\taccept := r.Header.Get(\"Accept-Language\")\n\tutil.InitLogLang(accept)\n\n\tif ld.failedTimes >= 5 {\n\t\tloginUnlock()\n\t\treturnError(w, util.LogStr(\"登录失败次数过多，请稍后再试\"))\n\t\treturn\n\t}\n\n\t// 从请求中读取 JSON 数据\n\tvar data struct {\n\t\tUsername string `json:\"Username\"`\n\t\tPassword string `json:\"Password\"`\n\t}\n\n\terr := json.NewDecoder(r.Body).Decode(&data)\n\n\tif err != nil {\n\t\treturnError(w, err.Error())\n\t\treturn\n\t}\n\n\t// 用户名密码不能为空\n\tif data.Username == \"\" || data.Password == \"\" {\n\t\treturnError(w, util.LogStr(\"必须输入用户名/密码\"))\n\t\treturn\n\t}\n\n\tconf, _ := config.GetConfigCached()\n\n\t// 初始化用户名密码\n\tif conf.Username == \"\" && conf.Password == \"\" {\n\t\tif time.Since(startTime) > saveLimit {\n\t\t\treturnError(w, util.LogStr(\"需在 %s 之前完成用户名密码设置,请重启ddns-go\", startTime.Add(saveLimit).Format(\"2006-01-02 15:04:05\")))\n\t\t\treturn\n\t\t}\n\n\t\tconf.NotAllowWanAccess = true\n\t\tu, err := url.Parse(r.Header.Get(\"referer\"))\n\t\tif err == nil && !util.IsPrivateNetwork(u.Host) {\n\t\t\tconf.NotAllowWanAccess = false\n\t\t}\n\n\t\tconf.Username = data.Username\n\t\thashedPwd, err := conf.CheckPassword(data.Password)\n\t\tif err != nil {\n\t\t\treturnError(w, err.Error())\n\t\t\treturn\n\t\t}\n\t\tconf.Password = hashedPwd\n\t\tconf.SaveConfig()\n\t}\n\n\t// 登录\n\tif data.Username == conf.Username && util.PasswordOK(conf.Password, data.Password) {\n\t\tld.ticker.Stop()\n\t\tld.failedTimes = 0\n\n\t\t// 设置cookie过期时间为1天\n\t\ttimeoutDays := 1\n\t\tif conf.NotAllowWanAccess {\n\t\t\t// 内网访问cookie过期时间为30天\n\t\t\ttimeoutDays = 30\n\t\t}\n\n\t\t// 覆盖cookie\n\t\tcookieInSystem = &http.Cookie{\n\t\t\tName:     cookieName,\n\t\t\tValue:    util.GenerateToken(data.Username), // 生成token\n\t\t\tPath:     \"/\",\n\t\t\tExpires:  time.Now().AddDate(0, 0, timeoutDays), // 设置过期时间\n\t\t\tHttpOnly: true,\n\t\t}\n\t\t// 写入cookie\n\t\thttp.SetCookie(w, cookieInSystem)\n\n\t\tutil.Log(\"%q 登录成功\", util.GetRequestIPStr(r))\n\n\t\treturnOK(w, util.LogStr(\"登录成功\"), cookieInSystem.Value)\n\t\treturn\n\t}\n\n\tld.failedTimes = ld.failedTimes + 1\n\tutil.Log(\"%q 帐号密码不正确\", util.GetRequestIPStr(r))\n\treturnError(w, util.LogStr(\"用户名或密码错误\"))\n}\n\n// loginUnlock login unlock, reset failed login attempts\nfunc loginUnlock() {\n\tld.failedTimes = ld.failedTimes + 1\n\tld.ticker.Reset(loginFailLockDuration)\n\n\tgo func(ticker *time.Ticker) {\n\t\tfor range ticker.C {\n\t\t\tld.failedTimes = 4\n\t\t\tticker.Stop()\n\t\t}\n\t}(ld.ticker)\n\n}\n"
  },
  {
    "path": "web/login.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\" />\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"author\" content=\"jeessy2\" />\n  <title>DDNS-GO</title>\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <link class=\"theme\" rel=\"stylesheet\" type=\"text/css\" href=\"./static/common.css\" />\n  <link rel=\"stylesheet\" href=\"./static/bootstrap.min.css\" />\n  <link rel=\"stylesheet\" href=\"./static/theme-button.css\" />\n  <script src=\"./static/constant.js\"></script>\n  <script src=\"./static/utils.js\"></script>\n  <script src=\"./static/i18n.js\"></script>\n  <script src=\"./static/tooltips.js\"></script>\n</head>\n\n<body>\n  <header>\n    <div class=\"navbar navbar-dark bg-dark shadow-sm\">\n      <div class=\"button-container container d-flex justify-content-between\">\n        <a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go\" class=\"navbar-brand d-flex align-items-center\">\n          <strong>DDNS-GO</strong>\n        </a>\n        <span class=\"theme-button gg-dark-mode\" data-toggle=\"tooltip\" data-placement=\"bottom\" data-html=\"true\"\n          data-i18n-attr=\"title:themeTooltip\" id=\"themeButton\"></span>\n      </div>\n    </div>\n  </header>\n\n  <main role=\"main\">\n    <div class=\"row\" style=\"margin-top: 10%\">\n      <div class=\"col-md-4 offset-md-4 align-self-center\">\n        <form id=\"login\">\n          <div class=\"portlet\">\n            <h5 data-i18n=\"Login\" class=\"portlet__head\">Login</h5>\n            <div class=\"portlet__body\" style=\"justify-content: center; align-items: center\">\n              <div class=\"form-group row\">\n                <label for=\"Username\" data-i18n=\"Username\" class=\"col-sm-2 col-form-label\">Username</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"Username\" id=\"Username\" autofocus />\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"Password\" data-i18n=\"Password\" class=\"col-sm-2 col-form-label\">Password</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" type=\"password\" name=\"Password\" id=\"Password\" />\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <div class=\"col-sm-10 offset-sm-2\">\n                  <button data-i18n=\"{{- if .EmptyUser -}}LoginInit{{else}}Login{{- end -}}\"\n                    class=\"btn btn-primary login_btn\">\n                    Login\n                  </button>\n                </div>\n              </div>\n            </div>\n          </div>\n        </form>\n      </div>\n    </div>\n  </main>\n</body>\n\n<script src=\"./static/theme.js\"></script>\n<script>\n  // 登录\n  document.querySelectorAll(\".login_btn\").forEach(($el) => {\n    $el.addEventListener(\"click\", async (e) => {\n      e.preventDefault();\n      try {\n        const resp = await request.post(\"./loginFunc\", {\n          Username: document.getElementById(\"Username\").value,\n          Password: document.getElementById(\"Password\").value,\n        });\n\n        if (resp.Code !== 200) {\n          showMessage({\n            content: resp.Msg,\n            type: \"error\",\n            duration: 5000,\n          });\n        } else {\n          window.location.href = \"./\";\n        }\n      } catch (err) {\n        showMessage({\n          content: err.toString(),\n          type: \"error\",\n          duration: 5000,\n        });\n      }\n    });\n  });\n</script>\n\n</html>"
  },
  {
    "path": "web/logout.go",
    "content": "package web\n\nimport (\n\t\"net/http\"\n\t\"time\"\n)\n\nfunc Logout(w http.ResponseWriter, r *http.Request) {\n\t// 覆盖cookieInSystem\n\tcookieInSystem = &http.Cookie{\n\t\tName:     cookieName,\n\t\tValue:    \"\",\n\t\tPath:     \"/\",\n\t\tExpires:  time.Unix(0, 0), // 设置为过期时间\n\t\tMaxAge:   -1,              // 立即删除该 Cookie\n\t\tHttpOnly: true,\n\t}\n\t// 设置过期的 Cookie\n\thttp.SetCookie(w, cookieInSystem)\n\n\t// 重定向用户到登录页面\n\thttp.Redirect(w, r, \"./login\", http.StatusFound)\n}\n"
  },
  {
    "path": "web/logs.go",
    "content": "package web\n\nimport (\n\t\"encoding/json\"\n\t\"io\"\n\t\"log\"\n\t\"net/http\"\n\t\"os\"\n)\n\n// MemoryLogs 内存中的日志\ntype MemoryLogs struct {\n\tMaxNum int      // 保存最大条数\n\tLogs   []string // 日志\n}\n\nfunc (mlogs *MemoryLogs) Write(p []byte) (n int, err error) {\n\tmlogs.Logs = append(mlogs.Logs, string(p))\n\t// 处理日志数量\n\tif len(mlogs.Logs) > mlogs.MaxNum {\n\t\tmlogs.Logs = mlogs.Logs[len(mlogs.Logs)-mlogs.MaxNum:]\n\t}\n\treturn len(p), nil\n}\n\nvar mlogs = &MemoryLogs{MaxNum: 50}\n\n// 初始化日志\nfunc init() {\n\tlog.SetOutput(io.MultiWriter(mlogs, os.Stdout))\n\t// log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile)\n}\n\n// Logs web\nfunc Logs(writer http.ResponseWriter, request *http.Request) {\n\t// mlogs.Logs数组转为json\n\tlogs, _ := json.Marshal(mlogs.Logs)\n\twriter.Write(logs)\n}\n\n// ClearLog\nfunc ClearLog(writer http.ResponseWriter, request *http.Request) {\n\tmlogs.Logs = mlogs.Logs[:0]\n}\n"
  },
  {
    "path": "web/return_json.go",
    "content": "package web\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n)\n\n// Result Result\ntype Result struct {\n\tCode int         // 状态\n\tMsg  string      // 消息\n\tData interface{} // 数据\n}\n\n// returnError 返回错误信息\nfunc returnError(w http.ResponseWriter, msg string) {\n\tresult := &Result{}\n\n\tresult.Code = http.StatusInternalServerError\n\tresult.Msg = msg\n\n\tjson.NewEncoder(w).Encode(result)\n}\n\n// returnOK\t返回成功信息\nfunc returnOK(w http.ResponseWriter, msg string, data interface{}) {\n\tresult := &Result{}\n\n\tresult.Code = http.StatusOK\n\tresult.Msg = msg\n\tresult.Data = data\n\n\tjson.NewEncoder(w).Encode(result)\n}\n"
  },
  {
    "path": "web/save.go",
    "content": "package web\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/dns\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\n// Save 保存\nfunc Save(writer http.ResponseWriter, request *http.Request) {\n\tresult := checkAndSave(request)\n\tdnsConfJsonStr := \"[]\"\n\tif result == \"ok\" {\n\t\tconf, _ := config.GetConfigCached()\n\t\tdnsConfJsonStr = getDnsConfStr(conf.DnsConf)\n\t}\n\tbyt, _ := json.Marshal(map[string]string{\"result\": result, \"dnsConf\": dnsConfJsonStr})\n\n\twriter.Write(byt)\n}\n\nfunc checkAndSave(request *http.Request) string {\n\tconf, _ := config.GetConfigCached()\n\n\t// 从请求中读取 JSON 数据\n\tvar data struct {\n\t\tUsername           string       `json:\"Username\"`\n\t\tPassword           string       `json:\"Password\"`\n\t\tNotAllowWanAccess  bool         `json:\"NotAllowWanAccess\"`\n\t\tWebhookURL         string       `json:\"WebhookURL\"`\n\t\tWebhookRequestBody string       `json:\"WebhookRequestBody\"`\n\t\tWebhookHeaders     string       `json:\"WebhookHeaders\"`\n\t\tDnsConf            []dnsConf4JS `json:\"DnsConf\"`\n\t}\n\n\t// 解析请求中的 JSON 数据\n\terr := json.NewDecoder(request.Body).Decode(&data)\n\tif err != nil {\n\t\treturn util.LogStr(\"数据解析失败, 请刷新页面重试\")\n\t}\n\tusernameNew := strings.TrimSpace(data.Username)\n\tpasswordNew := data.Password\n\n\t// 国际化\n\taccept := request.Header.Get(\"Accept-Language\")\n\tconf.Lang = util.InitLogLang(accept)\n\n\tconf.NotAllowWanAccess = data.NotAllowWanAccess\n\tconf.WebhookURL = strings.TrimSpace(data.WebhookURL)\n\tconf.WebhookRequestBody = strings.TrimSpace(data.WebhookRequestBody)\n\tconf.WebhookHeaders = strings.TrimSpace(data.WebhookHeaders)\n\n\t// 如果新密码不为空则检查是否够强, 内/外网要求强度不同\n\tconf.Username = usernameNew\n\tif passwordNew != \"\" {\n\t\thashedPwd, err := conf.CheckPassword(passwordNew)\n\t\tif err != nil {\n\t\t\treturn err.Error()\n\t\t}\n\t\tconf.Password = hashedPwd\n\t}\n\n\t// 帐号密码不能为空\n\tif conf.Username == \"\" || conf.Password == \"\" {\n\t\treturn util.LogStr(\"必须输入用户名/密码\")\n\t}\n\n\tdnsConfFromJS := data.DnsConf\n\tvar dnsConfArray []config.DnsConfig\n\tempty := dnsConf4JS{}\n\tfor k, v := range dnsConfFromJS {\n\t\tif v == empty {\n\t\t\tcontinue\n\t\t}\n\t\tdnsConf := config.DnsConfig{Name: v.Name, TTL: v.TTL}\n\t\t// 覆盖以前的配置\n\t\tdnsConf.DNS.Name = v.DnsName\n\t\tdnsConf.DNS.ID = strings.TrimSpace(v.DnsID)\n\t\tdnsConf.DNS.Secret = strings.TrimSpace(v.DnsSecret)\n\t\tdnsConf.DNS.ExtParam = strings.TrimSpace(v.DnsExtParam)\n\n\t\tif v.Ipv4Domains == \"\" && v.Ipv6Domains == \"\" {\n\t\t\tutil.Log(\"第 %s 个配置未填写域名\", util.Ordinal(k+1, conf.Lang))\n\t\t}\n\n\t\tdnsConf.Ipv4.Enable = v.Ipv4Enable\n\t\tdnsConf.Ipv4.GetType = v.Ipv4GetType\n\t\tdnsConf.Ipv4.URL = strings.TrimSpace(v.Ipv4Url)\n\t\tdnsConf.Ipv4.NetInterface = v.Ipv4NetInterface\n\t\tdnsConf.Ipv4.Cmd = strings.TrimSpace(v.Ipv4Cmd)\n\t\tdnsConf.Ipv4.Domains = util.SplitLines(v.Ipv4Domains)\n\n\t\tdnsConf.Ipv6.Enable = v.Ipv6Enable\n\t\tdnsConf.Ipv6.GetType = v.Ipv6GetType\n\t\tdnsConf.Ipv6.URL = strings.TrimSpace(v.Ipv6Url)\n\t\tdnsConf.Ipv6.NetInterface = v.Ipv6NetInterface\n\t\tdnsConf.Ipv6.Cmd = strings.TrimSpace(v.Ipv6Cmd)\n\t\tdnsConf.Ipv6.Ipv6Reg = strings.TrimSpace(v.Ipv6Reg)\n\t\tdnsConf.Ipv6.Domains = util.SplitLines(v.Ipv6Domains)\n\t\tdnsConf.HttpInterface = strings.TrimSpace(v.HttpInterface)\n\n\t\tif k < len(conf.DnsConf) {\n\t\t\tc := &conf.DnsConf[k]\n\t\t\tidHide, secretHide := getHideIDSecret(c)\n\t\t\tif dnsConf.DNS.ID == idHide {\n\t\t\t\tdnsConf.DNS.ID = c.DNS.ID\n\t\t\t}\n\t\t\tif dnsConf.DNS.Secret == secretHide {\n\t\t\t\tdnsConf.DNS.Secret = c.DNS.Secret\n\t\t\t}\n\t\t}\n\n\t\tdnsConfArray = append(dnsConfArray, dnsConf)\n\t}\n\tconf.DnsConf = dnsConfArray\n\n\t// 保存到用户目录\n\terr = conf.SaveConfig()\n\n\t// 只运行一次\n\tutil.ForceCompareGlobal = true\n\tgo dns.RunOnce()\n\n\t// 回写错误信息\n\tif err != nil {\n\t\treturn err.Error()\n\t}\n\treturn \"ok\"\n}\n"
  },
  {
    "path": "web/webhookTest.go",
    "content": "package web\n\nimport (\n\t\"encoding/json\"\n\t\"net/http\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n\t\"github.com/jeessy2/ddns-go/v6/util\"\n)\n\nfunc WebhookTest(writer http.ResponseWriter, request *http.Request) {\n\tvar data struct {\n\t\tURL         string `json:\"URL\"`\n\t\tRequestBody string `json:\"RequestBody\"`\n\t\tHeaders     string `json:\"Headers\"`\n\t}\n\terr := json.NewDecoder(request.Body).Decode(&data)\n\tif err != nil {\n\t\tutil.Log(\"数据解析失败, 请刷新页面重试\")\n\t\treturn\n\t}\n\n\turl := data.URL\n\trequestBody := data.RequestBody\n\theaders := data.Headers\n\n\tif url == \"\" {\n\t\tutil.Log(\"请输入Webhook的URL\")\n\t\treturn\n\t}\n\n\tvar domains = make([]*config.Domain, 1)\n\tdomains[0] = &config.Domain{}\n\tdomains[0].DomainName = \"example.com\"\n\tdomains[0].SubDomain = \"test\"\n\tdomains[0].UpdateStatus = config.UpdatedSuccess\n\n\tfakeDomains := &config.Domains{\n\t\tIpv4Addr:    \"127.0.0.1\",\n\t\tIpv4Domains: domains,\n\t\tIpv6Addr:    \"::1\",\n\t\tIpv6Domains: domains,\n\t}\n\n\tfakeConfig := &config.Config{\n\t\tWebhook: config.Webhook{\n\t\t\tWebhookURL:         url,\n\t\t\tWebhookRequestBody: requestBody,\n\t\t\tWebhookHeaders:     headers,\n\t\t},\n\t}\n\n\tconfig.ExecWebhook(fakeDomains, fakeConfig)\n}\n"
  },
  {
    "path": "web/writing.go",
    "content": "package web\n\nimport (\n\t\"embed\"\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"html/template\"\n\t\"net/http\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/jeessy2/ddns-go/v6/config\"\n)\n\n//go:embed writing.html\nvar writingEmbedFile embed.FS\n\nconst VersionEnv = \"DDNS_GO_VERSION\"\n\n// js中的dns配置\ntype dnsConf4JS struct {\n\tName             string\n\tDnsName          string\n\tDnsID            string\n\tDnsSecret        string\n\tDnsExtParam      string\n\tTTL              string\n\tIpv4Enable       bool\n\tIpv4GetType      string\n\tIpv4Url          string\n\tIpv4NetInterface string\n\tIpv4Cmd          string\n\tIpv4Domains      string\n\tIpv6Enable       bool\n\tIpv6GetType      string\n\tIpv6Url          string\n\tIpv6NetInterface string\n\tIpv6Cmd          string\n\tIpv6Reg          string\n\tIpv6Domains      string\n\tHttpInterface    string\n}\n\n// Writing 填写信息\nfunc Writing(writer http.ResponseWriter, request *http.Request) {\n\ttmpl, err := template.ParseFS(writingEmbedFile, \"writing.html\")\n\tif err != nil {\n\t\tfmt.Println(\"Error happened..\")\n\t\tfmt.Println(err)\n\t\treturn\n\t}\n\n\tconf, err := config.GetConfigCached()\n\t// 默认禁止公网访问\n\tif err != nil {\n\t\tconf.NotAllowWanAccess = true\n\t}\n\n\tipv4, ipv6, _ := config.GetNetInterface()\n\n\t// 获取所有网卡（去重合并IPv4和IPv6网卡名称）\n\tallIfaceNames := map[string]bool{}\n\tfor _, iface := range ipv4 {\n\t\tallIfaceNames[iface.Name] = true\n\t}\n\tfor _, iface := range ipv6 {\n\t\tallIfaceNames[iface.Name] = true\n\t}\n\tallInterfaces := []config.NetInterface{}\n\tfor name := range allIfaceNames {\n\t\tallInterfaces = append(allInterfaces, config.NetInterface{Name: name})\n\t}\n\n\terr = tmpl.Execute(writer, struct {\n\t\tDnsConf           template.JS\n\t\tNotAllowWanAccess bool\n\t\tUsername          string\n\t\tconfig.Webhook\n\t\tVersion       string\n\t\tIpv4          []config.NetInterface\n\t\tIpv6          []config.NetInterface\n\t\tAllInterfaces []config.NetInterface\n\t}{\n\t\tDnsConf:           template.JS(getDnsConfStr(conf.DnsConf)),\n\t\tNotAllowWanAccess: conf.NotAllowWanAccess,\n\t\tUsername:          conf.User.Username,\n\t\tWebhook:           conf.Webhook,\n\t\tVersion:           os.Getenv(VersionEnv),\n\t\tIpv4:              ipv4,\n\t\tIpv6:              ipv6,\n\t\tAllInterfaces:     allInterfaces,\n\t})\n\tif err != nil {\n\t\tfmt.Println(\"Error happened..\")\n\t\tfmt.Println(err)\n\t}\n}\n\nfunc getDnsConfStr(dnsConf []config.DnsConfig) string {\n\tdnsConfArray := []dnsConf4JS{}\n\tfor _, conf := range dnsConf {\n\t\t// 已存在配置文件，隐藏真实的ID、Secret\n\t\tidHide, secretHide := getHideIDSecret(&conf)\n\t\tdnsConfArray = append(dnsConfArray, dnsConf4JS{\n\t\t\tName:             conf.Name,\n\t\t\tDnsName:          conf.DNS.Name,\n\t\t\tDnsID:            idHide,\n\t\t\tDnsSecret:        secretHide,\n\t\t\tDnsExtParam:      conf.DNS.ExtParam,\n\t\t\tTTL:              conf.TTL,\n\t\t\tIpv4Enable:       conf.Ipv4.Enable,\n\t\t\tIpv4GetType:      conf.Ipv4.GetType,\n\t\t\tIpv4Url:          conf.Ipv4.URL,\n\t\t\tIpv4NetInterface: conf.Ipv4.NetInterface,\n\t\t\tIpv4Cmd:          conf.Ipv4.Cmd,\n\t\t\tIpv4Domains:      strings.Join(conf.Ipv4.Domains, \"\\r\\n\"),\n\t\t\tIpv6Enable:       conf.Ipv6.Enable,\n\t\t\tIpv6GetType:      conf.Ipv6.GetType,\n\t\t\tIpv6Url:          conf.Ipv6.URL,\n\t\t\tIpv6NetInterface: conf.Ipv6.NetInterface,\n\t\t\tIpv6Cmd:          conf.Ipv6.Cmd,\n\t\t\tIpv6Reg:          conf.Ipv6.Ipv6Reg,\n\t\t\tIpv6Domains:      strings.Join(conf.Ipv6.Domains, \"\\r\\n\"),\n\t\t\tHttpInterface:    conf.HttpInterface,\n\t\t})\n\t}\n\tbyt, _ := json.Marshal(dnsConfArray)\n\treturn string(byt)\n}\n\n// 显示的数量\nconst displayCount int = 3\n\n// hideIDSecret 隐藏真实的ID、Secret\nfunc getHideIDSecret(conf *config.DnsConfig) (idHide string, secretHide string) {\n\tif len(conf.DNS.ID) > displayCount && conf.DNS.Name != \"callback\" {\n\t\tidHide = conf.DNS.ID[:displayCount] + strings.Repeat(\"*\", len(conf.DNS.ID)-displayCount)\n\t} else {\n\t\tidHide = conf.DNS.ID\n\t}\n\tif len(conf.DNS.Secret) > displayCount && conf.DNS.Name != \"callback\" {\n\t\tsecretHide = conf.DNS.Secret[:displayCount] + strings.Repeat(\"*\", len(conf.DNS.Secret)-displayCount)\n\t} else {\n\t\tsecretHide = conf.DNS.Secret\n\t}\n\treturn\n}\n"
  },
  {
    "path": "web/writing.html",
    "content": "<!DOCTYPE html>\n<html>\n\n<head>\n  <meta charset=\"utf-8\" />\n  <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\" />\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n  <meta name=\"author\" content=\"jeessy2\" />\n  <title>DDNS-GO</title>\n  <meta name=\"color-scheme\" content=\"light dark\">\n  <link class=\"theme\" rel=\"stylesheet\" type=\"text/css\" href=\"./static/common.css\" />\n  <link rel=\"stylesheet\" href=\"./static/bootstrap.min.css\" />\n  <link rel=\"stylesheet\" href=\"./static/theme-button.css\" />\n  <script src=\"./static/constant.js\"></script>\n  <script src=\"./static/utils.js\"></script>\n  <script src=\"./static/i18n.js\"></script>\n  <script src=\"./static/tooltips.js\"></script>\n</head>\n\n<body>\n  <header>\n    <div class=\"navbar navbar-dark bg-dark shadow-sm\">\n      <div class=\"button-container container d-flex justify-content-between\">\n        <a target=\"blank\" href=\"https://github.com/jeessy2/ddns-go\" class=\"navbar-brand d-flex align-items-center\">\n          <strong>DDNS-GO</strong>\n        </a>\n        <button data-i18n=\"Logs\" class=\"btn btn-info btn-sm\" id=\"logsBtn\" data-toggle=\"tooltip\" data-placement=\"bottom\">\n          Logs\n        </button>\n        <span class=\"theme-button gg-dark-mode\" data-toggle=\"tooltip\" data-placement=\"bottom\" data-html=\"true\"\n          data-i18n-attr=\"title:themeTooltip\" id=\"themeButton\"></span>\n        <span class=\"badge badge-secondary\">{{.Version}}</span>\n        <a href=\"./logout\" class=\"action-button logout-button\" data-i18n=\"Logout\">\n          Logout\n        </a>\n      </div>\n    </div>\n  </header>\n\n  <main role=\"main\">\n    <div id=\"mask\" style=\"visibility: hidden\"></div>\n    <div class=\"row\">\n      <div class=\"col-md-6 offset-md-3\">\n        <div class=\"row\" style=\"margin-top: 15px; margin-bottom: 15px\">\n          <div class=\"col-md-4 col-sm-12\">\n            <button data-i18n=\"Save\" class=\"btn btn-primary submit_btn\">Save</button>\n          </div>\n\n          <div class=\"col-md-8 col-sm-12\" style=\"margin-left: auto; margin-right: 0\">\n            <form class=\"form-inline\" style=\"margin-top: 5px\">\n              <label data-i18n=\"Config:\" for=\"index\" style=\"margin-left: auto\">Config:</label>\n              <select class=\"form-control form-control-sm\" style=\"margin: 0 5px; width: 155px\" name=\"Index\"\n                id=\"index\"></select>\n              <button data-i18n=\"Add\" class=\"btn btn-primary btn-sm\" id=\"addBtn\">\n                Add\n              </button>\n              <button data-i18n=\"Rename\" class=\"btn btn-primary btn-sm\" style=\"margin: 0 5px\" id=\"renameBtn\">\n                Rename\n              </button>\n              <button data-i18n=\"Delete\" class=\"btn btn-primary btn-sm\" id=\"delBtn\">\n                Delete\n              </button>\n            </form>\n          </div>\n        </div>\n\n        <form id=\"formDnsConf\">\n          <div class=\"portlet\">\n            <h5 data-i18n=\"DNS Provider\" class=\"portlet__head\" id=\"dnsProvider\">DNS Provider</h5>\n            <div class=\"portlet__body\">\n              <div class=\"form-group row\">\n                <label class=\"col-sm-2 col-form-label\"></label>\n                <div class=\"col-sm-10\">\n                  <div id=\"DnsSelector\"></div>\n                  <small id=\"dnsHelp\" class=\"form-text text-muted\">\n                  </small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"DnsID\" id=\"dnsIdLabel\" class=\"col-sm-2 col-form-label\">AccessKey ID</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"DnsID\" id=\"DnsID\" />\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"DnsSecret\" id=\"dnsSecretLabel\" class=\"col-sm-2 col-form-label\">AccessKey Secret</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"DnsSecret\" id=\"DnsSecret\" />\n                </div>\n              </div>\n\n              <div class=\"form-group row\" id=\"DnsExtParamRow\" style=\"display: none;\">\n                <label for=\"DnsExtParam\" id=\"dnsExtParamLabel\" class=\"col-sm-2 col-form-label\">Team ID</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"DnsExtParam\" id=\"DnsExtParam\" placeholder=\"team_xxxxxx (可选)\" />\n                  <small class=\"form-text text-muted\">\n                    <span id=\"dnsExtParamHelp\" data-i18n-html=\"extParamHelp\">\n                      可选项，如果您使用的是 Vercel 团队账户，请填写团队 ID\n                    </span>\n                  </small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label class=\"col-sm-2 col-form-label\">TTL</label>\n                <div class=\"col-sm-10\">\n                  <select class=\"form-control form\" name=\"TTL\" id=\"TTL\" value=\"\">\n                    <option data-i18n=\"Auto\" value=\"\" selected>Auto</option>\n                    <option data-i18n=\"1s\" value=\"1\">1s</option>\n                    <option data-i18n=\"5s\" value=\"5\">5s</option>\n                    <option data-i18n=\"10s\" value=\"10\">10s</option>\n                    <option data-i18n=\"1m\" value=\"60\">1m</option>\n                    <option data-i18n=\"2m\" value=\"120\">2m</option>\n                    <option data-i18n=\"10m\" value=\"600\">10m</option>\n                    <option data-i18n=\"30m\" value=\"1800\">30m</option>\n                    <option data-i18n=\"1h\" value=\"3600\">1h</option>\n                  </select>\n                  <small data-i18n-html=\"ttlHelp\" id=\"ttlHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label data-i18n=\"Http Interface\" for=\"HttpInterface\" class=\"col-sm-2 col-form-label\">Http Interface</label>\n                <div class=\"col-sm-10\">\n                  <select class=\"form-control form\" name=\"HttpInterface\" id=\"HttpInterface\">\n                    <option data-i18n=\"Default\" value=\"\">Default</option>\n                    {{range .AllInterfaces}}\n                    <option value=\"{{.Name}}\">{{.Name}}</option>\n                    {{end}}\n                  </select>\n                  <small data-i18n-html=\"HttpInterfaceHelp\" id=\"HttpInterfaceHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"portlet\">\n            <h5 class=\"portlet__head\">IPv4</h5>\n            <div class=\"portlet__body\">\n              <div class=\"form-group row\">\n                <label data-i18n=\"Enabled\" for=\"Ipv4Enable\" class=\"col-sm-2\">Enabled</label>\n                <div class=\"col-sm-10\">\n                  <input type=\"checkbox\" class=\"form-check-inline\" style=\"margin-top: 5px\" id=\"Ipv4Enable\"\n                    name=\"Ipv4Enable\" checked />\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label data-i18n=\"Get IP method\" for=\"Ipv4Url\" class=\"col-sm-2 col-form-label\">\n                  Get IP method\n                </label>\n                <div class=\"col-sm-10\">\n                  <div class=\"form-check form-check-inline\">\n                    <input class=\"form-check-input\" type=\"radio\" name=\"Ipv4GetType\" id=\"urlRadioIpv4\" value=\"url\"\n                      checked />\n                    <label data-i18n=\"By api\" class=\"form-check-label\" for=\"urlRadioIpv4\">By api</label>\n                  </div>\n                  <div class=\"form-check form-check-inline\">\n                    <input class=\"form-check-input\" type=\"radio\" name=\"Ipv4GetType\" id=\"netInterfaceRadioIpv4\"\n                      value=\"netInterface\" />\n                    <label data-i18n=\"By network card\" class=\"form-check-label\" for=\"netInterfaceRadioIpv4\">By network\n                      card</label>\n                  </div>\n                  <div class=\"form-check form-check-inline\">\n                    <input class=\"form-check-input\" type=\"radio\" name=\"Ipv4GetType\" id=\"cmdRadioIpv4\" value=\"cmd\" />\n                    <label data-i18n=\"By command\" class=\"form-check-label\" for=\"cmdRadioIpv4\">By command</label>\n                  </div>\n                  <input type=\"url\" class=\"form-control form\" name=\"Ipv4Url\" id=\"Ipv4Url\" aria-describedby=\"Ipv4UrlHelp\"\n                    data-visible=\"url\" />\n                  <select class=\"form-control\" id=\"Ipv4NetInterface\" name=\"Ipv4NetInterface\"\n                    aria-describedby=\"Ipv4NetInterfaceHelp\" data-visible=\"netInterface\">\n                    {{range .Ipv4}}\n                    <option value=\"{{.Name}}\">\n                      {{.Name}}{{.Address}}\n                    </option>\n                    {{end}}\n                  </select>\n                  <input type=\"text\" class=\"form-control form\" id=\"Ipv4Cmd\" name=\"Ipv4Cmd\"\n                    aria-describedby=\"Ipv4CmdHelp\" data-visible=\"cmd\" />\n                  <small data-i18n-html=\"Ipv4UrlHelp\" id=\"Ipv4UrlHelp\" class=\"form-text text-muted\"\n                    data-visible=\"url\"></small>\n                  <small {{if len .Ipv4}} data-i18n-html=\"Ipv4NetInterfaceHelp\" {{else}}\n                    data-i18n-html=\"NetInterfaceEmptyHelp\" {{end}} id=\"Ipv4NetInterfaceHelp\"\n                    class=\"form-text text-muted\" data-visible=\"netInterface\"></small>\n                  <small data-i18n-html=\"Ipv4CmdHelp\" id=\"Ipv4CmdHelp\" class=\"form-text text-muted\"\n                    data-visible=\"cmd\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"Ipv4Domains\" class=\"col-sm-2 col-form-label\">Domains</label>\n                <div class=\"col-sm-10\">\n                  <textarea class=\"form-control form\" id=\"Ipv4Domains\" name=\"Ipv4Domains\" rows=\"3\"\n                    aria-describedby=\"ipv4DomainsHelp\"></textarea>\n                  <small data-i18n-html=\"domainsHelp\" id=\"ipv4DomainsHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"portlet\">\n            <h5 class=\"portlet__head\">IPv6</h5>\n            <div class=\"portlet__body\">\n              <div class=\"form-group row\">\n                <label data-i18n=\"Enabled\" for=\"Ipv6Enable\" class=\"col-sm-2\">Enabled</label>\n                <div class=\"col-sm-10\">\n                  <input type=\"checkbox\" class=\"form-check-inline\" style=\"margin-top: 5px\" id=\"Ipv6Enable\"\n                    name=\"Ipv6Enable\" checked />\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label data-i18n=\"Get IP method\" for=\"Ipv6Url\" class=\"col-sm-2 col-form-label\">\n                  Get IP method\n                </label>\n                <div class=\"col-sm-10\">\n                  <div class=\"form-check form-check-inline\">\n                    <input class=\"form-check-input\" type=\"radio\" name=\"Ipv6GetType\" id=\"urlRadioIpv6\" value=\"url\"\n                      checked />\n                    <label data-i18n=\"By api\" class=\"form-check-label\" for=\"urlRadioIpv6\">By api</label>\n                  </div>\n                  <div class=\"form-check form-check-inline\">\n                    <input class=\"form-check-input\" type=\"radio\" name=\"Ipv6GetType\" id=\"netInterfaceRadioIpv6\"\n                      value=\"netInterface\" />\n                    <label data-i18n=\"By network card\" class=\"form-check-label\" for=\"netInterfaceRadioIpv6\">By network\n                      card</label>\n                  </div>\n                  <div class=\"form-check form-check-inline\">\n                    <input class=\"form-check-input\" type=\"radio\" name=\"Ipv6GetType\" id=\"cmdRadioIpv6\" value=\"cmd\" />\n                    <label data-i18n=\"By command\" class=\"form-check-label\" for=\"cmdRadioIpv6\">By command</label>\n                  </div>\n                  <input type=\"url\" class=\"form-control form\" id=\"Ipv6Url\" name=\"Ipv6Url\" aria-describedby=\"Ipv6UrlHelp\"\n                    data-visible=\"url\" />\n                  <select class=\"form-control\" id=\"Ipv6NetInterface\" name=\"Ipv6NetInterface\"\n                    aria-describedby=\"Ipv6NetInterfaceHelp\" data-visible=\"netInterface\">\n                    {{range .Ipv6}}\n                    <option value=\"{{.Name}}\">\n                      {{.Name}}{{.Address}}\n                    </option>\n                    {{end}}\n                  </select>\n                  <input type=\"text\" class=\"form-control form\" id=\"Ipv6Cmd\" name=\"Ipv6Cmd\"\n                    aria-describedby=\"Ipv6CmdHelp\" data-visible=\"cmd\" />\n                  <small data-i18n-html=\"Ipv6UrlHelp\" id=\"Ipv6UrlHelp\" class=\"form-text text-muted\"\n                    data-visible=\"url\"></small>\n                  <small {{if len .Ipv6}} data-i18n-html=\"Ipv6NetInterfaceHelp\" {{else}}\n                    data-i18n-html=\"NetInterfaceEmptyHelp\" {{end}} id=\"Ipv6NetInterfaceHelp\"\n                    class=\"form-text text-muted\" data-visible=\"netInterface\"></small>\n                  <small data-i18n-html=\"Ipv6CmdHelp\" id=\"Ipv6CmdHelp\" class=\"form-text text-muted\"\n                    data-visible=\"cmd\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\" id=\"Ipv6RegDiv\" data-visible=\"netInterface\" style=\"display: none\">\n                <label data-i18n=\"Regular exp.\" for=\"Ipv6Reg\" class=\"col-sm-2 col-form-label\">Regular exp.</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"Ipv6Reg\" id=\"Ipv6Reg\" aria-describedby=\"Ipv6RegHelp\" />\n                  <small data-i18n-html=\"regHelp\" id=\"Ipv6RegHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"Ipv6Domains\" class=\"col-sm-2 col-form-label\">Domains</label>\n                <div class=\"col-sm-10\">\n                  <textarea class=\"form-control form\" id=\"Ipv6Domains\" name=\"Ipv6Domains\" rows=\"3\"\n                    aria-describedby=\"ipv6_domainsHelp\"></textarea>\n                  <small data-i18n-html=\"domainsHelp\" id=\"ipv6_domainsHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n            </div>\n          </div>\n        </form>\n\n        <form id=\"formGlobal\">\n          <div class=\"portlet\">\n            <h5 data-i18n=\"Others\" class=\"portlet__head\">Others</h5>\n            <div class=\"portlet__body\">\n              <div class=\"form-group row\">\n                <label data-i18n=\"Deny from WAN\" for=\"NotAllowWanAccess\" class=\"col-sm-2 col-form-label\">Deny from\n                  WAN</label>\n                <div class=\"col-sm-10\">\n                  <input type=\"checkbox\" class=\"form-check-inline\" style=\"margin-top: 5px\" id=\"NotAllowWanAccess\"\n                    name=\"NotAllowWanAccess\" {{if .NotAllowWanAccess}}checked{{end}} />\n                  <small data-i18n-html=\"NotAllowWanAccessHelp\" id=\"NotAllowWanAccessHelp\"\n                    class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label data-i18n=\"Username\" for=\"Username\" class=\"col-sm-2 col-form-label\">Username</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"Username\" id=\"Username\" value=\"{{.Username}}\"\n                    autocomplete=\"username\" aria-describedby=\"UsernameHelp\" />\n                  <small data-i18n-html=\"accountHelp\" id=\"UsernameHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label data-i18n=\"Password\" for=\"Password\" class=\"col-sm-2 col-form-label\">Password</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" type=\"password\" name=\"Password\" id=\"Password\"\n                    autocomplete=\"new-password\" aria-describedby=\"passwordHelp\" />\n                  <small data-i18n-html=\"passwordHelp\" id=\"passwordHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n            </div>\n          </div>\n\n          <div class=\"portlet\">\n            <h5 class=\"portlet__head\">Webhook</h5>\n            <div class=\"portlet__body\">\n              <div class=\"form-group row\">\n                <label for=\"WebhookURL\" class=\"col-sm-2 col-form-label\">URL</label>\n                <div class=\"col-sm-10\">\n                  <input class=\"form-control form\" name=\"WebhookURL\" id=\"WebhookURL\" value=\"{{.WebhookURL}}\"\n                    aria-describedby=\"WebhookURLHelp\" />\n                  <small data-i18n-html=\"WebhookURLHelp\" id=\"WebhookURLHelp\" class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"WebhookRequestBody\" class=\"col-sm-2 col-form-label\">RequestBody</label>\n                <div class=\"col-sm-10\">\n                  <textarea class=\"form-control form\" id=\"WebhookRequestBody\" name=\"WebhookRequestBody\" rows=\"3\"\n                    aria-describedby=\"WebhookRequestBodyHelp\">{{.WebhookRequestBody}}</textarea>\n                  <small data-i18n-html=\"WebhookRequestBodyHelp\" id=\"WebhookRequestBodyHelp\"\n                    class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label for=\"WebhookHeaders\" class=\"col-sm-2 col-form-label\">Headers</label>\n                <div class=\"col-sm-10\">\n                  <textarea class=\"form-control form\" id=\"WebhookHeaders\" name=\"WebhookHeaders\" rows=\"1\"\n                    aria-describedby=\"WebhookHeadersHelp\">{{.WebhookHeaders}}</textarea>\n                  <small data-i18n-html=\"WebhookHeadersHelp\" id=\"WebhookHeadersHelp\"\n                    class=\"form-text text-muted\"></small>\n                </div>\n              </div>\n\n              <div class=\"form-group row\">\n                <label class=\"col-sm-2 col-form-label\"></label>\n                <div class=\"col-sm-10\">\n                  <button data-i18n=\"Try it\" class=\"webhook-button btn btn-primary btn-sm\" id=\"webhookTestBtn\"\n                    data-toggle=\"tooltip\" data-i18n-attr=\"title:webhookTestTooltip\">\n                    Try it\n                  </button>\n                </div>\n              </div>\n            </div>\n          </div>\n        </form>\n\n        <button data-i18n=\"Save\" class=\"btn btn-primary submit_btn\" style=\"margin-bottom: 16px\" data-placement=\"top\">\n          Save\n        </button>\n      </div>\n      <div class=\"logs-panel col-md-6 offset-md-3\" style=\"visibility: hidden\" id=\"logs-panel\">\n        <textarea class=\"logs form-control\" id=\"logs\" readonly></textarea>\n        <button data-i18n=\"Clear\" type=\"button\" class=\"btn btn-danger btn-sm\" id=\"clearLogBtn\">\n          Clear\n        </button>\n        <button data-i18n=\"OK\" type=\"button\" class=\"btn btn-primary btn-sm\" style=\"float: right\" id=\"closeLogBtn\">\n          OK\n        </button>\n      </div>\n    </div>\n  </main>\n\n</body>\n\n<!-- 全局变量 -->\n<script>\n  let configIndex = -1;\n  let dnsConf = [];\n  const globalConf = {\n    NotAllowWanAccess: document.getElementById(\"NotAllowWanAccess\").checked,\n    Username: document.getElementById(\"Username\").value,\n    Password: document.getElementById(\"Password\").value,\n    WebhookURL: document.getElementById(\"WebhookURL\").value,\n    WebhookRequestBody: document.getElementById(\"WebhookRequestBody\").value,\n    WebhookHeaders: document.getElementById(\"WebhookHeaders\").value,\n  };\n  const defaultDnsConf = {\n    Name: \"\",\n    DnsID: \"\",\n    DnsName: \"alidns\",\n    DnsSecret: \"\",\n    DnsExtParam: \"\",\n    HttpInterface: \"\",\n    Ipv4Cmd: \"\",\n    Ipv4Domains: \"\",\n    Ipv4Enable: true,\n    Ipv4GetType: \"url\",\n    Ipv4NetInterface: \"\",\n    Ipv4Url: i18n({\n      \"en\": \"https://api.ipify.org, https://ddns.oray.com/checkip, https://ip.3322.net, https://4.ipw.cn, https://v4.yinghualuo.cn/bejson\",\n      \"zh-cn\": \"https://myip.ipip.net, https://ddns.oray.com/checkip, https://ip.3322.net, https://4.ipw.cn, https://v4.yinghualuo.cn/bejson\",\n    }),\n    Ipv6Cmd: \"\",\n    Ipv6Domains: \"\",\n    Ipv6Enable: true,\n    Ipv6GetType: \"netInterface\",\n    Ipv6NetInterface: \"\",\n    Ipv6Reg: \"\",\n    Ipv6Url: i18n({\n      \"en\": \"https://api64.ipify.org, https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson\",\n      \"zh-cn\": \"https://speed.neu6.edu.cn/getIP.php, https://v6.ident.me, https://6.ipw.cn, https://v6.yinghualuo.cn/bejson\",\n    }),\n    TTL: \"\",\n  };\n</script>\n\n<!-- 表单相关 -->\n<script>\n  // 生成DNS选择项\n  for (const key in DNS_PROVIDERS) {\n    const value = DNS_PROVIDERS[key];\n    const $selector = document.getElementById(\"DnsSelector\");\n    const $el = html2Element(`\n        <div class=\"form-check form-check-inline col-form-label\">\n          <input\n            class=\"form-check-input\"\n            type=\"radio\"\n            name=\"DnsName\"\n            id=\"${key}\"\n            value=\"${key}\"\n          />\n          <label class=\"form-check-label\" for=\"${key}\">${i18n(value.name)}</label>\n        </div>\n      `);\n    $selector.appendChild($el);\n  }\n\n  // Dns名称被点击\n  document.querySelectorAll(\"input[name=DnsName]\").forEach($input => {\n    $input.addEventListener('click', e => {\n      const dnsInfo = DNS_PROVIDERS[e.target.value];\n      const $dnsID = document.getElementById(\"DnsID\");\n      const $dnsExtParamRow = document.getElementById(\"DnsExtParamRow\");\n      const $dnsExtParamLabel = document.getElementById(\"dnsExtParamLabel\");\n      const $dnsExtParamHelp = document.getElementById(\"dnsExtParamHelp\");\n      // idLabel 为空时隐藏 DnsID\n      if (dnsInfo.idLabel) {\n        $dnsID.style.display = \"block\";\n      } else {\n        $dnsID.style.display = \"none\";\n      }\n      // 根据DNS提供商显示扩展参数\n      if (dnsInfo.extParamLabel) {\n        $dnsExtParamRow.style.display = \"\";\n        $dnsExtParamLabel.innerHTML = dnsInfo.extParamLabel;\n        $dnsExtParamHelp.innerHTML = i18n(dnsInfo.extParamHelpHtml);\n      } else {\n        $dnsExtParamRow.style.display = \"none\";\n      }\n      document.getElementById(\"dnsIdLabel\").innerHTML = dnsInfo.idLabel;\n      document.getElementById(\"dnsSecretLabel\").innerHTML = dnsInfo.secretLabel;\n      document.getElementById(\"dnsHelp\").innerHTML = i18n(dnsInfo.helpHtml);\n      document.getElementById(`index_${configIndex}`).textContent = getConfName(configIndex, e.target.value);\n\n      dnsConf[configIndex].DnsName = e.target.value;\n    });\n  });\n\n  // formDnsConf中的表单项值改变时，更新dnsConf\n  document.querySelectorAll(\"#formDnsConf [name]\").forEach($e => {\n    const name = $e.getAttribute(\"name\");\n    // 判断对应input的类型\n    switch ($e.getAttribute(\"type\")) {\n      case \"checkbox\":\n        $e.addEventListener('change', e => {\n          dnsConf[configIndex][name] = e.target.checked;\n        });\n        break;\n      // 如果是其它类型的input或者不是input（如textarea、select），都可以使用input事件监听\n      default:\n        $e.addEventListener('input', e => {\n          dnsConf[configIndex][name] = e.target.value;\n        });\n        break;\n    }\n  });\n\n  // formGlobal中的表单项值改变时，更新globalConf\n  document.querySelectorAll(\"#formGlobal [name]\").forEach($e => {\n    const name = $e.getAttribute(\"name\");\n    // 判断对应input的类型\n    switch ($e.getAttribute(\"type\")) {\n      case \"checkbox\":\n        $e.addEventListener('change', e => {\n          globalConf[name] = e.target.checked;\n        });\n        break;\n      // 如果是其它类型的input或者不是input（如textarea、select），都可以使用input事件监听\n      default:\n        $e.addEventListener('input', e => {\n          globalConf[name] = e.target.value;\n        });\n        break;\n    }\n  });\n\n  // 处理切换IP获取方式时的UI变化\n  document.querySelectorAll('[name=Ipv4GetType], [name=Ipv6GetType]').forEach($input => {\n    $input.addEventListener('click', e => {\n      const $target = e.target;\n      const $group = $target.closest('.form-group');\n      const $card = $target.closest('.portlet__body');\n      $card.querySelectorAll('[data-visible]').forEach($el => {\n        $el.style.display = \"none\";\n      });\n      $card.querySelectorAll(`[data-visible=${$target.value}]`).forEach($el => {\n        $el.style.display = \"\";\n      });\n      // 修改label的for属性\n      const $curInput = $group.querySelector(`[data-visible=${$target.value}]`);\n      $group.querySelector('[data-i18n=\"Get IP method\"]').setAttribute('for', $curInput.id);\n    });\n  });\n</script>\n\n<!-- 配置项 -->\n<script>\n  // 不需要填充到表单中的字段\n  const SKIPPED_NAMES = [\"Name\"];\n\n  // 把dnsConf中的值填充到表单中\n  function showConf(idx) {\n    const conf = dnsConf[idx] ?? {};\n    for (const name in conf) {\n      if (SKIPPED_NAMES.includes(name)) {\n        continue;\n      }\n\n      const $e = document.querySelector(`[name=${name}]`);\n      // 判断对应input的类型\n      switch ($e.getAttribute(\"type\")) {\n        case \"checkbox\":\n          $e.checked = conf[name];\n          break;\n        case \"radio\":\n          document.querySelector(`[name=${name}][value=${conf[name]}]`).click();\n          break;\n        default:\n          //特殊处理select类型，要保证option存在，否则取第一个option\n          if ($e.tagName === \"SELECT\" &&\n            $e.querySelector(`option[value=\"${conf[name]}\"]`) === null) {\n            $e.value = $e.querySelector(\"option\")?.value;\n            conf[name] = $e.value || \"\";\n          }\n          // 如果是其它类型的input或者不是input（如textarea），都使用value赋值\n          $e.value = conf[name];\n          break;\n      }\n    }\n    // 根据 DNS 提供商显示或隐藏扩展参数输入框\n    const $dnsExtParamRow = document.getElementById(\"DnsExtParamRow\");\n    const $dnsExtParamLabel = document.getElementById(\"dnsExtParamLabel\");\n    const $dnsExtParamHelp = document.getElementById(\"dnsExtParamHelp\");\n    const dnsInfo = DNS_PROVIDERS[conf.DnsName];\n    if (dnsInfo && dnsInfo.extParamLabel) {\n      $dnsExtParamRow.style.display = \"\";\n      $dnsExtParamLabel.innerHTML = dnsInfo.extParamLabel;\n      $dnsExtParamHelp.innerHTML = i18n(dnsInfo.extParamHelpHtml);\n    } else {\n      $dnsExtParamRow.style.display = \"none\";\n    }\n  }\n\n  // 从json中重新加载配置\n  function reloadConf(jsonConf) {\n    try {\n      dnsConf = JSON.parse(jsonConf);\n      if (dnsConf.length === 0) {\n        console.warn(\"dnsConf is empty, add defaultDnsConf\");\n        dnsConf.push({ ...defaultDnsConf });\n      }\n    } catch (e) {\n      alert(\"error: \" + e.toString());\n      return;\n    }\n\n    // Reload all configs in dnsConf into index\n    const $index = document.getElementById(\"index\");\n    $index.innerHTML = \"\";\n    for (let i = 0; i < dnsConf.length; i++) {\n      appendConfigToIndex(i);\n    }\n    // 负数表示倒数第几个\n    if (configIndex < 0) {\n      configIndex += dnsConf.length;\n    } else {\n      configIndex = Math.min(configIndex, dnsConf.length - 1);\n    }\n    $index.value = configIndex;\n    showConf(configIndex);\n  }\n\n  // 拼接新的配置项到下拉菜单\n  function appendConfigToIndex(idx) {\n    const $index = document.getElementById('index');\n\n    const $option = html2Element(`\n        <option id=\"index_${idx}\" value=\"${idx}\">\n          ${getConfName(idx)}\n        </option>`);\n    $index.append($option);\n  }\n\n  // 获取配置名称或生成默认名称\n  function getConfName(idx, _default = dnsConf[idx].DnsName) {\n    return dnsConf[idx].Name || `${idx + 1} - ${_default}`;\n  }\n\n  // 新增配置按钮被点击\n  document.getElementById(\"addBtn\").addEventListener('click', e => {\n    e.preventDefault();\n    configIndex = dnsConf.length;\n    dnsConf[configIndex] = { ...defaultDnsConf };\n\n    // 创建新的option\n    appendConfigToIndex(configIndex);\n\n    document.getElementById(\"index\").value = configIndex;\n    showConf(configIndex);\n  });\n\n  // 重命名配置按钮被点击\n  document.getElementById(\"renameBtn\").addEventListener('click', e => {\n    e.preventDefault();\n\n    const newName = prompt(i18n(\"RenameHelp\"));\n    if (newName) {\n      dnsConf[configIndex].Name = newName;\n      document.getElementById(`index_${configIndex}`).textContent = newName;\n    }\n  });\n\n  // 删除配置按钮被点击\n  document.getElementById(\"delBtn\").addEventListener('click', e => {\n    e.preventDefault();\n    const $index = document.getElementById(\"index\");\n    $index.options[configIndex].disabled = true;\n    $index.options[configIndex].text = configIndex + 1 + \" - Deleted\";\n    dnsConf[configIndex] = null;\n    while (dnsConf[configIndex] === null && configIndex >= 0) {\n      configIndex--;\n    }\n    if (configIndex >= 0) {\n      $index.value = configIndex;\n      showConf(configIndex);\n    } else {\n      document.getElementById(\"addBtn\").click();\n    }\n  });\n\n  // 保存配置按钮被点击\n  document.querySelectorAll(\".submit_btn\").forEach($el => {\n    $el.addEventListener('click', async e => {\n      e.preventDefault();\n      // 如果没有idLabel，删除DnsID\n      if (!DNS_PROVIDERS[dnsConf[configIndex].DnsName].idLabel) {\n        dnsConf[configIndex].DnsID = \"\";\n      }\n      try {\n        const resp = await request.post(\"./save\", {\n          ...globalConf,\n          DnsConf: dnsConf\n        });\n        if (resp.result !== \"ok\") {\n          showMessage({\n            content: resp.result,\n            type: \"error\",\n            duration: 5000,\n          });\n        } else {\n          showMessage({\n            content: i18n({\n              \"en\": \"Successfully saved\",\n              \"zh-cn\": \"保存成功\",\n            }),\n            type: \"success\",\n            duration: 1500,\n          });\n          reloadConf(resp.dnsConf);\n        }\n      } catch (err) {\n        alert(`${err.toString()}`);\n      }\n    });\n  });\n\n  // 切换配置项\n  document.getElementById(\"index\").addEventListener('change', e => {\n    configIndex = parseInt(e.target.value);\n    showConf(configIndex);\n  });\n\n  // 初始化dnsConf\n  reloadConf(\"{{.DnsConf}}\");\n</script>\n\n<!-- 日志相关函数和日志初始化 -->\n<script>\n  // 获取日志\n  const getLogs = async (loop = false) => {\n    let logsList = [];\n    try {\n      const resp = await request.get(\"./logs\");\n      // 如果不是数组，说明返回的是错误信息\n      if (!Array.isArray(resp)) {\n        throw new Error(resp);\n      }\n      logsList = resp;\n    } catch (err) {\n      showMessage({\n        content: err.toString(),\n        type: \"error\",\n        duration: 5000,\n      });\n      return;\n    } finally {\n      if (loop) {\n        setTimeout(getLogs, 5 * 1000, true);\n      }\n    }\n    const $logs = document.getElementById(\"logs\");\n    // 判断滚动条是否在底部\n    const isBottom = $logs.scrollHeight - $logs.scrollTop - $logs.clientHeight < 10;\n    $logs.value = logsList.join(\"\");\n    // 如果滚动条原先在底部，滚动到底部\n    if (isBottom) {\n      $logs.scrollTop = $logs.scrollHeight;\n    }\n    const oldLogItem = localStorage.getItem(\"logItem\") || \"\";\n    localStorage.setItem(\n      \"logItem\",\n      logsList[logsList.length - 1] || \"\"\n    );\n    // 计算日志更新部分\n    const newLogsList = logsList.slice(\n      // 如果oldLogItem出现多次，使用indexOf会得到第一次出现的位置导致一直判定为有新日志，所以这里使用lastIndexOf\n      logsList.lastIndexOf(oldLogItem) + 1\n    );\n    // 如果日志面板可见或者没有新增日志，则不显示\n    if (\n      !newLogsList.length ||\n      document.getElementById(\"logs-panel\").style.visibility !== \"hidden\"\n    ) {\n      return;\n    }\n    const $logsBtn = document.getElementById(\"logsBtn\");\n    $logsBtn.classList.add(\"unread\");\n    $logsBtn.dataset.title = i18n({\n      \"en\": `${newLogsList.length} new logs`,\n      \"zh-cn\": `新增${newLogsList.length}条日志`,\n    });\n    // 如果新增日志行数小于等于3，则message显示新增日志，否则显示最后2行并提示剩余行数\n    if (newLogsList.length <= 3) {\n      for (const line of newLogsList) {\n        showMessage({\n          type: \"info\",\n          content: line,\n        });\n        await delay(800);\n      }\n    } else {\n      showMessage({\n        type: \"info\",\n        content: i18n({\n          \"en\": `Please check the previous ${newLogsList.length - 2} logs by yourself`,\n          \"zh-cn\": `前${newLogsList.length - 2}条日志请自行查看`,\n        })\n      });\n      for (const line of newLogsList.slice(-2)) {\n        showMessage({\n          type: \"info\",\n          content: line,\n        });\n        await delay(800);\n      }\n    }\n  }\n\n  // 清空日志\n  document.getElementById(\"clearLogBtn\").addEventListener('click', async e => {\n    e.preventDefault();\n    try {\n      await request.get(\"./clearLog\");\n      getLogs();\n    } catch (err) {\n      showMessage({\n        content: err.toString(),\n        type: \"error\",\n        duration: 5000,\n      });\n    }\n  });\n\n  // 显示/隐藏日志面板\n  document.querySelectorAll('#logsBtn, #closeLogBtn, #mask').forEach($el => {\n    $el.addEventListener('click', () => {\n      // 取消未读标记\n      const $logsBtn = document.getElementById(\"logsBtn\");\n      $logsBtn.classList.remove(\"unread\");\n      $logsBtn.dataset.title = \"\"\n      if (document.getElementById(\"logs-panel\").style.visibility === \"hidden\") {\n        document.getElementById(\"logs-panel\").style.visibility = \"\";\n        document.getElementById(\"mask\").style.visibility = \"\";\n      } else {\n        document.getElementById(\"logs-panel\").style.visibility = \"hidden\";\n        document.getElementById(\"mask\").style.visibility = \"hidden\";\n      }\n    });\n  });\n\n  // 页面加载完成后定时获取日志\n  document.addEventListener('DOMContentLoaded', () => getLogs(true));\n</script>\n\n<!-- 主题色相关的函数和初始化 -->\n<script src=\"./static/theme.js\"></script>\n\n<!-- 测试相关 -->\n<script>\n  // 模拟测试webhook\n  document.getElementById(\"webhookTestBtn\").addEventListener('click', async e => {\n    e.preventDefault();\n    try {\n      await request.post(\"./webhookTest\", {\n        URL: globalConf.WebhookURL,\n        RequestBody: globalConf.WebhookRequestBody,\n        Headers: globalConf.WebhookHeaders,\n      });\n      showMessage({\n        content: i18n({\n          \"en\": \"Submit simulation test successfully! The data is fake data, just to test whether the Webhook is normal or not\",\n          \"zh-cn\": \"提交模拟测试成功! 数据为假数据, 只是为了测试Webhook正常与否\",\n        }),\n        type: \"success\",\n      });\n    } catch (err) {\n      showMessage({\n        content: err.toString(),\n        type: \"error\",\n        duration: 5000,\n      });\n    }\n  });\n\n  // 测试正则表达式\n  const $ipv6Reg = document.getElementById(\"Ipv6Reg\");\n  const ipv6RegTooltip = new Tooltip($ipv6Reg, ['manual', 'focus']);\n  // ipv6网卡信息\n  const $ipv6NetInterface = document.getElementById(\"Ipv6NetInterface\");\n  const ipv6Dict = Array.from($ipv6NetInterface.options).reduce((acc, option) => {\n    const ipv6s = option.innerText.match(/([0-9a-fA-F:]{2,})/g);\n    if (ipv6s) {\n      acc[option.value] = ipv6s;\n    }\n    return acc;\n  }, {});\n  $ipv6Reg.addEventListener('input', e => {\n    // 为空时不处理\n    if (!$ipv6Reg.value) {\n      ipv6RegTooltip.hide();\n      return;\n    }\n    const curIpv6s = ipv6Dict[$ipv6NetInterface.value] || [];\n    // 指定第N个ipv6地址\n    const ipv6IndexMatch = $ipv6Reg.value.match(/^@(\\d+)$/);\n    if (ipv6IndexMatch) {\n      const idx = parseInt(ipv6IndexMatch[1]) - 1;\n      if (idx < 0 || idx >= curIpv6s.length) {\n        ipv6RegTooltip.show({\n          title: i18n({\n            \"en\": \"<span style='color: red'>Index out of range</span>\",\n            \"zh-cn\": \"<span style='color: red'>索引超出范围</span>\",\n          }),\n          html: true,\n          placement: \"top\",\n        });\n        return;\n      }\n      ipv6RegTooltip.show({\n        title: i18n({\n          \"en\": `Matched: ${curIpv6s[idx]}`,\n          \"zh-cn\": `匹配到: ${curIpv6s[idx]}`,\n        }),\n        placement: \"top\",\n      });\n      return;\n    }\n\n    // 检测正则表达式是否合法\n    let reg;\n    try {\n      reg = new RegExp($ipv6Reg.value);\n    } catch (err) {\n      ipv6RegTooltip.show({\n        title: i18n({\n          \"en\": \"<span style='color: red'>Invalid regular expression</span>\",\n          \"zh-cn\": \"<span style='color: red'>无效的正则表达式</span>\",\n        }),\n        html: true,\n        placement: \"top\",\n      });\n      return;\n    }\n    // 显示正则表达式的匹配结果\n    for (const ipv6 of curIpv6s) {\n      if (reg.test(ipv6)) {\n        ipv6RegTooltip.show({\n          title: i18n({\n            \"en\": `Matched: ${ipv6}`,\n            \"zh-cn\": `匹配到: ${ipv6}`,\n          }),\n          placement: \"top\",\n        });\n        return;\n      }\n    }\n    ipv6RegTooltip.show({\n      title: i18n({\n        \"en\": \"<span style='color: red'>No match found</span>\",\n        \"zh-cn\": \"<span style='color: red'>无匹配项</span>\",\n      }),\n      html: true,\n      placement: \"top\",\n    });\n  });\n</script>\n\n</html>"
  }
]