├── .github ├── CODEOWNERS ├── renovate.json └── workflows │ └── build.yml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── .pre-commit-config.yaml ├── Dockerfile ├── LICENSE ├── README.md ├── cmd ├── cmd.go ├── envs │ ├── cmd.go │ └── cmd_test.go ├── options.go └── sources │ ├── cmd.go │ └── cmd_test.go ├── docs ├── cloudflare-ddns.md ├── cloudflare-ddns_envs.md └── cloudflare-ddns_sources.md ├── go.mod ├── go.sum ├── goreleaser.Dockerfile ├── internal ├── config │ ├── completions.go │ ├── config.go │ ├── context.go │ ├── flags.go │ ├── load.go │ ├── log.go │ └── validate.go ├── ddns │ └── ddns.go ├── errsgroup │ ├── errsgroup.go │ └── errsgroup_test.go ├── generate │ ├── completions │ │ └── main.go │ ├── docs │ │ └── main.go │ └── manpages │ │ └── main.go ├── lookup │ ├── dns.go │ ├── dns_test.go │ ├── http.go │ ├── http_test.go │ ├── lookup.go │ ├── options.go │ ├── source.go │ └── source_string.go └── output │ └── format.go └── main.go /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @gabe565 2 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>gabe565/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: push 4 | 5 | jobs: 6 | lint: 7 | name: Lint 8 | runs-on: ubuntu-24.04 9 | permissions: 10 | contents: read 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Set up Go 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version-file: go.mod 18 | cache: false 19 | - name: Lint 20 | uses: golangci/golangci-lint-action@v8 21 | 22 | test: 23 | name: Test 24 | runs-on: ubuntu-24.04 25 | permissions: 26 | contents: read 27 | steps: 28 | - name: Checkout 29 | uses: actions/checkout@v4 30 | - name: Set up Go 31 | uses: actions/setup-go@v5 32 | with: 33 | go-version-file: go.mod 34 | - name: Test 35 | run: go test ./... 36 | 37 | build: 38 | name: Build 39 | runs-on: ubuntu-24.04 40 | permissions: 41 | contents: write 42 | packages: write 43 | steps: 44 | - name: Checkout 45 | uses: actions/checkout@v4 46 | with: 47 | fetch-depth: 0 48 | - name: Set up QEMU 49 | uses: docker/setup-qemu-action@v3 50 | - name: Set up Buildx 51 | uses: docker/setup-buildx-action@v3 52 | - name: Login to GitHub Container Registry 53 | uses: docker/login-action@v3 54 | with: 55 | registry: ghcr.io 56 | username: ${{ github.actor }} 57 | password: ${{ github.token }} 58 | - name: Set up Go 59 | uses: actions/setup-go@v5 60 | with: 61 | go-version-file: go.mod 62 | - name: Set build variables 63 | id: vars 64 | run: | 65 | args='release --clean' 66 | if [[ "$GITHUB_REF" != refs/tags/* ]]; then 67 | args+=' --snapshot' 68 | fi 69 | echo "args=$args" >> $GITHUB_OUTPUT 70 | - name: Generate Token 71 | id: app-token 72 | if: startsWith(github.ref, 'refs/tags/') 73 | uses: actions/create-github-app-token@v2 74 | with: 75 | app-id: ${{ secrets.BOT_APP_ID }} 76 | private-key: ${{ secrets.BOT_APP_PRIVATE_KEY }} 77 | repositories: cloudflare-ddns,homebrew-tap 78 | - name: Run GoReleaser 79 | uses: goreleaser/goreleaser-action@v6 80 | with: 81 | version: v2 82 | args: ${{ steps.vars.outputs.args }} 83 | env: 84 | GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} 85 | FURY_TOKEN: ${{ secrets.FURY_TOKEN }} 86 | AUR_SSH_KEY: ${{ secrets.AUR_SSH_KEY }} 87 | - name: Push beta image 88 | if: github.ref_name == 'main' 89 | run: | 90 | export REPO="$(tr '[[:upper:]]' '[[:lower:]]' <<< "ghcr.io/$GITHUB_REPOSITORY")" 91 | IMAGES=() 92 | while read -r SOURCE DEST; do 93 | docker tag "$SOURCE" "$DEST" 94 | docker push "$DEST" 95 | IMAGES+=("$DEST") 96 | done \ 97 | < <(docker image ls --format=json | \ 98 | yq --input-format=json --output-format=tsv ' 99 | select(.Repository == strenv(REPO)) | 100 | [ 101 | .Repository + ":" + .Tag, 102 | .Repository + ":beta-" + (.Tag | sub(".*-", "")) 103 | ] 104 | ') 105 | 106 | docker manifest create "$REPO:beta" "${IMAGES[@]}" 107 | docker manifest push "$REPO:beta" 108 | - uses: actions/upload-artifact@v4 109 | with: 110 | name: dist 111 | path: dist 112 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea/ 3 | /completions/ 4 | /manpages/ 5 | cloudflare-ddns 6 | dist/ 7 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | 3 | issues: 4 | max-same-issues: 50 5 | 6 | formatters: 7 | enable: 8 | - gci 9 | - gofmt 10 | - goimports 11 | - golines 12 | exclusions: 13 | generated: lax 14 | settings: 15 | golines: 16 | max-len: 120 17 | 18 | linters: 19 | enable: 20 | - asasalint 21 | - asciicheck 22 | - bidichk 23 | - bodyclose 24 | - canonicalheader 25 | - copyloopvar 26 | - decorder 27 | - cyclop 28 | - depguard 29 | - dupl 30 | - durationcheck 31 | - err113 32 | - errcheck 33 | - errname 34 | - errorlint 35 | - exptostd 36 | - fatcontext 37 | - forbidigo 38 | - funlen 39 | - gocheckcompilerdirectives 40 | - gochecknoglobals 41 | - gochecknoinits 42 | - gochecksumtype 43 | - gocognit 44 | - goconst 45 | - gocritic 46 | - gocyclo 47 | - godox 48 | - goheader 49 | - godot 50 | - gomoddirectives 51 | - goprintffuncname 52 | - gosec 53 | - importas 54 | - inamedparam 55 | - interfacebloat 56 | - govet 57 | - iface 58 | - ineffassign 59 | - intrange 60 | - ireturn 61 | - loggercheck 62 | - makezero 63 | - mirror 64 | - musttag 65 | - nakedret 66 | - nestif 67 | - nilerr 68 | - nilnesserr 69 | - nilnil 70 | - noctx 71 | - nolintlint 72 | - nonamedreturns 73 | - nosprintfhostport 74 | - perfsprint 75 | - prealloc 76 | - predeclared 77 | - promlinter 78 | - protogetter 79 | - reassign 80 | - recvcheck 81 | - revive 82 | - rowserrcheck 83 | - sloglint 84 | - spancheck 85 | - sqlclosecheck 86 | - staticcheck 87 | - testableexamples 88 | - testifylint 89 | - unconvert 90 | - unparam 91 | - unused 92 | - usestdlibvars 93 | - usetesting 94 | - wastedassign 95 | - whitespace 96 | settings: 97 | cyclop: 98 | max-complexity: 30 99 | depguard: 100 | rules: 101 | "deprecated": 102 | files: 103 | - "$all" 104 | deny: 105 | - pkg: github.com/golang/protobuf 106 | desc: Use google.golang.org/protobuf instead, see https://developers.google.com/protocol-buffers/docs/reference/go/faq#modules 107 | - pkg: github.com/satori/go.uuid 108 | desc: Use github.com/google/uuid instead, satori's package is not maintained 109 | - pkg: github.com/gofrs/uuid$ 110 | desc: Use github.com/gofrs/uuid/v5 or later, it was not a go module before v5 111 | "non-test files": 112 | files: 113 | - "!$test" 114 | deny: 115 | - pkg: math/rand$ 116 | desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2 117 | "non-main files": 118 | files: 119 | - "!**/main.go" 120 | deny: 121 | - pkg: log$ 122 | desc: Use log/slog instead, see https://go.dev/blog/slog 123 | errcheck: 124 | check-type-assertions: true 125 | funlen: 126 | lines: 100 127 | statements: 50 128 | gocritic: 129 | settings: 130 | captLocal: 131 | paramsOnly: false 132 | underef: 133 | skipRecvDeref: false 134 | gosec: 135 | excludes: 136 | - G306 137 | govet: 138 | enable-all: true 139 | disable: 140 | - fieldalignment 141 | - shadow 142 | inamedparam: 143 | skip-single-param: true 144 | nakedret: 145 | max-func-lines: 0 146 | nestif: 147 | min-complexity: 15 148 | nolintlint: 149 | allow-no-explanation: [funlen, gocognit, golines] 150 | require-specific: true 151 | perfsprint: 152 | strconcat: false 153 | usetesting: 154 | os-temp-dir: true 155 | 156 | exclusions: 157 | warn-unused: true 158 | generated: lax 159 | presets: 160 | - comments 161 | - common-false-positives 162 | - legacy 163 | - std-error-handling 164 | rules: 165 | - path: _test\.go 166 | linters: 167 | - dupl 168 | - err113 169 | - errcheck 170 | - funlen 171 | - gocognit 172 | - gocyclo 173 | - gosec 174 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: cloudflare-ddns 3 | before: 4 | hooks: 5 | - go mod download 6 | - go run ./internal/generate/completions --date={{ .CommitDate }} 7 | - go run ./internal/generate/manpages --version={{ .Version }} --date={{ .CommitDate }} 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | flags: 12 | - -trimpath 13 | ldflags: 14 | - -s 15 | - -w 16 | - -X main.version={{ .Version }} 17 | goarch: 18 | - amd64 19 | - arm 20 | - arm64 21 | goarm: 22 | - "7" 23 | ignore: 24 | - goos: windows 25 | goarch: arm 26 | archives: 27 | - formats: tar.gz 28 | # use zip for windows archives 29 | format_overrides: 30 | - goos: windows 31 | formats: zip 32 | files: 33 | - LICENSE 34 | - README.md 35 | - manpages/* 36 | - completions/* 37 | checksum: 38 | name_template: "checksums.txt" 39 | snapshot: 40 | version_template: "{{ incpatch .Version }}-next" 41 | changelog: 42 | sort: asc 43 | filters: 44 | exclude: 45 | - '^docs' 46 | - '^test' 47 | groups: 48 | - title: Features 49 | order: 0 50 | regexp: "(feat)" 51 | - title: Fixes 52 | order: 1 53 | regexp: "(fix|perf)" 54 | - title: Dependencies 55 | order: 999 56 | regexp: '\(deps\):' 57 | - title: Others 58 | order: 998 59 | brews: 60 | - homepage: https://github.com/gabe565/cloudflare-ddns 61 | description: Sync a Cloudflare DNS record with your current public IP address 62 | license: Apache2 63 | repository: 64 | owner: gabe565 65 | name: homebrew-tap 66 | directory: Formula 67 | install: | 68 | bin.install "cloudflare-ddns" 69 | man1.install "manpages/cloudflare-ddns.1.gz" 70 | bash_completion.install "completions/cloudflare-ddns.bash" => "cloudflare-ddns" 71 | zsh_completion.install "completions/cloudflare-ddns.zsh" => "_cloudflare-ddns" 72 | fish_completion.install "completions/cloudflare-ddns.fish" 73 | nfpms: 74 | - id: packages 75 | vendor: Gabe Cook 76 | homepage: https://github.com/gabe565/cloudflare-ddns 77 | description: Sync a Cloudflare DNS record with your current public IP address 78 | license: Apache2 79 | maintainer: Gabe Cook 80 | formats: 81 | - deb 82 | - rpm 83 | contents: 84 | - src: ./manpages/ 85 | dst: /usr/share/man/man1 86 | file_info: 87 | mode: 0644 88 | - src: ./completions/cloudflare-ddns.bash 89 | dst: /usr/share/bash-completion/completions/cloudflare-ddns 90 | file_info: 91 | mode: 0644 92 | - src: ./completions/cloudflare-ddns.fish 93 | dst: /usr/share/fish/vendor_completions.d/cloudflare-ddns.fish 94 | file_info: 95 | mode: 0644 96 | - src: ./completions/cloudflare-ddns.zsh 97 | dst: /usr/share/zsh/vendor-completions/_cloudflare-ddns 98 | file_info: 99 | mode: 0644 100 | publishers: 101 | - name: fury.io 102 | ids: 103 | - packages 104 | dir: "{{ dir .ArtifactPath }}" 105 | cmd: curl -sf -Fpackage=@{{ .ArtifactName }} https://{{ .Env.FURY_TOKEN }}@push.fury.io/gabe565/ 106 | aurs: 107 | - name: cloudflare-ddns-bin 108 | homepage: https://github.com/gabe565/cloudflare-ddns 109 | description: Sync a Cloudflare DNS record with your current public IP address 110 | maintainers: 111 | - Gabe Cook 112 | license: Apache2 113 | private_key: '{{ .Env.AUR_SSH_KEY }}' 114 | git_url: ssh://aur@aur.archlinux.org/cloudflare-ddns-bin.git 115 | skip_upload: auto 116 | package: |- 117 | # bin 118 | install -Dm755 "./cloudflare-ddns" "${pkgdir}/usr/bin/cloudflare-ddns" 119 | # license 120 | install -Dm644 "./LICENSE" "${pkgdir}/usr/share/licenses/cloudflare-ddns/LICENSE" 121 | # man pages 122 | install -Dm644 "./manpages/cloudflare-ddns.1.gz" "${pkgdir}/usr/share/man/man1/cloudflare-ddns.1.gz" 123 | # completions 124 | install -Dm644 "./completions/cloudflare-ddns.bash" "${pkgdir}/usr/share/bash-completion/completions/cloudflare-ddns" 125 | install -Dm644 "./completions/cloudflare-ddns.zsh" "${pkgdir}/usr/share/zsh/site-functions/_cloudflare-ddns" 126 | install -Dm644 "./completions/cloudflare-ddns.fish" "${pkgdir}/usr/share/fish/vendor_completions.d/cloudflare-ddns.fish" 127 | commit_author: 128 | name: goreleaserbot 129 | email: bot@goreleaser.com 130 | dockers: 131 | - &dockers 132 | image_templates: 133 | - ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }}-amd64 134 | use: buildx 135 | dockerfile: goreleaser.Dockerfile 136 | build_flag_templates: 137 | - "--platform=linux/amd64" 138 | - <<: *dockers 139 | image_templates: 140 | - ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }}-armv7 141 | goarch: arm 142 | goarm: 7 143 | build_flag_templates: 144 | - "--platform=linux/arm/v7" 145 | - <<: *dockers 146 | image_templates: 147 | - ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }}-arm64v8 148 | goarch: arm64 149 | build_flag_templates: 150 | - "--platform=linux/arm64/v8" 151 | docker_manifests: 152 | - &docker_manifests 153 | name_template: ghcr.io/gabe565/{{ .ProjectName }}:latest 154 | image_templates: 155 | - ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }}-amd64 156 | - ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }}-armv7 157 | - ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }}-arm64v8 158 | - <<: *docker_manifests 159 | name_template: ghcr.io/gabe565/{{ .ProjectName }}:{{ .Version }} 160 | - <<: *docker_manifests 161 | name_template: ghcr.io/gabe565/{{ .ProjectName }}:{{ .Major }} 162 | - <<: *docker_manifests 163 | name_template: ghcr.io/gabe565/{{ .ProjectName }}:{{ .Major }}.{{ .Minor }} 164 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/TekWizely/pre-commit-golang 3 | rev: v1.0.0-rc.1 4 | hooks: 5 | - id: go-mod-tidy-repo 6 | - id: golangci-lint-mod 7 | args: [--fix] 8 | 9 | - repo: local 10 | hooks: 11 | - id: usage-docs 12 | name: usage-docs 13 | entry: go run ./internal/generate/docs 14 | language: system 15 | pass_filenames: false 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #syntax=docker/dockerfile:1 2 | 3 | FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.6.1 AS xx 4 | 5 | FROM --platform=$BUILDPLATFORM golang:1.24.2-alpine AS build 6 | WORKDIR /app 7 | 8 | COPY --from=xx / / 9 | 10 | COPY go.mod go.sum ./ 11 | RUN go mod download 12 | 13 | COPY . . 14 | 15 | ARG TARGETPLATFORM 16 | RUN --mount=type=cache,target=/root/.cache \ 17 | CGO_ENABLED=0 xx-go build -ldflags='-w -s' -trimpath 18 | 19 | 20 | FROM gcr.io/distroless/static:nonroot 21 | COPY --from=build /app/cloudflare-ddns / 22 | ENTRYPOINT ["/cloudflare-ddns"] 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare DDNS 2 | [![GitHub release (latest by date)](https://img.shields.io/github/v/release/gabe565/cloudflare-ddns)](https://github.com/gabe565/cloudflare-ddns/releases) 3 | [![Build](https://github.com/gabe565/cloudflare-ddns/actions/workflows/build.yml/badge.svg)](https://github.com/gabe565/cloudflare-ddns/actions/workflows/build.yml) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/gabe565/cloudflare-ddns)](https://goreportcard.com/report/github.com/gabe565/cloudflare-ddns) 5 | 6 | Cloudflare DDNS is a command-line dynamic DNS tool that keeps Cloudflare DNS records in sync with your public IP address. 7 | 8 | ## Features 9 | - **Multiple IP Sources:** Tries multiple sources in order for fetching your public IP: 10 | - Cloudflare DNS (TLS) (using `whoami.cloudflare`) 11 | - OpenDNS (TLS) (using `myip.opendns.com`) 12 | - [icanhazip.com](https://icanhazip.com) 13 | - [ipinfo.io](https://ipinfo.io) 14 | - [ipify.org](https://ipify.org) 15 | - Cloudflare DNS (using `whoami.cloudflare`) 16 | - OpenDNS (using `myip.opendns.com`) 17 | - **Future Compatibility:** Supports managing A and AAAA records. 18 | - **Flexible Usage:** Run as a one-off command for use with cron/systemd/Kubernetes, or as a daemon with a configurable update interval. 19 | - **Simple Authentication:** Use either `CF_API_TOKEN` or `CF_API_KEY` with `CF_API_EMAIL` to securely connect to the Cloudflare API. 20 | 21 | ## Installation 22 | 23 | ### APT (Ubuntu, Debian) 24 | 25 |
26 | Click to expand 27 | 28 | 1. If you don't have it already, install the `ca-certificates` package 29 | ```shell 30 | sudo apt install ca-certificates 31 | ``` 32 | 33 | 2. Add gabe565 apt repository 34 | ``` 35 | echo 'deb [trusted=yes] https://apt.gabe565.com /' | sudo tee /etc/apt/sources.list.d/gabe565.list 36 | ``` 37 | 38 | 3. Update apt repositories 39 | ```shell 40 | sudo apt update 41 | ``` 42 | 43 | 4. Install cloudflare-ddns 44 | ```shell 45 | sudo apt install cloudflare-ddns 46 | ``` 47 |
48 | 49 | ### RPM (CentOS, RHEL) 50 | 51 |
52 | Click to expand 53 | 54 | 1. If you don't have it already, install the `ca-certificates` package 55 | ```shell 56 | sudo dnf install ca-certificates 57 | ``` 58 | 59 | 2. Add gabe565 rpm repository to `/etc/yum.repos.d/gabe565.repo` 60 | ```ini 61 | [gabe565] 62 | name=gabe565 63 | baseurl=https://rpm.gabe565.com 64 | enabled=1 65 | gpgcheck=0 66 | ``` 67 | 68 | 3. Install cloudflare-ddns 69 | ```shell 70 | sudo dnf install cloudflare-ddns 71 | ``` 72 |
73 | 74 | ### AUR (Arch Linux) 75 | 76 |
77 | Click to expand 78 | 79 | Install [cloudflare-ddns-bin](https://aur.archlinux.org/packages/cloudflare-ddns-bin) with your [AUR helper](https://wiki.archlinux.org/index.php/AUR_helpers) of choice. 80 |
81 | 82 | ### Homebrew (macOS, Linux) 83 | 84 |
85 | Click to expand 86 | 87 | Install cloudflare-ddns from [gabe565/homebrew-tap](https://github.com/gabe565/homebrew-tap): 88 | ```shell 89 | brew install gabe565/tap/cloudflare-ddns 90 | ``` 91 |
92 | 93 | ### Docker 94 | 95 |
96 | Click to expand 97 | 98 | A Docker image is available at [`ghcr.io/gabe565/cloudflare-ddns`](https://ghcr.io/gabe565/cloudflare-ddns) 99 |
100 | 101 | 102 | ### Manual Installation 103 | 104 |
105 | Click to expand 106 | 107 | Download and run the [latest release binary](https://github.com/gabe565/cloudflare-ddns/releases/latest) for your system and architecture. 108 |
109 | 110 | ## Usage 111 | To authenticate to the Cloudflare API, set either `CF_API_TOKEN` or `CF_API_KEY` and `CF_API_EMAIL`. 112 | 113 | ### One-Off Mode 114 | Runs once, then exits. Useful for crontab, systemd timers, or Kubernetes CronJobs. 115 | 116 | #### Local 117 | ```shell 118 | export CF_API_TOKEN=token 119 | cloudflare-ddns example.com 120 | ``` 121 | 122 | #### Docker 123 | ```shell 124 | docker run --rm -it \ 125 | -e CF_API_TOKEN=token \ 126 | ghcr.io/gabe565/cloudflare-ddns \ 127 | example.com 128 | ``` 129 | 130 | ### Daemon Mode 131 | Runs continuously, updating the DNS record every specified interval. 132 | 133 | #### Local 134 | ```shell 135 | export CF_API_TOKEN=token 136 | cloudflare-ddns example.com --interval=10m 137 | ``` 138 | 139 | #### Docker 140 | ```shell 141 | docker run --rm -d --restart=always \ 142 | -e CF_API_TOKEN=token \ 143 | ghcr.io/gabe565/cloudflare-ddns \ 144 | example.com --interval=10m 145 | ``` 146 | 147 | ### Full Reference 148 | - [Command line usage](docs/cloudflare-ddns.md) 149 | - [Environment variables](docs/cloudflare-ddns_envs.md) 150 | - [Public IP Sources](docs/cloudflare-ddns_sources.md) 151 | -------------------------------------------------------------------------------- /cmd/cmd.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | "os/signal" 8 | "syscall" 9 | "time" 10 | 11 | "gabe565.com/cloudflare-ddns/cmd/envs" 12 | "gabe565.com/cloudflare-ddns/cmd/sources" 13 | "gabe565.com/cloudflare-ddns/internal/config" 14 | "gabe565.com/cloudflare-ddns/internal/ddns" 15 | "gabe565.com/utils/cobrax" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | func New(opts ...cobrax.Option) *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: "cloudflare-ddns", 22 | Short: "Sync a Cloudflare DNS record with your current public IP address", 23 | RunE: run, 24 | 25 | // Fixes unknown command error due to help subcommands 26 | Args: func(_ *cobra.Command, _ []string) error { 27 | return nil 28 | }, 29 | 30 | ValidArgsFunction: config.CompleteDomain, 31 | SilenceErrors: true, 32 | DisableAutoGenTag: true, 33 | } 34 | cmd.AddCommand( 35 | envs.New(), 36 | sources.New(), 37 | ) 38 | 39 | conf := config.New() 40 | conf.RegisterFlags(cmd) 41 | conf.RegisterCompletions(cmd) 42 | 43 | ctx := cmd.Context() 44 | if ctx == nil { 45 | ctx = context.Background() 46 | } 47 | cmd.SetContext(config.NewContext(ctx, conf)) 48 | 49 | for _, opt := range opts { 50 | opt(cmd) 51 | } 52 | return cmd 53 | } 54 | 55 | func run(cmd *cobra.Command, args []string) error { 56 | conf, err := config.Load(cmd, args) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | if err := conf.Validate(); err != nil { 62 | return err 63 | } 64 | 65 | cmd.SilenceUsage = true 66 | 67 | if conf.DryRun { 68 | slog.Warn("Running in dry run mode") 69 | } 70 | 71 | if conf.Interval != 0 { 72 | slog.Info("Cloudflare DDNS", "version", cobrax.GetVersion(cmd), "commit", cobrax.GetCommit(cmd)) 73 | } 74 | 75 | ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM, syscall.SIGQUIT) 76 | defer cancel() 77 | 78 | if err := ddns.NewUpdater(conf).Update(ctx); err != nil { 79 | return err 80 | } 81 | 82 | if conf.Interval != 0 { 83 | ticker := time.NewTicker(conf.Interval) 84 | defer ticker.Stop() 85 | for { 86 | select { 87 | case <-ctx.Done(): 88 | return ctx.Err() 89 | case <-ticker.C: 90 | if err := ddns.NewUpdater(conf).Update(ctx); err != nil { 91 | slog.Error("Run failed", "error", err) 92 | } 93 | } 94 | } 95 | } 96 | 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /cmd/envs/cmd.go: -------------------------------------------------------------------------------- 1 | package envs 2 | 3 | import ( 4 | "cmp" 5 | "io" 6 | "slices" 7 | "strings" 8 | 9 | "gabe565.com/cloudflare-ddns/internal/config" 10 | "gabe565.com/cloudflare-ddns/internal/output" 11 | "github.com/charmbracelet/lipgloss" 12 | "github.com/charmbracelet/lipgloss/table" 13 | "github.com/spf13/cobra" 14 | "github.com/spf13/pflag" 15 | ) 16 | 17 | const Name = "envs" 18 | 19 | func New() *cobra.Command { 20 | cmd := &cobra.Command{ 21 | Use: Name, 22 | Short: "Environment variable reference", 23 | 24 | ValidArgsFunction: cobra.NoFileCompletions, 25 | } 26 | cmd.SetHelpFunc(helpFunc) 27 | return cmd 28 | } 29 | 30 | func helpFunc(cmd *cobra.Command, _ []string) { 31 | format, _ := output.FromContext(cmd.Context()) 32 | 33 | var result strings.Builder 34 | if format == output.FormatMarkdown { 35 | result.WriteString("# Environment Variables\n\n") 36 | } else { 37 | result.WriteString("Environment Variables\n\n") 38 | } 39 | 40 | t := table.New(). 41 | Headers("Name", "Usage", "Default") 42 | 43 | pad := lipgloss.NewStyle().Padding(0, 1) 44 | if format == output.FormatMarkdown { 45 | t.Border(lipgloss.MarkdownBorder()). 46 | BorderTop(false). 47 | BorderBottom(false). 48 | StyleFunc(func(int, int) lipgloss.Style { 49 | return pad 50 | }) 51 | } else { 52 | bold := pad.Bold(true) 53 | italic := pad.Italic(true) 54 | t.StyleFunc(func(row, col int) lipgloss.Style { 55 | switch { 56 | case col == 0, row == -1: 57 | return bold 58 | case col == 2: 59 | return italic 60 | default: 61 | return pad 62 | } 63 | }) 64 | } 65 | 66 | root := cmd.Root() 67 | excludeNames := []string{"completion", "help", "version"} 68 | var rows [][]string 69 | root.Flags().VisitAll(func(flag *pflag.Flag) { 70 | if slices.Contains(excludeNames, flag.Name) { 71 | return 72 | } 73 | 74 | var value string 75 | switch fv := flag.Value.(type) { 76 | case pflag.SliceValue: 77 | value = strings.Join(fv.GetSlice(), ",") 78 | default: 79 | value = flag.Value.String() 80 | } 81 | 82 | if format == output.FormatMarkdown { 83 | if value == "" { 84 | value = " " 85 | } 86 | rows = append(rows, []string{"`" + config.EnvName(flag.Name) + "`", flag.Usage, "`" + value + "`"}) 87 | } else { 88 | rows = append(rows, []string{config.EnvName(flag.Name), flag.Usage, value}) 89 | } 90 | }) 91 | slices.SortFunc(rows, func(a, b []string) int { 92 | return cmp.Compare(a[0], b[0]) 93 | }) 94 | t.Rows(rows...) 95 | 96 | result.WriteString(t.Render()) 97 | result.WriteByte('\n') 98 | _, _ = io.WriteString(cmd.OutOrStdout(), result.String()) 99 | } 100 | -------------------------------------------------------------------------------- /cmd/envs/cmd_test.go: -------------------------------------------------------------------------------- 1 | package envs 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "gabe565.com/cloudflare-ddns/internal/config" 8 | "gabe565.com/cloudflare-ddns/internal/output" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestEnvs(t *testing.T) { 14 | cmd := New() 15 | conf := config.New() 16 | conf.RegisterFlags(cmd) 17 | 18 | t.Run("ansi", func(t *testing.T) { 19 | cmd.SetContext(output.NewContext(t.Context(), output.FormatANSI)) 20 | var buf strings.Builder 21 | cmd.SetOut(&buf) 22 | require.NoError(t, cmd.Execute()) 23 | assert.NotEmpty(t, buf.String()) 24 | }) 25 | 26 | t.Run("markdown", func(t *testing.T) { 27 | cmd.SetContext(output.NewContext(t.Context(), output.FormatMarkdown)) 28 | var buf strings.Builder 29 | cmd.SetOut(&buf) 30 | require.NoError(t, cmd.Execute()) 31 | assert.NotEmpty(t, buf.String()) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /cmd/options.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | 6 | "gabe565.com/cloudflare-ddns/internal/output" 7 | "gabe565.com/utils/cobrax" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func WithMarkdown() cobrax.Option { 12 | return func(cmd *cobra.Command) { 13 | ctx := cmd.Context() 14 | if ctx == nil { 15 | ctx = context.Background() 16 | } 17 | cmd.SetContext(output.NewContext(ctx, output.FormatMarkdown)) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /cmd/sources/cmd.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "io" 5 | "strings" 6 | 7 | "gabe565.com/cloudflare-ddns/internal/lookup" 8 | "gabe565.com/cloudflare-ddns/internal/output" 9 | "github.com/charmbracelet/lipgloss" 10 | "github.com/charmbracelet/lipgloss/table" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | const Name = "sources" 15 | 16 | func New() *cobra.Command { 17 | cmd := &cobra.Command{ 18 | Use: Name, 19 | Short: "Public IP source reference", 20 | 21 | ValidArgsFunction: cobra.NoFileCompletions, 22 | } 23 | cmd.SetHelpFunc(helpFunc) 24 | return cmd 25 | } 26 | 27 | func helpFunc(cmd *cobra.Command, _ []string) { 28 | format, _ := output.FromContext(cmd.Context()) 29 | 30 | italic := lipgloss.NewStyle().Italic(true).Render 31 | var result strings.Builder 32 | if format == output.FormatMarkdown { 33 | result.WriteString( 34 | "# Public IP Sources\n\nThe `--source` flag lets you define which sources are used to get your public IP address.\n\n" + 35 | "## Available Sources\n\n", 36 | ) 37 | } else { 38 | result.WriteString("The " + italic("--source") + " flag lets you define which sources are used to get your public IP address.\n\n" + 39 | "Available Sources:\n") 40 | } 41 | 42 | t := table.New(). 43 | Headers("Name", "Description") 44 | 45 | pad := lipgloss.NewStyle().Padding(0, 1) 46 | if format == output.FormatMarkdown { 47 | t.Border(lipgloss.MarkdownBorder()). 48 | BorderTop(false). 49 | BorderBottom(false). 50 | StyleFunc(func(int, int) lipgloss.Style { 51 | return pad 52 | }) 53 | } else { 54 | bold := pad.Bold(true) 55 | t.StyleFunc(func(row, col int) lipgloss.Style { 56 | switch { 57 | case col == 0, row == -1: 58 | return bold 59 | default: 60 | return pad 61 | } 62 | }) 63 | } 64 | 65 | sources := lookup.SourceValues() 66 | 67 | for _, v := range sources { 68 | if format == output.FormatMarkdown { 69 | t.Row("`"+v.String()+"`", v.Description(output.FormatMarkdown)) 70 | } else { 71 | t.Row(v.String(), v.Description(output.FormatANSI)) 72 | } 73 | } 74 | 75 | result.WriteString(t.Render()) 76 | result.WriteByte('\n') 77 | _, _ = io.WriteString(cmd.OutOrStdout(), result.String()) 78 | } 79 | -------------------------------------------------------------------------------- /cmd/sources/cmd_test.go: -------------------------------------------------------------------------------- 1 | package sources 2 | 3 | import ( 4 | "strings" 5 | "testing" 6 | 7 | "gabe565.com/cloudflare-ddns/internal/config" 8 | "gabe565.com/cloudflare-ddns/internal/output" 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestSources(t *testing.T) { 14 | cmd := New() 15 | conf := config.New() 16 | conf.RegisterFlags(cmd) 17 | 18 | t.Run("ansi", func(t *testing.T) { 19 | cmd.SetContext(output.NewContext(t.Context(), output.FormatANSI)) 20 | var buf strings.Builder 21 | cmd.SetOut(&buf) 22 | require.NoError(t, cmd.Execute()) 23 | assert.NotEmpty(t, buf.String()) 24 | }) 25 | 26 | t.Run("markdown", func(t *testing.T) { 27 | cmd.SetContext(output.NewContext(t.Context(), output.FormatMarkdown)) 28 | var buf strings.Builder 29 | cmd.SetOut(&buf) 30 | require.NoError(t, cmd.Execute()) 31 | assert.NotEmpty(t, buf.String()) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /docs/cloudflare-ddns.md: -------------------------------------------------------------------------------- 1 | ## cloudflare-ddns 2 | 3 | Sync a Cloudflare DNS record with your current public IP address 4 | 5 | ``` 6 | cloudflare-ddns [flags] 7 | ``` 8 | 9 | ### Options 10 | 11 | ``` 12 | --cf-account-id string Cloudflare account ID 13 | --cf-api-email string Cloudflare account email address 14 | --cf-api-key string Cloudflare API key 15 | --cf-api-token string Cloudflare API token (recommended) 16 | --dns-tcp Force DNS to use TCP 17 | -d, --domain strings Domains to manage 18 | -n, --dry-run Runs without changing any records 19 | -h, --help help for cloudflare-ddns 20 | -i, --interval duration Update interval 21 | -4, --ipv4 Enables A records (default true) 22 | -6, --ipv6 Enables AAAA records 23 | --log-format string Log format (one of auto, color, plain, json) (default "auto") 24 | --log-level string Log level (one of trace, debug, info, warn, error) (default "info") 25 | -p, --proxied Enables Cloudflare proxy for the record 26 | -s, --source strings Enabled IP sources (see cloudflare-ddns sources) (default [cloudflare_tls,opendns_tls,icanhazip,ipinfo,ipify,cloudflare,opendns]) 27 | --timeout duration Maximum length of time that an update may take (default 1m0s) 28 | -t, --ttl float DNS record TTL (default auto) 29 | -v, --version version for cloudflare-ddns 30 | ``` 31 | 32 | ### SEE ALSO 33 | * [cloudflare-ddns envs](cloudflare-ddns_envs.md) - Environment variable reference 34 | * [cloudflare-ddns sources](cloudflare-ddns_sources.md) - Public IP source reference 35 | -------------------------------------------------------------------------------- /docs/cloudflare-ddns_envs.md: -------------------------------------------------------------------------------- 1 | # Environment Variables 2 | 3 | | Name | Usage | Default | 4 | |-------------------|----------------------------------------------------|------------------------------------------------------------------------| 5 | | `CF_ACCOUNT_ID` | Cloudflare account ID | ` ` | 6 | | `CF_API_EMAIL` | Cloudflare account email address | ` ` | 7 | | `CF_API_KEY` | Cloudflare API key | ` ` | 8 | | `CF_API_TOKEN` | Cloudflare API token (recommended) | ` ` | 9 | | `DDNS_DNS_TCP` | Force DNS to use TCP | `false` | 10 | | `DDNS_DOMAINS` | Domains to manage | ` ` | 11 | | `DDNS_DRY_RUN` | Runs without changing any records | `false` | 12 | | `DDNS_INTERVAL` | Update interval | `0s` | 13 | | `DDNS_IPV4` | Enables A records | `true` | 14 | | `DDNS_IPV6` | Enables AAAA records | `false` | 15 | | `DDNS_LOG_FORMAT` | Log format (one of auto, color, plain, json) | `auto` | 16 | | `DDNS_LOG_LEVEL` | Log level (one of trace, debug, info, warn, error) | `info` | 17 | | `DDNS_PROXIED` | Enables Cloudflare proxy for the record | `false` | 18 | | `DDNS_SOURCES` | Enabled IP sources (see cloudflare-ddns sources) | `cloudflare_tls,opendns_tls,icanhazip,ipinfo,ipify,cloudflare,opendns` | 19 | | `DDNS_TIMEOUT` | Maximum length of time that an update may take | `1m0s` | 20 | | `DDNS_TTL` | DNS record TTL (default auto) | `0` | 21 | 22 | ### SEE ALSO 23 | * [cloudflare-ddns](cloudflare-ddns.md) - Sync a Cloudflare DNS record with your current public IP address 24 | * [cloudflare-ddns sources](cloudflare-ddns_sources.md) - Public IP source reference 25 | -------------------------------------------------------------------------------- /docs/cloudflare-ddns_sources.md: -------------------------------------------------------------------------------- 1 | # Public IP Sources 2 | 3 | The `--source` flag lets you define which sources are used to get your public IP address. 4 | 5 | ## Available Sources 6 | 7 | | Name | Description | 8 | |------------------|----------------------------------------------------------------------------------------| 9 | | `cloudflare_tls` | Queries `whoami.cloudflare` using DNS-over-TLS via `one.one.one.one:853`. | 10 | | `cloudflare` | Queries `whoami.cloudflare` using DNS via `one.one.one.one:53`. | 11 | | `opendns_tls` | Queries `myip.opendns.com` using DNS-over-TLS via `dns.opendns.com:853`. | 12 | | `opendns` | Queries `myip.opendns.com` using DNS via `dns.opendns.com:53`. | 13 | | `icanhazip` | Makes HTTPS requests to `https://ipv4.icanhazip.com` and `https://ipv6.icanhazip.com`. | 14 | | `ipinfo` | Makes HTTPS requests to `https://ipinfo.io/ip` and `https://v6.ipinfo.io/ip`. | 15 | | `ipify` | Makes HTTPS requests to `https://api.ipify.org` and `https://api6.ipify.org`. | 16 | 17 | ### SEE ALSO 18 | * [cloudflare-ddns](cloudflare-ddns.md) - Sync a Cloudflare DNS record with your current public IP address 19 | * [cloudflare-ddns envs](cloudflare-ddns_envs.md) - Environment variable reference 20 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module gabe565.com/cloudflare-ddns 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | gabe565.com/utils v0.0.0-20250302063333-ede73b14282c 7 | github.com/charmbracelet/lipgloss v1.1.0 8 | github.com/cloudflare/cloudflare-go/v4 v4.3.0 9 | github.com/lmittmann/tint v1.0.7 10 | github.com/miekg/dns v1.1.65 11 | github.com/spf13/cobra v1.9.2-0.20250311125636-ceb39aba25c8 12 | github.com/spf13/pflag v1.0.6 13 | github.com/stretchr/testify v1.10.0 14 | ) 15 | 16 | require ( 17 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 18 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect 19 | github.com/charmbracelet/x/ansi v0.8.0 // indirect 20 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect 21 | github.com/charmbracelet/x/term v0.2.1 // indirect 22 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 23 | github.com/davecgh/go-spew v1.1.1 // indirect 24 | github.com/dmarkham/enumer v1.5.11 // indirect 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 26 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 27 | github.com/mattn/go-isatty v0.0.20 // indirect 28 | github.com/mattn/go-runewidth v0.0.16 // indirect 29 | github.com/muesli/termenv v0.16.0 // indirect 30 | github.com/pascaldekloe/name v1.0.0 // indirect 31 | github.com/pmezard/go-difflib v1.0.0 // indirect 32 | github.com/rivo/uniseg v0.4.7 // indirect 33 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 34 | github.com/tidwall/gjson v1.18.0 // indirect 35 | github.com/tidwall/match v1.1.1 // indirect 36 | github.com/tidwall/pretty v1.2.1 // indirect 37 | github.com/tidwall/sjson v1.2.5 // indirect 38 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 39 | golang.org/x/mod v0.24.0 // indirect 40 | golang.org/x/net v0.38.0 // indirect 41 | golang.org/x/sync v0.12.0 // indirect 42 | golang.org/x/sys v0.31.0 // indirect 43 | golang.org/x/tools v0.31.0 // indirect 44 | gopkg.in/yaml.v3 v3.0.1 // indirect 45 | ) 46 | 47 | tool github.com/dmarkham/enumer 48 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | gabe565.com/utils v0.0.0-20250302063333-ede73b14282c h1:740ifPX0340EHhAPnr5PJ5FgpDFMX3EEmd8CMLcSi5I= 2 | gabe565.com/utils v0.0.0-20250302063333-ede73b14282c/go.mod h1:zPhcEoKWZOPz7CH+g3nzkKY/AOs+QEo9SokKKkJyq2U= 3 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= 4 | github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= 5 | github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= 6 | github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= 7 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= 8 | github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= 9 | github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= 10 | github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= 11 | github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= 12 | github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= 13 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= 14 | github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= 15 | github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= 16 | github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= 17 | github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= 18 | github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= 19 | github.com/cloudflare/cloudflare-go/v4 v4.3.0 h1:AG/Aq6u96GCvVqEOiPSc8OqCYJnfzwA1HeRv74YBOZI= 20 | github.com/cloudflare/cloudflare-go/v4 v4.3.0/go.mod h1:XcYpLe7Mf6FN87kXzEWVnJ6z+vskW/k6eUqgqfhFE9k= 21 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 22 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 23 | github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= 24 | github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= 25 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 26 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 27 | github.com/dmarkham/enumer v1.5.11 h1:quorLCaEfzjJ23Pf7PB9lyyaHseh91YfTM/sAD/4Mbo= 28 | github.com/dmarkham/enumer v1.5.11/go.mod h1:yixql+kDDQRYqcuBM2n9Vlt7NoT9ixgXhaXry8vmRg8= 29 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 30 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 31 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 32 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 33 | github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= 34 | github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= 35 | github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= 36 | github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= 37 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 38 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 39 | github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= 40 | github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 41 | github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc= 42 | github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck= 43 | github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= 44 | github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= 45 | github.com/pascaldekloe/name v1.0.0 h1:n7LKFgHixETzxpRv2R77YgPUFo85QHGZKrdaYm7eY5U= 46 | github.com/pascaldekloe/name v1.0.0/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= 47 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 48 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 49 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 50 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 51 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 52 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 53 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 54 | github.com/spf13/cobra v1.9.2-0.20250311125636-ceb39aba25c8 h1:txBNS6+OnzZ5yKMp7u+fofoR/EiWNgEVzx6Wf0bWe8c= 55 | github.com/spf13/cobra v1.9.2-0.20250311125636-ceb39aba25c8/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= 56 | github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= 57 | github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 58 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 59 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 60 | github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 61 | github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= 62 | github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= 63 | github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= 64 | github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= 65 | github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 66 | github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= 67 | github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= 68 | github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= 69 | github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= 70 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= 71 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= 72 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= 73 | golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= 74 | golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= 75 | golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= 76 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 77 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 78 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 79 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 80 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 81 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 82 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 83 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= 84 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= 85 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 86 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 87 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 88 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 89 | -------------------------------------------------------------------------------- /goreleaser.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static:nonroot 2 | LABEL org.opencontainers.image.source="https://github.com/gabe565/cloudflare-ddns" 3 | COPY cloudflare-ddns / 4 | ENTRYPOINT ["/cloudflare-ddns"] 5 | -------------------------------------------------------------------------------- /internal/config/completions.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "log/slog" 5 | "slices" 6 | "strings" 7 | 8 | "gabe565.com/cloudflare-ddns/internal/lookup" 9 | "gabe565.com/utils/must" 10 | "gabe565.com/utils/slogx" 11 | "github.com/cloudflare/cloudflare-go/v4" 12 | "github.com/cloudflare/cloudflare-go/v4/accounts" 13 | "github.com/spf13/cobra" 14 | ) 15 | 16 | func (c *Config) RegisterCompletions(cmd *cobra.Command) { 17 | must.Must(cmd.RegisterFlagCompletionFunc( 18 | FlagLogLevel, 19 | func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 20 | return slogx.LevelStrings(), cobra.ShellCompDirectiveNoFileComp 21 | }, 22 | )) 23 | must.Must(cmd.RegisterFlagCompletionFunc( 24 | FlagLogFormat, 25 | func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 26 | return slogx.FormatStrings(), cobra.ShellCompDirectiveNoFileComp 27 | }, 28 | )) 29 | 30 | must.Must(cmd.RegisterFlagCompletionFunc( 31 | FlagSource, 32 | func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 33 | return lookup.SourceStrings(), cobra.ShellCompDirectiveNoFileComp 34 | }, 35 | )) 36 | must.Must(cmd.RegisterFlagCompletionFunc(FlagDomain, CompleteDomain)) 37 | must.Must(cmd.RegisterFlagCompletionFunc( 38 | FlagInterval, 39 | func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 40 | return []string{ 41 | "1m", 42 | "15m", 43 | "1h", 44 | "24h", 45 | }, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder 46 | }, 47 | )) 48 | must.Must(cmd.RegisterFlagCompletionFunc(FlagDNSUseTCP, completeBool)) 49 | must.Must(cmd.RegisterFlagCompletionFunc(FlagProxied, completeBool)) 50 | must.Must(cmd.RegisterFlagCompletionFunc( 51 | FlagTTL, 52 | func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 53 | return []string{ 54 | "0\tauto", 55 | "5m", 56 | "1h", 57 | }, cobra.ShellCompDirectiveNoFileComp | cobra.ShellCompDirectiveKeepOrder 58 | }, 59 | )) 60 | must.Must(cmd.RegisterFlagCompletionFunc( 61 | FlagTimeout, 62 | func(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 63 | return []string{"0\tno timeout", "30s", "1m"}, cobra.ShellCompDirectiveNoFileComp 64 | }, 65 | )) 66 | 67 | must.Must(cmd.RegisterFlagCompletionFunc(FlagCloudflareAccountID, completeAccount)) 68 | must.Must(cmd.RegisterFlagCompletionFunc(FlagCloudflareToken, cobra.NoFileCompletions)) 69 | must.Must(cmd.RegisterFlagCompletionFunc(FlagCloudflareKey, cobra.NoFileCompletions)) 70 | must.Must(cmd.RegisterFlagCompletionFunc(FlagCloudflareEmail, cobra.NoFileCompletions)) 71 | } 72 | 73 | func completeBool(_ *cobra.Command, _ []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 74 | return []string{"true", "false"}, cobra.ShellCompDirectiveNoFileComp 75 | } 76 | 77 | func setupCompletion(cmd *cobra.Command, args []string) (*Config, *cloudflare.Client, error) { 78 | conf, err := Load(cmd, args) 79 | if err != nil { 80 | slog.Error("Failed to load config", "error", err) 81 | return nil, nil, err 82 | } 83 | 84 | client, err := conf.NewCloudflareClient() 85 | if err != nil { 86 | slog.Error("Failed to create Cloudflare client", "error", err) 87 | return nil, nil, err 88 | } 89 | 90 | return conf, client, nil 91 | } 92 | 93 | func CompleteDomain( 94 | cmd *cobra.Command, 95 | args []string, 96 | toComplete string, 97 | ) ([]cobra.Completion, cobra.ShellCompDirective) { 98 | conf, client, err := setupCompletion(cmd, args) 99 | if err != nil { 100 | return nil, cobra.ShellCompDirectiveError 101 | } 102 | 103 | var comps []string 104 | iter := client.Zones.ListAutoPaging(cmd.Context(), conf.CloudflareZoneListParams()) 105 | for iter.Next() { 106 | zone := iter.Current().Name 107 | 108 | if toComplete != "" && strings.HasSuffix(toComplete, zone) { 109 | return []string{toComplete}, cobra.ShellCompDirectiveNoFileComp 110 | } 111 | 112 | comps = append(comps, zone) 113 | } 114 | if err := iter.Err(); err != nil { 115 | slog.Error("Failed to list zones", "error", err) 116 | return nil, cobra.ShellCompDirectiveError 117 | } 118 | 119 | overlaps := make([]string, 0, len(comps)) 120 | if toComplete != "" { 121 | // Finds zones that overlap with toComplete. For example: 122 | // `home.ex` would overlap with `example.com`, resulting in `home.example.com`. 123 | for _, zone := range comps { 124 | prefix := toComplete 125 | for { 126 | if strings.HasPrefix(zone, prefix) { 127 | overlaps = append(overlaps, strings.TrimPrefix(zone, prefix)) 128 | break 129 | } 130 | 131 | i := strings.Index(prefix, ".") 132 | if i == -1 { 133 | break 134 | } 135 | 136 | prefix = prefix[i+1:] 137 | } 138 | } 139 | } 140 | 141 | if len(overlaps) == 0 { 142 | // No overlaps were found, assume toComplete is a subdomain 143 | if toComplete != "" && !strings.HasSuffix(toComplete, ".") { 144 | toComplete += "." 145 | } 146 | } else { 147 | comps = overlaps 148 | } 149 | 150 | for i, zone := range comps { 151 | comps[i] = toComplete + zone 152 | } 153 | 154 | if len(conf.Domains) != 0 { 155 | // Remove already configured domains from the list 156 | comps = slices.DeleteFunc(comps, func(s string) bool { 157 | return slices.Contains(conf.Domains, s) 158 | }) 159 | } 160 | 161 | return comps, cobra.ShellCompDirectiveNoFileComp 162 | } 163 | 164 | func completeAccount(cmd *cobra.Command, args []string, _ string) ([]cobra.Completion, cobra.ShellCompDirective) { 165 | _, client, err := setupCompletion(cmd, args) 166 | if err != nil { 167 | return nil, cobra.ShellCompDirectiveError 168 | } 169 | 170 | var names []string 171 | iter := client.Accounts.ListAutoPaging(cmd.Context(), accounts.AccountListParams{}) 172 | for iter.Next() { 173 | account := iter.Current() 174 | names = append(names, account.ID+"\t"+account.Name) 175 | } 176 | if err := iter.Err(); err != nil { 177 | slog.Error("Failed to list accounts", "error", err) 178 | return nil, cobra.ShellCompDirectiveError 179 | } 180 | 181 | return names, cobra.ShellCompDirectiveNoFileComp 182 | } 183 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "time" 5 | 6 | "gabe565.com/cloudflare-ddns/internal/lookup" 7 | "gabe565.com/utils/slogx" 8 | "github.com/cloudflare/cloudflare-go/v4" 9 | "github.com/cloudflare/cloudflare-go/v4/option" 10 | "github.com/cloudflare/cloudflare-go/v4/zones" 11 | ) 12 | 13 | type Config struct { 14 | LogLevel slogx.Level 15 | LogFormat slogx.Format 16 | 17 | SourceStrs []string 18 | UseV4 bool 19 | UseV6 bool 20 | Domains []string 21 | Interval time.Duration 22 | DNSUseTCP bool 23 | Proxied bool 24 | TTL float64 25 | Timeout time.Duration 26 | DryRun bool 27 | 28 | CloudflareToken string 29 | CloudflareKey string 30 | CloudflareEmail string 31 | CloudflareAccountID string 32 | } 33 | 34 | func New() *Config { 35 | return &Config{ 36 | UseV4: true, 37 | Timeout: time.Minute, 38 | SourceStrs: []string{ 39 | lookup.CloudflareTLS.String(), 40 | lookup.OpenDNSTLS.String(), 41 | lookup.ICanHazIP.String(), 42 | lookup.IPInfo.String(), 43 | lookup.IPify.String(), 44 | lookup.Cloudflare.String(), 45 | lookup.OpenDNS.String(), 46 | }, 47 | } 48 | } 49 | 50 | func (c *Config) NewCloudflareClient() (*cloudflare.Client, error) { 51 | var opts []option.RequestOption 52 | switch { 53 | case c.CloudflareToken != "": 54 | opts = append(opts, option.WithAPIToken(c.CloudflareToken)) 55 | case c.CloudflareEmail != "" && c.CloudflareKey != "": 56 | opts = append(opts, 57 | option.WithAPIEmail(c.CloudflareEmail), 58 | option.WithAPIKey(c.CloudflareKey), 59 | ) 60 | } 61 | 62 | return cloudflare.NewClient(opts...), nil 63 | } 64 | 65 | func (c *Config) CloudflareZoneListParams() zones.ZoneListParams { 66 | var params zones.ZoneListParams 67 | if c.CloudflareAccountID != "" { 68 | params.Account = cloudflare.F(zones.ZoneListParamsAccount{ 69 | ID: cloudflare.F(c.CloudflareAccountID), 70 | }) 71 | } 72 | return params 73 | } 74 | 75 | func (c *Config) Sources() ([]lookup.Source, error) { 76 | s := make([]lookup.Source, 0, len(c.SourceStrs)) 77 | for _, str := range c.SourceStrs { 78 | source, err := lookup.SourceString(str) 79 | if err != nil { 80 | return nil, err 81 | } 82 | s = append(s, source) 83 | } 84 | return s, nil 85 | } 86 | -------------------------------------------------------------------------------- /internal/config/context.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "context" 4 | 5 | type ctxKey uint8 6 | 7 | const configKey ctxKey = iota 8 | 9 | func NewContext(ctx context.Context, conf *Config) context.Context { 10 | return context.WithValue(ctx, configKey, conf) 11 | } 12 | 13 | func FromContext(ctx context.Context) (*Config, bool) { 14 | conf, ok := ctx.Value(configKey).(*Config) 15 | return conf, ok 16 | } 17 | -------------------------------------------------------------------------------- /internal/config/flags.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "strings" 5 | 6 | "gabe565.com/utils/slogx" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | const ( 11 | FlagLogLevel = "log-level" 12 | FlagLogFormat = "log-format" 13 | 14 | FlagSource = "source" 15 | FlagIPV4 = "ipv4" 16 | FlagIPV6 = "ipv6" 17 | FlagDomain = "domain" 18 | FlagInterval = "interval" 19 | FlagDNSUseTCP = "dns-tcp" 20 | FlagProxied = "proxied" 21 | FlagTTL = "ttl" 22 | FlagTimeout = "timeout" 23 | FlagDryRun = "dry-run" 24 | 25 | FlagCloudflareToken = "cf-api-token" //nolint:gosec 26 | FlagCloudflareKey = "cf-api-key" 27 | FlagCloudflareEmail = "cf-api-email" 28 | FlagCloudflareAccountID = "cf-account-id" 29 | ) 30 | 31 | func (c *Config) RegisterFlags(cmd *cobra.Command) { 32 | fs := cmd.Flags() 33 | 34 | fs.Var(&c.LogLevel, FlagLogLevel, "Log level (one of "+strings.Join(slogx.LevelStrings(), ", ")+")") 35 | fs.Var(&c.LogFormat, FlagLogFormat, "Log format (one of "+strings.Join(slogx.FormatStrings(), ", ")+")") 36 | 37 | fs.StringSliceVarP(&c.SourceStrs, FlagSource, "s", c.SourceStrs, "Enabled IP sources (see cloudflare-ddns sources)") 38 | fs.BoolVarP(&c.UseV4, FlagIPV4, "4", c.UseV4, "Enables A records") 39 | fs.BoolVarP(&c.UseV6, FlagIPV6, "6", c.UseV6, "Enables AAAA records") 40 | fs.StringSliceVarP(&c.Domains, FlagDomain, "d", c.Domains, "Domains to manage") 41 | fs.DurationVarP(&c.Interval, FlagInterval, "i", c.Interval, "Update interval") 42 | fs.BoolVarP(&c.Proxied, FlagProxied, "p", c.Proxied, "Enables Cloudflare proxy for the record") 43 | fs.Float64VarP(&c.TTL, FlagTTL, "t", c.TTL, "DNS record TTL (default auto)") 44 | fs.BoolVar(&c.DNSUseTCP, FlagDNSUseTCP, c.DNSUseTCP, "Force DNS to use TCP") 45 | fs.DurationVar(&c.Timeout, FlagTimeout, c.Timeout, "Maximum length of time that an update may take") 46 | fs.BoolVarP(&c.DryRun, FlagDryRun, "n", c.DryRun, "Runs without changing any records") 47 | 48 | fs.StringVar(&c.CloudflareToken, FlagCloudflareToken, c.CloudflareToken, "Cloudflare API token (recommended)") 49 | fs.StringVar(&c.CloudflareKey, FlagCloudflareKey, c.CloudflareKey, "Cloudflare API key") 50 | fs.StringVar(&c.CloudflareEmail, FlagCloudflareEmail, c.CloudflareEmail, "Cloudflare account email address") 51 | fs.StringVar(&c.CloudflareAccountID, FlagCloudflareAccountID, c.CloudflareAccountID, "Cloudflare account ID") 52 | } 53 | -------------------------------------------------------------------------------- /internal/config/load.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "strings" 7 | 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | func Load(cmd *cobra.Command, domains []string) (*Config, error) { 13 | conf, ok := FromContext(cmd.Context()) 14 | if !ok { 15 | panic("command missing config") 16 | } 17 | 18 | var errs []error 19 | cmd.Flags().VisitAll(func(f *pflag.Flag) { 20 | if !f.Changed { 21 | if val, ok := os.LookupEnv(EnvName(f.Name)); ok { 22 | if err := f.Value.Set(val); err != nil { 23 | errs = append(errs, err) 24 | } 25 | } 26 | } 27 | }) 28 | if err := errors.Join(errs...); err != nil { 29 | return nil, err 30 | } 31 | 32 | if len(domains) != 0 { 33 | conf.Domains = domains 34 | } 35 | 36 | conf.InitLog(cmd.ErrOrStderr()) 37 | 38 | return conf, nil 39 | } 40 | 41 | const EnvPrefix = "DDNS_" 42 | 43 | func EnvName(name string) string { 44 | switch name { 45 | case FlagCloudflareToken: 46 | return "CF_API_TOKEN" 47 | case FlagCloudflareKey: 48 | return "CF_API_KEY" 49 | case FlagCloudflareEmail: 50 | return "CF_API_EMAIL" 51 | case FlagCloudflareAccountID: 52 | return "CF_ACCOUNT_ID" 53 | case FlagSource, FlagDomain: 54 | name += "s" 55 | } 56 | name = strings.ToUpper(name) 57 | name = strings.ReplaceAll(name, "-", "_") 58 | return EnvPrefix + name 59 | } 60 | -------------------------------------------------------------------------------- /internal/config/log.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "io" 5 | "log/slog" 6 | "time" 7 | 8 | "gabe565.com/utils/slogx" 9 | "gabe565.com/utils/termx" 10 | "github.com/lmittmann/tint" 11 | ) 12 | 13 | func (c *Config) InitLog(w io.Writer) { 14 | InitLog(w, c.LogLevel, c.LogFormat) 15 | } 16 | 17 | func InitLog(w io.Writer, level slogx.Level, format slogx.Format) { 18 | switch format { 19 | case slogx.FormatJSON: 20 | slog.SetDefault(slog.New(slog.NewJSONHandler(w, &slog.HandlerOptions{ 21 | Level: level.Level(), 22 | }))) 23 | default: 24 | var color bool 25 | switch format { 26 | case slogx.FormatAuto: 27 | color = termx.IsColor(w) 28 | case slogx.FormatColor: 29 | color = true 30 | } 31 | 32 | slog.SetDefault(slog.New( 33 | tint.NewHandler(w, &tint.Options{ 34 | Level: level.Level(), 35 | TimeFormat: time.DateTime, 36 | NoColor: !color, 37 | }), 38 | )) 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/config/validate.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "gabe565.com/cloudflare-ddns/internal/lookup" 8 | ) 9 | 10 | var ( 11 | ErrInvalidSource = errors.New("invalid source") 12 | ErrNoProto = errors.New("either v4 or v6 must be enabled") 13 | ErrCloudflareAuth = errors.New("missing Cloudflare auth") 14 | ErrNoDomain = errors.New("at least one domain must be provided") 15 | ) 16 | 17 | func (c *Config) Validate() error { 18 | for _, sourceStr := range c.SourceStrs { 19 | if _, err := lookup.SourceString(sourceStr); err != nil { 20 | return fmt.Errorf("%w: %s", ErrInvalidSource, sourceStr) 21 | } 22 | } 23 | 24 | switch { 25 | case len(c.Domains) == 0: 26 | return ErrNoDomain 27 | case !c.UseV4 && !c.UseV6: 28 | return ErrNoProto 29 | case c.CloudflareToken == "" && c.CloudflareKey == "": 30 | return fmt.Errorf("%w: CF_API_KEY or CF_API_TOKEN is required", ErrCloudflareAuth) 31 | case c.CloudflareKey != "" && c.CloudflareEmail == "": 32 | return fmt.Errorf("%w: CF_API_EMAIL is required", ErrCloudflareAuth) 33 | } 34 | 35 | return nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/ddns/ddns.go: -------------------------------------------------------------------------------- 1 | package ddns 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "strings" 9 | "time" 10 | 11 | "gabe565.com/cloudflare-ddns/internal/config" 12 | "gabe565.com/cloudflare-ddns/internal/errsgroup" 13 | "gabe565.com/cloudflare-ddns/internal/lookup" 14 | "github.com/cloudflare/cloudflare-go/v4" 15 | "github.com/cloudflare/cloudflare-go/v4/dns" 16 | "github.com/cloudflare/cloudflare-go/v4/zones" 17 | ) 18 | 19 | func NewUpdater(conf *config.Config) Updater { 20 | return Updater{conf: conf} 21 | } 22 | 23 | type Updater struct { 24 | conf *config.Config 25 | client *cloudflare.Client 26 | } 27 | 28 | func (u Updater) Update(ctx context.Context) error { 29 | start := time.Now() 30 | 31 | if u.conf.Timeout != 0 { 32 | var cancel context.CancelFunc 33 | ctx, cancel = context.WithTimeout(ctx, u.conf.Timeout) 34 | defer cancel() 35 | } 36 | 37 | publicIP, err := u.getPublicIP(ctx) 38 | if err != nil { 39 | return err 40 | } 41 | 42 | if u.client, err = u.conf.NewCloudflareClient(); err != nil { 43 | return err 44 | } 45 | 46 | var group errsgroup.Group 47 | 48 | for _, domain := range u.conf.Domains { 49 | group.Go(func() error { 50 | return u.updateDomain(ctx, domain, publicIP) 51 | }) 52 | } 53 | 54 | if err := group.Wait(); err != nil { 55 | slog.Debug("Update failed", "took", time.Since(start), "error", err) 56 | return err 57 | } 58 | 59 | slog.Debug("Update complete", "took", time.Since(start)) 60 | return nil 61 | } 62 | 63 | func (u Updater) getPublicIP(ctx context.Context) (lookup.Response, error) { 64 | sources, err := u.conf.Sources() 65 | if err != nil { 66 | return lookup.Response{}, err 67 | } 68 | 69 | lookupClient := lookup.NewClient( 70 | lookup.WithV4(u.conf.UseV4), 71 | lookup.WithV6(u.conf.UseV6), 72 | lookup.WithForceTCP(u.conf.DNSUseTCP), 73 | lookup.WithSources(sources...), 74 | ) 75 | 76 | publicIP, err := lookupClient.GetPublicIP(ctx) 77 | if err != nil { 78 | return lookup.Response{}, err 79 | } 80 | 81 | slog.Debug("Got public IP", "ip", publicIP) 82 | return publicIP, nil 83 | } 84 | 85 | func (u Updater) updateDomain(ctx context.Context, domain string, ip lookup.Response) error { 86 | zone, err := u.FindZone(ctx, u.conf.CloudflareZoneListParams(), domain) 87 | if err != nil { 88 | return err 89 | } 90 | 91 | v4, v6, err := u.GetRecords(ctx, zone, domain) 92 | if err != nil && !errors.Is(err, ErrRecordNotFound) { 93 | return err 94 | } 95 | 96 | var group errsgroup.Group 97 | 98 | if u.conf.UseV4 { 99 | group.Go(func() error { 100 | return u.updateRecord(ctx, zone, dns.RecordTypeA, v4, domain, ip.IPV4) 101 | }) 102 | } 103 | 104 | if u.conf.UseV6 { 105 | group.Go(func() error { 106 | return u.updateRecord(ctx, zone, dns.RecordTypeAAAA, v6, domain, ip.IPV6) 107 | }) 108 | } 109 | 110 | return group.Wait() 111 | } 112 | 113 | func (u Updater) updateRecord( 114 | ctx context.Context, 115 | zone *zones.Zone, 116 | recordType dns.RecordType, 117 | record *dns.RecordResponse, 118 | domain, content string, 119 | ) error { 120 | log := slog.With("type", recordType, "domain", domain) 121 | switch { 122 | case record == nil: 123 | log.Info("Creating record", "content", content) 124 | if !u.conf.DryRun { 125 | _, err := u.client.DNS.Records.New(ctx, dns.RecordNewParams{ 126 | ZoneID: cloudflare.F(zone.ID), 127 | Record: newRecordParam(recordType, domain, content, u.conf.Proxied, dns.TTL(u.conf.TTL)), 128 | }) 129 | return err 130 | } 131 | case record.Content != content: 132 | log.Info("Updating record", "previous", record.Content, "content", content) 133 | if !u.conf.DryRun { 134 | _, err := u.client.DNS.Records.Update(ctx, record.ID, dns.RecordUpdateParams{ 135 | ZoneID: cloudflare.F(zone.ID), 136 | Record: newRecordParam(recordType, domain, content, u.conf.Proxied, dns.TTL(u.conf.TTL)), 137 | }) 138 | return err 139 | } 140 | default: 141 | log.Info("Record up to date", "content", record.Content) 142 | } 143 | return nil 144 | } 145 | 146 | var ErrZoneNotFound = errors.New("zone not found") 147 | 148 | func (u Updater) FindZone(ctx context.Context, params zones.ZoneListParams, domain string) (*zones.Zone, error) { 149 | iter := u.client.Zones.ListAutoPaging(ctx, params) 150 | for iter.Next() { 151 | v := iter.Current() 152 | if domain == v.Name || strings.HasSuffix(domain, "."+v.Name) { 153 | slog.Debug("Found zone", "name", v.Name, "id", v.ID) 154 | return &v, nil 155 | } 156 | } 157 | if iter.Err() != nil { 158 | return nil, iter.Err() 159 | } 160 | return nil, fmt.Errorf("%w for domain %s", ErrZoneNotFound, domain) 161 | } 162 | 163 | var ( 164 | ErrRecordNotFound = errors.New("record not found") 165 | ErrUnsupportedRecordType = errors.New("unsupported record type") 166 | ) 167 | 168 | func (u Updater) GetRecords( 169 | ctx context.Context, 170 | zone *zones.Zone, 171 | domain string, 172 | ) (*dns.RecordResponse, *dns.RecordResponse, error) { 173 | iter := u.client.DNS.Records.ListAutoPaging(ctx, dns.RecordListParams{ 174 | ZoneID: cloudflare.F(zone.ID), 175 | Name: cloudflare.F(dns.RecordListParamsName{ 176 | Exact: cloudflare.F(domain), 177 | }), 178 | }) 179 | var v4, v6 *dns.RecordResponse 180 | for iter.Next() { 181 | v := iter.Current() 182 | switch v.Type { 183 | case dns.RecordResponseTypeA: 184 | slog.Debug("Found A record", "name", v.Name, "type", v.Type, "id", v.ID, "content", v.Content) 185 | v4 = &v 186 | case dns.RecordResponseTypeAAAA: 187 | slog.Debug("Found AAAA record", "name", v.Name, "type", v.Type, "id", v.ID, "content", v.Content) 188 | v6 = &v 189 | case dns.RecordResponseTypeCNAME: 190 | return nil, nil, fmt.Errorf("%w: %s", ErrUnsupportedRecordType, v.Type) 191 | } 192 | } 193 | if iter.Err() != nil { 194 | return nil, nil, iter.Err() 195 | } 196 | return v4, v6, fmt.Errorf("%w: %s", ErrRecordNotFound, domain) 197 | } 198 | 199 | func newRecordParam(recordType dns.RecordType, domain, content string, proxied bool, ttl dns.TTL) dns.RecordParam { 200 | if ttl == 0 { 201 | ttl = dns.TTL1 202 | } 203 | return dns.RecordParam{ 204 | Comment: cloudflare.F("DDNS record managed by gabe565/cloudflare-ddns"), 205 | Content: cloudflare.F(content), 206 | Name: cloudflare.F(domain), 207 | Proxied: cloudflare.F(proxied), 208 | TTL: cloudflare.F(ttl), 209 | Type: cloudflare.F(recordType), 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /internal/errsgroup/errsgroup.go: -------------------------------------------------------------------------------- 1 | package errsgroup 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | ) 7 | 8 | type Group struct { 9 | wg sync.WaitGroup 10 | errs []error 11 | mu sync.Mutex 12 | } 13 | 14 | func (g *Group) Go(f func() error) { 15 | g.wg.Add(1) 16 | go func() { 17 | defer g.wg.Done() 18 | if err := f(); err != nil { 19 | g.mu.Lock() 20 | g.errs = append(g.errs, err) 21 | g.mu.Unlock() 22 | } 23 | }() 24 | } 25 | 26 | func (g *Group) Wait() error { 27 | g.wg.Wait() 28 | return errors.Join(g.errs...) 29 | } 30 | -------------------------------------------------------------------------------- /internal/errsgroup/errsgroup_test.go: -------------------------------------------------------------------------------- 1 | package errsgroup 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestGroup(t *testing.T) { 13 | t.Run("no error", func(t *testing.T) { 14 | var group Group 15 | group.Go(func() error { 16 | return nil 17 | }) 18 | require.NoError(t, group.Wait()) 19 | }) 20 | 21 | t.Run("1 error", func(t *testing.T) { 22 | var group Group 23 | group.Go(func() error { 24 | return errors.New("some error") 25 | }) 26 | err := group.Wait() 27 | require.Error(t, err) 28 | assert.Equal(t, "some error", err.Error()) 29 | }) 30 | 31 | t.Run("2 errors", func(t *testing.T) { 32 | var group Group 33 | group.Go(func() error { 34 | return errors.New("some error") 35 | }) 36 | group.Go(func() error { 37 | return errors.New("another error") 38 | }) 39 | err := group.Wait() 40 | require.Error(t, err) 41 | assert.Contains(t, err.Error(), "some error") 42 | assert.Contains(t, err.Error(), "another error") 43 | }) 44 | 45 | t.Run("waits", func(t *testing.T) { 46 | var group Group 47 | var n int 48 | group.Go(func() error { 49 | time.Sleep(time.Millisecond) 50 | n++ 51 | return nil 52 | }) 53 | require.NoError(t, group.Wait()) 54 | assert.Equal(t, 1, n) 55 | }) 56 | } 57 | -------------------------------------------------------------------------------- /internal/generate/completions/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | "gabe565.com/cloudflare-ddns/cmd" 11 | flag "github.com/spf13/pflag" 12 | ) 13 | 14 | func main() { 15 | flags := flag.NewFlagSet("", flag.ContinueOnError) 16 | 17 | var dateParam string 18 | flags.StringVar(&dateParam, "date", time.Now().Format(time.RFC3339), "Build date") 19 | 20 | if err := flags.Parse(os.Args); err != nil { 21 | panic(err) 22 | } 23 | 24 | date, err := time.Parse(time.RFC3339, dateParam) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | if err := os.RemoveAll("completions"); err != nil { 30 | panic(err) 31 | } 32 | 33 | if err := os.MkdirAll("completions", 0o777); err != nil { 34 | panic(err) 35 | } 36 | 37 | rootCmd := cmd.New() 38 | name := rootCmd.Name() 39 | var buf bytes.Buffer 40 | rootCmd.SetOut(&buf) 41 | 42 | for _, shell := range []string{"bash", "zsh", "fish"} { 43 | rootCmd.SetArgs([]string{"completion", shell}) 44 | if err := rootCmd.Execute(); err != nil { 45 | panic(err) 46 | } 47 | 48 | f, err := os.Create(filepath.Join("completions", name+"."+shell)) 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | if _, err := io.Copy(f, &buf); err != nil { 54 | panic(err) 55 | } 56 | 57 | if err := f.Close(); err != nil { 58 | panic(err) 59 | } 60 | 61 | if err := os.Chtimes(f.Name(), date, date); err != nil { 62 | panic(err) 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /internal/generate/docs/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | "os" 9 | "path/filepath" 10 | 11 | "gabe565.com/cloudflare-ddns/cmd" 12 | "gabe565.com/cloudflare-ddns/cmd/envs" 13 | "gabe565.com/cloudflare-ddns/cmd/sources" 14 | "gabe565.com/utils/cobrax" 15 | "github.com/spf13/cobra" 16 | "github.com/spf13/cobra/doc" 17 | ) 18 | 19 | func main() { 20 | output := "./docs" 21 | 22 | if err := os.RemoveAll(output); err != nil { 23 | slog.Error("failed to remove existing dir", "error", err) 24 | os.Exit(1) 25 | } 26 | 27 | if err := os.MkdirAll(output, 0o755); err != nil { 28 | slog.Error("failed to mkdir", "error", err) 29 | os.Exit(1) 30 | } 31 | 32 | root := cmd.New(cobrax.WithVersion("beta"), cmd.WithMarkdown()) 33 | 34 | if err := errors.Join( 35 | generateFlagDoc(root, filepath.Join(output, root.Name()+".md")), 36 | generateEnvDoc(root, filepath.Join(output, root.Name()+"_envs.md")), 37 | generateSourcesDoc(root, filepath.Join(output, root.Name()+"_sources.md")), 38 | ); err != nil { 39 | slog.Error(err.Error()) 40 | os.Exit(1) 41 | } 42 | } 43 | 44 | func generateFlagDoc(cmd *cobra.Command, output string) error { 45 | var buf bytes.Buffer 46 | if err := doc.GenMarkdown(cmd, &buf); err != nil { 47 | return fmt.Errorf("failed to generate markdown: %w", err) 48 | } 49 | 50 | buf.WriteString("### SEE ALSO\n") 51 | addSeeAlso(&buf, cmd, cmd.Commands()...) 52 | 53 | return os.WriteFile(output, buf.Bytes(), 0o600) 54 | } 55 | 56 | func generateEnvDoc(cmd *cobra.Command, output string) error { 57 | var buf bytes.Buffer 58 | cmd.SetOut(&buf) 59 | cmd.SetArgs([]string{envs.Name}) 60 | if err := cmd.Execute(); err != nil { 61 | return err 62 | } 63 | 64 | sourcesCmd, _, err := cmd.Find([]string{sources.Name}) 65 | if err != nil { 66 | panic(err) 67 | } 68 | 69 | buf.WriteString("\n### SEE ALSO\n") 70 | addSeeAlso(&buf, cmd, cmd, sourcesCmd) 71 | 72 | return os.WriteFile(output, buf.Bytes(), 0o600) 73 | } 74 | 75 | func generateSourcesDoc(cmd *cobra.Command, output string) error { 76 | var buf bytes.Buffer 77 | cmd.SetOut(&buf) 78 | cmd.SetArgs([]string{sources.Name}) 79 | if err := cmd.Execute(); err != nil { 80 | return err 81 | } 82 | 83 | envCmd, _, err := cmd.Find([]string{envs.Name}) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | buf.WriteString("\n### SEE ALSO\n") 89 | addSeeAlso(&buf, cmd, cmd, envCmd) 90 | 91 | return os.WriteFile(output, buf.Bytes(), 0o600) 92 | } 93 | 94 | func addSeeAlso(buf *bytes.Buffer, cmd *cobra.Command, cmds ...*cobra.Command) { 95 | for _, subcmd := range cmds { 96 | if subcmd.Name() == "help" { 97 | continue 98 | } 99 | if cmd.Name() == subcmd.Name() { 100 | fmt.Fprintf(buf, "* [%s](%s.md) - %s\n", subcmd.Name(), subcmd.Name(), subcmd.Short) 101 | } else { 102 | fmt.Fprintf(buf, "* [%s %s](%s_%s.md) - %s\n", cmd.Name(), subcmd.Name(), cmd.Name(), subcmd.Name(), subcmd.Short) 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /internal/generate/manpages/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "io/fs" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "gabe565.com/cloudflare-ddns/cmd" 13 | "github.com/spf13/cobra/doc" 14 | flag "github.com/spf13/pflag" 15 | ) 16 | 17 | func main() { 18 | flags := flag.NewFlagSet("", flag.ContinueOnError) 19 | 20 | var version string 21 | flags.StringVar(&version, "version", "beta", "Version") 22 | 23 | var dateParam string 24 | flags.StringVar(&dateParam, "date", time.Now().Format(time.RFC3339), "Build date") 25 | 26 | if err := flags.Parse(os.Args); err != nil { 27 | panic(err) 28 | } 29 | 30 | if err := os.RemoveAll("manpages"); err != nil { 31 | panic(err) 32 | } 33 | 34 | if err := os.MkdirAll("manpages", 0o755); err != nil { 35 | panic(err) 36 | } 37 | 38 | rootCmd := cmd.New() 39 | rootName := rootCmd.Name() 40 | 41 | date, err := time.Parse(time.RFC3339, dateParam) 42 | if err != nil { 43 | panic(err) 44 | } 45 | 46 | header := doc.GenManHeader{ 47 | Title: strings.ToUpper(rootName), 48 | Section: "1", 49 | Date: &date, 50 | Source: rootName + " " + version, 51 | Manual: "User Commands", 52 | } 53 | 54 | if err := doc.GenManTree(rootCmd, &header, "manpages"); err != nil { 55 | panic(err) 56 | } 57 | 58 | if err := filepath.Walk("manpages", func(path string, info fs.FileInfo, err error) error { 59 | if err != nil || info.IsDir() { 60 | return err 61 | } 62 | 63 | in, err := os.Open(path) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | out, err := os.Create(path + ".gz") 69 | if err != nil { 70 | return err 71 | } 72 | gz := gzip.NewWriter(out) 73 | 74 | if _, err := io.Copy(gz, in); err != nil { 75 | return err 76 | } 77 | 78 | if err := in.Close(); err != nil { 79 | return err 80 | } 81 | if err := os.Remove(path); err != nil { 82 | return err 83 | } 84 | 85 | if err := gz.Close(); err != nil { 86 | return err 87 | } 88 | if err := out.Close(); err != nil { 89 | return err 90 | } 91 | 92 | return nil 93 | }); err != nil { 94 | panic(err) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /internal/lookup/dns.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "gabe565.com/cloudflare-ddns/internal/errsgroup" 9 | "gabe565.com/utils/slogx" 10 | "github.com/miekg/dns" 11 | ) 12 | 13 | const ( 14 | tcp4 = "tcp4" 15 | tcp6 = "tcp6" 16 | ) 17 | 18 | var ErrNoDNSAnswer = errors.New("no DNS answer") 19 | 20 | func DNS(ctx context.Context, server string, tcp, ipv6, tls bool, question dns.Question) (string, error) { 21 | start := time.Now() 22 | c := &dns.Client{} 23 | if ipv6 { 24 | switch { 25 | case tls: 26 | c.Net = tcp6 + "-tls" 27 | case tcp: 28 | c.Net = tcp6 29 | default: 30 | c.Net = "udp6" 31 | } 32 | } else { 33 | switch { 34 | case tls: 35 | c.Net = tcp4 + "-tls" 36 | case tcp: 37 | c.Net = tcp4 38 | default: 39 | c.Net = "udp4" 40 | } 41 | } 42 | m := &dns.Msg{Question: []dns.Question{question}} 43 | 44 | slogx.Trace("DNS query", 45 | "server", server, 46 | "net", c.Net, 47 | "name", question.Name, 48 | "type", dns.TypeToString[question.Qtype], 49 | "class", dns.ClassToString[question.Qclass], 50 | ) 51 | 52 | res, _, err := c.ExchangeContext(ctx, m, server) 53 | if err != nil { 54 | return "", err 55 | } 56 | 57 | slogx.Trace("DNS response", "took", time.Since(start), "server", server, "response", res) 58 | 59 | if len(res.Answer) == 0 { 60 | return "", ErrNoDNSAnswer 61 | } 62 | 63 | var val string 64 | switch answer := res.Answer[0].(type) { 65 | case *dns.A: 66 | val = answer.A.String() 67 | case *dns.AAAA: 68 | val = answer.AAAA.String() 69 | case *dns.TXT: 70 | if len(answer.Txt) == 0 { 71 | return "", ErrNoDNSAnswer 72 | } 73 | val = answer.Txt[0] 74 | } 75 | return val, nil 76 | } 77 | 78 | func (c *Client) DNSv4v6(ctx context.Context, req DNSv4v6) (Response, error) { 79 | var response Response 80 | var group errsgroup.Group 81 | 82 | if c.v4 { 83 | group.Go(func() error { 84 | var err error 85 | response.IPV4, err = DNS(ctx, req.ServerV4, c.tcp, false, req.TLS, req.QuestionV4) 86 | return err 87 | }) 88 | } 89 | 90 | if c.v6 { 91 | group.Go(func() error { 92 | var err error 93 | response.IPV6, err = DNS(ctx, req.ServerV6, c.tcp, true, req.TLS, req.QuestionV6) 94 | return err 95 | }) 96 | } 97 | 98 | err := group.Wait() 99 | return response, err 100 | } 101 | -------------------------------------------------------------------------------- /internal/lookup/dns_test.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "net" 5 | "testing" 6 | 7 | "github.com/miekg/dns" 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | const ( 13 | domain = "test." 14 | cfV4 = "1.1.1.1" 15 | cfV6 = "2606:4700:4700::1111" 16 | ) 17 | 18 | func newDNSServer(t *testing.T, network string) string { 19 | mux := dns.NewServeMux() 20 | mux.HandleFunc(domain, func(w dns.ResponseWriter, r *dns.Msg) { 21 | m := &dns.Msg{} 22 | m.SetReply(r) 23 | switch r.Question[0].Qtype { 24 | case dns.TypeA: 25 | m.Answer = append(m.Answer, &dns.A{ 26 | Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeA, Class: dns.ClassINET}, 27 | A: net.ParseIP(cfV4), 28 | }) 29 | case dns.TypeAAAA: 30 | m.Answer = append(m.Answer, &dns.AAAA{ 31 | Hdr: dns.RR_Header{Name: m.Question[0].Name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET}, 32 | AAAA: net.ParseIP(cfV6), 33 | }) 34 | } 35 | err := w.WriteMsg(m) 36 | require.NoError(t, err) 37 | }) 38 | 39 | addr := "127.0.0.1:0" 40 | if network == tcp6 { 41 | addr = "[::1]:0" 42 | } 43 | 44 | ready := make(chan struct{}) 45 | server := dns.Server{ 46 | Addr: addr, 47 | Net: network, 48 | Handler: mux, 49 | NotifyStartedFunc: func() { close(ready) }, 50 | } 51 | t.Cleanup(func() { 52 | _ = server.Shutdown() 53 | }) 54 | 55 | go func() { 56 | assert.NoError(t, server.ListenAndServe()) 57 | }() 58 | 59 | select { 60 | case <-t.Context().Done(): 61 | return "" 62 | case <-ready: 63 | return server.Listener.Addr().String() 64 | } 65 | } 66 | 67 | func Test_DNS(t *testing.T) { 68 | t.Run("v4", func(t *testing.T) { 69 | got, err := DNS(t.Context(), newDNSServer(t, tcp4), true, false, false, dns.Question{ 70 | Name: domain, 71 | Qtype: dns.TypeA, 72 | Qclass: dns.ClassINET, 73 | }) 74 | require.NoError(t, err) 75 | assert.Equal(t, cfV4, got) 76 | }) 77 | 78 | t.Run("v6", func(t *testing.T) { 79 | got, err := DNS(t.Context(), newDNSServer(t, tcp6), true, true, false, dns.Question{ 80 | Name: domain, 81 | Qtype: dns.TypeAAAA, 82 | Qclass: dns.ClassINET, 83 | }) 84 | require.NoError(t, err) 85 | assert.Equal(t, cfV6, got) 86 | }) 87 | } 88 | 89 | func TestDNSv4v6(t *testing.T) { 90 | t.Run("both", func(t *testing.T) { 91 | c := Client{v4: true, v6: true, tcp: true} 92 | got, err := c.DNSv4v6(t.Context(), DNSv4v6{ 93 | ServerV4: newDNSServer(t, tcp4), 94 | QuestionV4: dns.Question{ 95 | Name: domain, 96 | Qtype: dns.TypeA, 97 | Qclass: dns.ClassINET, 98 | }, 99 | ServerV6: newDNSServer(t, tcp6), 100 | QuestionV6: dns.Question{ 101 | Name: domain, 102 | Qtype: dns.TypeAAAA, 103 | Qclass: dns.ClassINET, 104 | }, 105 | }) 106 | require.NoError(t, err) 107 | 108 | expect := Response{IPV4: cfV4, IPV6: cfV6} 109 | assert.Equal(t, expect, got) 110 | }) 111 | 112 | t.Run("only v4", func(t *testing.T) { 113 | c := Client{v4: true, tcp: true} 114 | got, err := c.DNSv4v6(t.Context(), DNSv4v6{ 115 | ServerV4: newDNSServer(t, tcp4), 116 | QuestionV4: dns.Question{ 117 | Name: domain, 118 | Qtype: dns.TypeA, 119 | Qclass: dns.ClassINET, 120 | }, 121 | }) 122 | require.NoError(t, err) 123 | 124 | expect := Response{IPV4: cfV4} 125 | assert.Equal(t, expect, got) 126 | }) 127 | 128 | t.Run("only v6", func(t *testing.T) { 129 | c := Client{v6: true, tcp: true} 130 | got, err := c.DNSv4v6(t.Context(), DNSv4v6{ 131 | ServerV6: newDNSServer(t, tcp6), 132 | QuestionV6: dns.Question{ 133 | Name: domain, 134 | Qtype: dns.TypeAAAA, 135 | Qclass: dns.ClassINET, 136 | }, 137 | }) 138 | require.NoError(t, err) 139 | 140 | expect := Response{IPV6: cfV6} 141 | assert.Equal(t, expect, got) 142 | }) 143 | } 144 | -------------------------------------------------------------------------------- /internal/lookup/http.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "time" 12 | 13 | "gabe565.com/cloudflare-ddns/internal/errsgroup" 14 | "gabe565.com/utils/slogx" 15 | ) 16 | 17 | var ErrUpstreamStatus = errors.New("upstream error") 18 | 19 | func HTTP(ctx context.Context, network, url string) (string, error) { 20 | start := time.Now() 21 | slogx.Trace("HTTP request", "url", url) 22 | 23 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 24 | if err != nil { 25 | return "", err 26 | } 27 | 28 | dialer := net.Dialer{} 29 | //nolint:errcheck 30 | transport := http.DefaultTransport.(*http.Transport).Clone() 31 | transport.DialContext = func(ctx context.Context, _, addr string) (net.Conn, error) { 32 | return dialer.DialContext(ctx, network, addr) 33 | } 34 | client := http.Client{Transport: transport} 35 | 36 | res, err := client.Do(req) 37 | if err != nil { 38 | return "", err 39 | } 40 | defer func() { 41 | _, _ = io.Copy(io.Discard, res.Body) 42 | _ = res.Body.Close() 43 | }() 44 | 45 | b, err := io.ReadAll(res.Body) 46 | if err != nil { 47 | return "", err 48 | } 49 | 50 | slogx.Trace("HTTP response", "took", time.Since(start), "status", res.Status, "body", string(b)) 51 | 52 | if res.StatusCode != http.StatusOK { 53 | return "", fmt.Errorf("%w: %s", ErrUpstreamStatus, res.Status) 54 | } 55 | 56 | return string(bytes.TrimSpace(b)), nil 57 | } 58 | 59 | func (c *Client) HTTPv4v6(ctx context.Context, req HTTPv4v6) (Response, error) { 60 | var response Response 61 | var group errsgroup.Group 62 | 63 | if c.v4 { 64 | group.Go(func() error { 65 | var err error 66 | response.IPV4, err = HTTP(ctx, tcp4, req.URLv4) 67 | return err 68 | }) 69 | } 70 | 71 | if c.v6 { 72 | group.Go(func() error { 73 | var err error 74 | response.IPV6, err = HTTP(ctx, tcp6, req.URLv6) 75 | return err 76 | }) 77 | } 78 | 79 | err := group.Wait() 80 | return response, err 81 | } 82 | -------------------------------------------------------------------------------- /internal/lookup/http_test.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "net" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "github.com/stretchr/testify/assert" 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func newHTTPServer(t *testing.T, network string) *httptest.Server { 14 | server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { 15 | switch network { 16 | case tcp4: 17 | _, err := w.Write([]byte(cfV4)) 18 | assert.NoError(t, err) 19 | case tcp6: 20 | _, err := w.Write([]byte(cfV6)) 21 | assert.NoError(t, err) 22 | } 23 | })) 24 | 25 | addr := "127.0.0.1:0" 26 | if network == tcp6 { 27 | addr = "[::1]:0" 28 | } 29 | 30 | var err error 31 | server.Listener, err = net.Listen(network, addr) 32 | require.NoError(t, err) 33 | 34 | server.Start() 35 | t.Cleanup(server.Close) 36 | 37 | return server 38 | } 39 | 40 | func Test_HTTPPlain(t *testing.T) { 41 | t.Run("v4", func(t *testing.T) { 42 | server := newHTTPServer(t, tcp4) 43 | got, err := HTTP(t.Context(), tcp4, server.URL) 44 | require.NoError(t, err) 45 | assert.Equal(t, cfV4, got) 46 | }) 47 | 48 | t.Run("v6", func(t *testing.T) { 49 | server := newHTTPServer(t, tcp6) 50 | got, err := HTTP(t.Context(), tcp6, server.URL) 51 | require.NoError(t, err) 52 | assert.Equal(t, cfV6, got) 53 | }) 54 | } 55 | 56 | func TestHTTPv4v6(t *testing.T) { 57 | t.Run("both", func(t *testing.T) { 58 | c := Client{v4: true, v6: true} 59 | got, err := c.HTTPv4v6(t.Context(), HTTPv4v6{ 60 | URLv4: newHTTPServer(t, tcp4).URL, 61 | URLv6: newHTTPServer(t, tcp6).URL, 62 | }) 63 | require.NoError(t, err) 64 | 65 | expect := Response{IPV4: cfV4, IPV6: cfV6} 66 | assert.Equal(t, expect, got) 67 | }) 68 | 69 | t.Run("only v4", func(t *testing.T) { 70 | c := Client{v4: true} 71 | got, err := c.HTTPv4v6(t.Context(), HTTPv4v6{ 72 | URLv4: newHTTPServer(t, tcp4).URL, 73 | }) 74 | require.NoError(t, err) 75 | 76 | expect := Response{IPV4: cfV4} 77 | assert.Equal(t, expect, got) 78 | }) 79 | 80 | t.Run("only v6", func(t *testing.T) { 81 | c := Client{v6: true} 82 | got, err := c.HTTPv4v6(t.Context(), HTTPv4v6{ 83 | URLv6: newHTTPServer(t, tcp6).URL, 84 | }) 85 | require.NoError(t, err) 86 | 87 | expect := Response{IPV6: cfV6} 88 | assert.Equal(t, expect, got) 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /internal/lookup/lookup.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "log/slog" 8 | 9 | "gabe565.com/utils/slogx" 10 | ) 11 | 12 | func NewClient(opts ...Option) *Client { 13 | c := &Client{v4: true} 14 | for _, opt := range opts { 15 | opt(c) 16 | } 17 | return c 18 | } 19 | 20 | type Client struct { 21 | v4, v6, tcp bool 22 | sources []Source 23 | } 24 | 25 | var ErrAllSourcesFailed = errors.New("all sources failed") 26 | 27 | func (c *Client) GetPublicIP(ctx context.Context) (Response, error) { 28 | var errs []error //nolint:prealloc 29 | for _, source := range c.sources { 30 | slogx.Trace("Querying source", "name", source) 31 | var response Response 32 | var err error 33 | switch req := source.Request().(type) { 34 | case DNSv4v6: 35 | response, err = c.DNSv4v6(ctx, req) 36 | case HTTPv4v6: 37 | response, err = c.HTTPv4v6(ctx, req) 38 | default: 39 | panic("unknown request type") 40 | } 41 | if err == nil { 42 | slog.Debug("Got response", "source", source, "ip", response) 43 | return response, nil 44 | } 45 | errs = append(errs, fmt.Errorf("%s: %w", source, err)) 46 | slog.Debug("Source failed", "source", source, "error", err) 47 | } 48 | return Response{}, fmt.Errorf("%w: %w", ErrAllSourcesFailed, errors.Join(errs...)) 49 | } 50 | 51 | type Response struct { 52 | IPV4, IPV6 string 53 | } 54 | 55 | func (r Response) LogValue() slog.Value { 56 | attr := make([]slog.Attr, 0, 2) 57 | if r.IPV4 != "" { 58 | attr = append(attr, slog.String("v4", r.IPV4)) 59 | } 60 | if r.IPV6 != "" { 61 | attr = append(attr, slog.String("v6", r.IPV6)) 62 | } 63 | return slog.GroupValue(attr...) 64 | } 65 | -------------------------------------------------------------------------------- /internal/lookup/options.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | type Option func(*Client) 4 | 5 | func WithV4(enabled bool) Option { 6 | return func(c *Client) { 7 | c.v4 = enabled 8 | } 9 | } 10 | 11 | func WithV6(enabled bool) Option { 12 | return func(c *Client) { 13 | c.v6 = enabled 14 | } 15 | } 16 | 17 | func WithForceTCP(enabled bool) Option { 18 | return func(c *Client) { 19 | c.tcp = enabled 20 | } 21 | } 22 | 23 | func WithSources(sources ...Source) Option { 24 | return func(c *Client) { 25 | c.sources = sources 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/lookup/source.go: -------------------------------------------------------------------------------- 1 | package lookup 2 | 3 | import ( 4 | "strings" 5 | 6 | "gabe565.com/cloudflare-ddns/internal/output" 7 | "github.com/charmbracelet/lipgloss" 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | //go:generate go tool enumer -type Source -transform snake -linecomment -output source_string.go 12 | 13 | type Source uint8 14 | 15 | const ( 16 | CloudflareTLS Source = iota 17 | Cloudflare 18 | OpenDNSTLS // opendns_tls 19 | OpenDNS // opendns 20 | ICanHazIP // icanhazip 21 | IPInfo // ipinfo 22 | IPify // ipify 23 | ) 24 | 25 | func (s Source) Description(format output.Format) string { 26 | req := s.Request() 27 | return req.Description(format) 28 | } 29 | 30 | type Requestv4v6 interface { 31 | Description(format output.Format) string 32 | } 33 | 34 | type HTTPv4v6 struct { 35 | URLv4, URLv6 string 36 | } 37 | 38 | func (d HTTPv4v6) Description(format output.Format) string { 39 | switch format { 40 | case output.FormatANSI: 41 | bold := lipgloss.NewStyle().Bold(true).Render 42 | return "Makes HTTPS requests to " + bold(d.URLv4) + " and " + bold(d.URLv6) + "." 43 | case output.FormatMarkdown: 44 | return "Makes HTTPS requests to `" + d.URLv4 + "` and `" + d.URLv6 + "`." 45 | default: 46 | panic("unimplemented format: " + format) 47 | } 48 | } 49 | 50 | type DNSv4v6 struct { 51 | ServerV4 string 52 | QuestionV4 dns.Question 53 | ServerV6 string 54 | QuestionV6 dns.Question 55 | TLS bool 56 | } 57 | 58 | func (d DNSv4v6) Description(format output.Format) string { 59 | proto := "DNS" 60 | if d.TLS { 61 | proto = "DNS-over-TLS" 62 | } 63 | switch format { 64 | case output.FormatANSI: 65 | bold := lipgloss.NewStyle().Bold(true).Render 66 | return "Queries " + bold( 67 | strings.TrimSuffix(d.QuestionV4.Name, "."), 68 | ) + " using " + proto + " via " + bold( 69 | d.ServerV4, 70 | ) + "." 71 | case output.FormatMarkdown: 72 | return "Queries `" + strings.TrimSuffix( 73 | d.QuestionV4.Name, 74 | ".", 75 | ) + "` using " + proto + " via `" + d.ServerV4 + "`." 76 | default: 77 | panic("unimplemented format: " + format) 78 | } 79 | } 80 | 81 | func (s Source) Request() Requestv4v6 { //nolint:ireturn 82 | var server string 83 | var tls bool 84 | switch s { 85 | case CloudflareTLS: 86 | server = "one.one.one.one:853" 87 | tls = true 88 | fallthrough 89 | case Cloudflare: 90 | if server == "" { 91 | server = "one.one.one.one:53" 92 | } 93 | question := dns.Question{ 94 | Name: "whoami.cloudflare.", 95 | Qtype: dns.TypeTXT, 96 | Qclass: dns.ClassCHAOS, 97 | } 98 | return DNSv4v6{ 99 | ServerV4: server, 100 | QuestionV4: question, 101 | ServerV6: server, 102 | QuestionV6: question, 103 | TLS: tls, 104 | } 105 | case OpenDNSTLS: 106 | server = "dns.opendns.com:853" 107 | tls = true 108 | fallthrough 109 | case OpenDNS: 110 | if server == "" { 111 | server = "dns.opendns.com:53" 112 | } 113 | return DNSv4v6{ 114 | ServerV4: server, 115 | QuestionV4: dns.Question{ 116 | Name: "myip.opendns.com.", 117 | Qtype: dns.TypeA, 118 | Qclass: dns.ClassINET, 119 | }, 120 | ServerV6: server, 121 | QuestionV6: dns.Question{ 122 | Name: "myip.opendns.com.", 123 | Qtype: dns.TypeAAAA, 124 | Qclass: dns.ClassINET, 125 | }, 126 | TLS: tls, 127 | } 128 | case ICanHazIP: 129 | return HTTPv4v6{ 130 | URLv4: "https://ipv4.icanhazip.com", 131 | URLv6: "https://ipv6.icanhazip.com", 132 | } 133 | case IPInfo: 134 | return HTTPv4v6{ 135 | URLv4: "https://ipinfo.io/ip", 136 | URLv6: "https://v6.ipinfo.io/ip", 137 | } 138 | case IPify: 139 | return HTTPv4v6{ 140 | URLv4: "https://api.ipify.org", 141 | URLv6: "https://api6.ipify.org", 142 | } 143 | default: 144 | panic("source request unimplemented: " + s.String()) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /internal/lookup/source_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "enumer -type Source -transform snake -linecomment -output source_string.go"; DO NOT EDIT. 2 | 3 | package lookup 4 | 5 | import ( 6 | "fmt" 7 | "strings" 8 | ) 9 | 10 | const _SourceName = "cloudflare_tlscloudflareopendns_tlsopendnsicanhazipipinfoipify" 11 | 12 | var _SourceIndex = [...]uint8{0, 14, 24, 35, 42, 51, 57, 62} 13 | 14 | const _SourceLowerName = "cloudflare_tlscloudflareopendns_tlsopendnsicanhazipipinfoipify" 15 | 16 | func (i Source) String() string { 17 | if i >= Source(len(_SourceIndex)-1) { 18 | return fmt.Sprintf("Source(%d)", i) 19 | } 20 | return _SourceName[_SourceIndex[i]:_SourceIndex[i+1]] 21 | } 22 | 23 | // An "invalid array index" compiler error signifies that the constant values have changed. 24 | // Re-run the stringer command to generate them again. 25 | func _SourceNoOp() { 26 | var x [1]struct{} 27 | _ = x[CloudflareTLS-(0)] 28 | _ = x[Cloudflare-(1)] 29 | _ = x[OpenDNSTLS-(2)] 30 | _ = x[OpenDNS-(3)] 31 | _ = x[ICanHazIP-(4)] 32 | _ = x[IPInfo-(5)] 33 | _ = x[IPify-(6)] 34 | } 35 | 36 | var _SourceValues = []Source{CloudflareTLS, Cloudflare, OpenDNSTLS, OpenDNS, ICanHazIP, IPInfo, IPify} 37 | 38 | var _SourceNameToValueMap = map[string]Source{ 39 | _SourceName[0:14]: CloudflareTLS, 40 | _SourceLowerName[0:14]: CloudflareTLS, 41 | _SourceName[14:24]: Cloudflare, 42 | _SourceLowerName[14:24]: Cloudflare, 43 | _SourceName[24:35]: OpenDNSTLS, 44 | _SourceLowerName[24:35]: OpenDNSTLS, 45 | _SourceName[35:42]: OpenDNS, 46 | _SourceLowerName[35:42]: OpenDNS, 47 | _SourceName[42:51]: ICanHazIP, 48 | _SourceLowerName[42:51]: ICanHazIP, 49 | _SourceName[51:57]: IPInfo, 50 | _SourceLowerName[51:57]: IPInfo, 51 | _SourceName[57:62]: IPify, 52 | _SourceLowerName[57:62]: IPify, 53 | } 54 | 55 | var _SourceNames = []string{ 56 | _SourceName[0:14], 57 | _SourceName[14:24], 58 | _SourceName[24:35], 59 | _SourceName[35:42], 60 | _SourceName[42:51], 61 | _SourceName[51:57], 62 | _SourceName[57:62], 63 | } 64 | 65 | // SourceString retrieves an enum value from the enum constants string name. 66 | // Throws an error if the param is not part of the enum. 67 | func SourceString(s string) (Source, error) { 68 | if val, ok := _SourceNameToValueMap[s]; ok { 69 | return val, nil 70 | } 71 | 72 | if val, ok := _SourceNameToValueMap[strings.ToLower(s)]; ok { 73 | return val, nil 74 | } 75 | return 0, fmt.Errorf("%s does not belong to Source values", s) 76 | } 77 | 78 | // SourceValues returns all values of the enum 79 | func SourceValues() []Source { 80 | return _SourceValues 81 | } 82 | 83 | // SourceStrings returns a slice of all String values of the enum 84 | func SourceStrings() []string { 85 | strs := make([]string, len(_SourceNames)) 86 | copy(strs, _SourceNames) 87 | return strs 88 | } 89 | 90 | // IsASource returns "true" if the value is listed in the enum definition. "false" otherwise 91 | func (i Source) IsASource() bool { 92 | for _, v := range _SourceValues { 93 | if i == v { 94 | return true 95 | } 96 | } 97 | return false 98 | } 99 | -------------------------------------------------------------------------------- /internal/output/format.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import "context" 4 | 5 | type Format string 6 | 7 | const ( 8 | FormatANSI Format = "ansi" 9 | FormatMarkdown Format = "markdown" 10 | ) 11 | 12 | type ctxKey uint8 13 | 14 | const formatKey ctxKey = iota 15 | 16 | func NewContext(ctx context.Context, format Format) context.Context { 17 | return context.WithValue(ctx, formatKey, format) 18 | } 19 | 20 | func FromContext(ctx context.Context) (Format, bool) { 21 | format, ok := ctx.Value(formatKey).(Format) 22 | return format, ok 23 | } 24 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log/slog" 5 | "os" 6 | 7 | "gabe565.com/cloudflare-ddns/cmd" 8 | "gabe565.com/cloudflare-ddns/internal/config" 9 | "gabe565.com/utils/cobrax" 10 | "gabe565.com/utils/slogx" 11 | ) 12 | 13 | var version = "beta" 14 | 15 | func main() { 16 | config.InitLog(os.Stderr, slogx.LevelInfo, slogx.FormatAuto) 17 | 18 | root := cmd.New(cobrax.WithVersion(version)) 19 | if err := root.Execute(); err != nil { 20 | slog.Error(err.Error()) 21 | os.Exit(1) 22 | } 23 | } 24 | --------------------------------------------------------------------------------