├── .drone.yml ├── .gitignore ├── .goreleaser.yml ├── CHANGES.md ├── LICENSE ├── Makefile ├── README.md ├── appconfig └── appconfig.go ├── applog └── log.go ├── build ├── config_test.go ├── countries ├── countries.go └── regiongroups.go ├── dayduration.go ├── dns ├── 1.168.192.in-addr.arpa.json ├── Makefile ├── example.com.json ├── geodns.conf.sample ├── hc.example.com.json ├── test.example.com.json └── test.example.org.json ├── edns ├── README.md └── edns.go ├── geodns.go ├── go.mod ├── go.sum ├── health ├── health.go ├── healthtest │ ├── healthtest.go │ └── healthtesters.go ├── status.go ├── status_file.go ├── status_test.go └── test.json ├── http.go ├── http_test.go ├── monitor ├── hub.go └── monitor.go ├── querylog ├── avro.go ├── avro_test.go ├── file.go ├── querylog.avsc ├── querylog.go └── testdata │ └── queries.log ├── scripts ├── defaults ├── download-release ├── download-test-geoip ├── fury-publish ├── geodns.service ├── postinstall.sh └── run-goreleaser ├── server ├── querylog_test.go ├── serve.go ├── serve_test.go └── server.go ├── service ├── log │ └── run └── run ├── targeting ├── geo │ └── geo.go ├── geoip2 │ └── geoip2.go ├── targeting.go └── targeting_test.go ├── typeutil └── typeutil.go ├── util.go └── zones ├── muxmanager.go ├── picker.go ├── reader.go ├── reader_test.go ├── zone.go ├── zone_health_test.go ├── zone_stats.go ├── zone_stats_test.go ├── zone_test.go └── zones_closest_test.go /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: kubernetes 4 | name: default 5 | 6 | environment: 7 | GOCACHE: /cache/pkg/cache 8 | GOMODCACHE: /cache/pkg/mod 9 | 10 | steps: 11 | - name: fetch-tags 12 | image: alpine/git 13 | commands: 14 | - git fetch --tags 15 | resources: 16 | requests: 17 | cpu: 250 18 | memory: 50MiB 19 | limits: 20 | cpu: 250 21 | memory: 100MiB 22 | 23 | - name: test 24 | image: golang:1.21.3 25 | volumes: 26 | - name: go 27 | path: /go 28 | - name: gopkg 29 | path: /cache 30 | commands: 31 | - ./scripts/download-test-geoip 32 | - go test -v ./... 33 | - go build ./... 34 | resources: 35 | requests: 36 | cpu: 1000 37 | memory: 128MiB 38 | limits: 39 | cpu: 2000 40 | memory: 2GiB 41 | 42 | - name: goreleaser 43 | image: golang:1.21.3 44 | resources: 45 | requests: 46 | cpu: 4000 47 | memory: 512MiB 48 | limits: 49 | cpu: 10000 50 | memory: 2048MiB 51 | volumes: 52 | - name: go 53 | path: /go 54 | - name: gopkg 55 | path: /cache 56 | commands: 57 | - git status 58 | - ./scripts/run-goreleaser 59 | - echo Done 60 | when: 61 | ref: 62 | - refs/heads/main 63 | - refs/heads/avro 64 | - refs/tags/** 65 | depends_on: [test] 66 | 67 | - name: upload 68 | image: plugins/s3 69 | resources: 70 | requests: 71 | cpu: 250 72 | memory: 64MiB 73 | limits: 74 | cpu: 250 75 | memory: 256MiB 76 | settings: 77 | access_key: 78 | from_secret: s3_access_key 79 | secret_key: 80 | from_secret: s3_secret_key 81 | bucket: geodns 82 | target: /geodns/builds/test/${DRONE_BUILD_NUMBER} 83 | source: dist/* 84 | strip_prefix: dist/ 85 | endpoint: https://minio-ewr1.develooper.com/ 86 | path_style: true 87 | depends_on: [goreleaser] 88 | 89 | - name: fury-publish 90 | image: golang:1.21.3 91 | resources: 92 | requests: 93 | cpu: 250 94 | memory: 64MiB 95 | limits: 96 | cpu: 250 97 | memory: 256MiB 98 | environment: 99 | FURY_TOKEN: 100 | from_secret: fury_test_token 101 | commands: 102 | - ./scripts/fury-publish ntppool-test 103 | when: 104 | ref: 105 | - refs/heads/main 106 | - refs/heads/drone-test 107 | - refs/tags/** 108 | depends_on: [goreleaser] 109 | 110 | volumes: 111 | - name: go 112 | temp: {} 113 | - name: gopkg 114 | claim: 115 | name: go-pkg 116 | 117 | trigger: 118 | event: 119 | - push 120 | - tag 121 | - pull_request 122 | 123 | --- 124 | kind: pipeline 125 | type: kubernetes 126 | name: publish-production 127 | 128 | steps: 129 | - name: download 130 | image: golang:1.21.3 131 | commands: 132 | - ./scripts/download-release geodns test/${DRONE_BUILD_PARENT} dist/ 133 | resources: 134 | requests: 135 | cpu: 250 136 | memory: 64MiB 137 | limits: 138 | cpu: 250 139 | memory: 256MiB 140 | 141 | - name: upload 142 | image: plugins/s3 143 | resources: 144 | requests: 145 | cpu: 250 146 | memory: 64MiB 147 | limits: 148 | cpu: 250 149 | memory: 256MiB 150 | settings: 151 | access_key: 152 | from_secret: s3_access_key 153 | secret_key: 154 | from_secret: s3_secret_key 155 | bucket: geodns 156 | target: /geodns/builds/release/${DRONE_BUILD_NUMBER} 157 | source: dist/* 158 | strip_prefix: dist/ 159 | endpoint: https://minio-ewr1.develooper.com/ 160 | path_style: true 161 | depends_on: ["download"] 162 | 163 | - name: fury-publish 164 | image: golang:1.21.3 165 | resources: 166 | requests: 167 | cpu: 250 168 | memory: 64MiB 169 | limits: 170 | cpu: 250 171 | memory: 256MiB 172 | environment: 173 | FURY_TOKEN: 174 | from_secret: fury_token 175 | commands: 176 | - ./scripts/fury-publish ntppool 177 | depends_on: ["download"] 178 | 179 | trigger: 180 | event: 181 | - promote 182 | target: 183 | - publish 184 | --- 185 | kind: signature 186 | hmac: e548b46090913220734b26fbf4c8ff97b8b0931f11f63d0c39f5eefe4c128c0d 187 | 188 | ... 189 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | dist/ 3 | .DS_Store 4 | /geodns 5 | /REVISION 6 | /run 7 | .idea 8 | /dns/geodns.conf 9 | geodns-*-* 10 | geodns-*-*.tar 11 | /devel/ 12 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | - go generate ./... 5 | builds: 6 | - id: geodns 7 | env: 8 | - CGO_ENABLED=0 9 | ldflags: 10 | - -s -w 11 | - -X go.ntppool.org/common/version.VERSION={{.Version}} 12 | goos: 13 | - linux 14 | - freebsd 15 | - darwin 16 | ignore: 17 | - goos: darwin 18 | goarch: 386 19 | - goos: freebsd 20 | goarch: 386 21 | - goos: freebsd 22 | goarch: arm64 23 | 24 | archives: 25 | - files: 26 | - service/** 27 | - LICENSE 28 | - README.md 29 | 30 | checksum: 31 | name_template: "checksums.txt" 32 | snapshot: 33 | name_template: '{{ .Tag }}{{ if index .Env "DRONE_BUILD_NUMBER" }}-{{ .Env.DRONE_BUILD_NUMBER }}{{ end }}' 34 | changelog: 35 | sort: asc 36 | filters: 37 | exclude: 38 | - "^docs:" 39 | - "^test:" 40 | 41 | nfpms: 42 | - id: geodns 43 | 44 | # Name of the package. 45 | # Defaults to `ProjectName`. 46 | package_name: geodns 47 | 48 | # release: {{ if index .Env "DRONE_BUILD_NUMBER" }}{{ .Env.DRONE_BUILD_NUMBER }}{{ else }}1{{ end }} 49 | 50 | vendor: NTP Pool Project 51 | homepage: https://www.ntppool.org/ 52 | maintainer: Ask Bjørn Hansen 53 | description: GeoDNS server 54 | license: Apache 2.0 55 | file_name_template: "{{ .ConventionalFileName }}" 56 | formats: 57 | - deb 58 | - rpm 59 | - apk 60 | bindir: /usr/bin 61 | contents: 62 | - src: "scripts/geodns.service" 63 | dst: "/etc/systemd/system/geodns.service" 64 | 65 | - src: "scripts/defaults" 66 | dst: "/etc/default/geodns.sample" 67 | type: config 68 | 69 | scripts: 70 | postinstall: scripts/postinstall.sh 71 | 72 | overrides: 73 | rpm: 74 | #file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}" 75 | # {{ if index .Env "DRONE_BUILD_NUMBER" }}-{{ .Env.DRONE_BUILD_NUMBER }}{{ end }} 76 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | # GeoDNS Changelog 2 | 3 | ## Next 4 | - DNS configuration options (see dns/geodns.conf.sample) for 5 | disabling qnames being in the prometheus labels and enabling 6 | public debug queries. 7 | 8 | ## 3.3.3 August 2023 9 | - Fix how NS / SOA queries are treated for alias records pointing 10 | to the zone apex 11 | - Update Go to 1.21.0 and package dependencies 12 | 13 | ## 3.3.2 August 2023 14 | - Update Go to 1.20.7 and package dependencies 15 | - Minor Avro logging improvements 16 | 17 | ## 3.3.1 July 2023 18 | - Use server IP if ECS provided IP is unhelpful 19 | - Avro logging fixes 20 | - Remove deprecated geodns-logs tool 21 | - Lowercase 'version' in prometheus build_info 22 | 23 | ## 3.3.0 July 2023 24 | - Avro logging feature 25 | - Default to use all CPUs on the system. 26 | - Graceful shutdown on term/quit/interrupt signals 27 | 28 | ## 3.2.3 June 2023 29 | - querylog: Add software version, answer data and IsTCP fields 30 | - Make Go module paths semantic versions 31 | - Remove extra bogus json field from query log 32 | - Update dependencies (and Go 1.20.5) 33 | 34 | ## 3.2.2 May 2023 35 | * Go 1.20.4 36 | * Updated dependencies 37 | 38 | ## 3.2.1 November 2022 39 | * Go 1.19.3 40 | * Add new country codes 41 | 42 | ## 3.2.0 October 2021 43 | 44 | * Reload GeoIP 2 databases when they change (Tyler Davis) 45 | * Updated build process, rpm and deb packages now available 46 | * Build with Go 1.17.2 (Tyler Davis) 47 | * Minor fix to geodns-logs tool 48 | * Updated code comments (Sven Nebel) 49 | 50 | ## 3.1.0 August 2021 51 | 52 | * NSID support 53 | * Support for DNS Cookies 54 | * dnsflagday cleanups 55 | * Add Russia's federal districts as country region codes 56 | * Update dependencies 57 | * Publish rpm and deb files 58 | 59 | ## 3.0.2 December 2019 60 | 61 | * Better test errors when geoip2 files aren't found 62 | * Require Go 1.13 or later (just for build script for now) 63 | * Add geodns-logs to Docker image 64 | * Fix targeting tests (GeoIP data changed) 65 | * Update dependencies 66 | 67 | ## 3.0.1 April 2019 68 | 69 | * Added Prometheus metrics support 70 | * Removed /monitor websocket interface 71 | * Removed /status and /status.json pages 72 | * Support "closest" matching (instead of geo/asn labels) for A and AAAA records (Alex Bligh) 73 | * Support for GeoIP2 databases (including IPv6 data and ASN databases) 74 | * "Pluggable" targeting data support 75 | * Support for "health status" in an external file (not documented) 76 | * Integrated health check support coming later (integrated work done by Alex Bligh, but not functional in this release - his branch on Github has/had it working) 77 | * Remove minimum TTL for NS records (Alex Bligh) 78 | * More/updated tests 79 | * Don't let the server ID be 127.0.0.1 80 | * Use 'dep' to manage dependencies 81 | * Remove built-in InfluxDB support from the log processing tool 82 | 83 | ## 2.7.0 February 13, 2017 84 | 85 | * Add support for PTR records (Florent AIDE) 86 | * Test improvements (Alex Bligh) 87 | * Update github.com/miekg/dns 88 | * Update github.com/rcrowley/go-metrics 89 | * Use vendor/ instead of godep 90 | * Make query logging (globally) configurable 91 | * Support base configuration file outside the zone config directory 92 | * service: Read extra args from env/ARGS 93 | 94 | ## 2.6.0 October 4, 2015 95 | 96 | Leif Johansson: 97 | * Start new /status.json statistics end-point 98 | 99 | Alex Bligh: 100 | * Add ability to log to file. 101 | * Add option to make debugging queries private. 102 | * Fix race referencing config and other configuration system improvements. 103 | * Fix crash on removal of zonefile with invalid JSON (Issue #69) 104 | * Fix issue #74 - crash on reenabling previously invalid zone 105 | 106 | Ask Bjørn Hansen: 107 | * Fix critical data race in serve.go (and other rare races) 108 | * Optionally require basic authentication for http interface 109 | * Fix weighted CNAMEs (only return one) 110 | * Make /status.json dump all metrics from go-metrics 111 | * Update godeps (including miekg/dns) 112 | * StatHat bugfix when the configuration changed at runtime 113 | * ./build should just build, not install 114 | * Fix crash when removing an invalid zone file 115 | * Don't double timestamps when running under supervise 116 | * Require Go 1.4+ 117 | * Internal improvements to metrics collection 118 | * Remove every minute logging of goroutine and query count 119 | * Add per-instance UUID to parsable status outputs (experimental) 120 | * Report Go version as part of the version reporting 121 | * Minor optimizations 122 | 123 | ## 2.5.0 June 5, 2015 124 | 125 | * Add resolver ASN and IP targeting (Ewan Chou) 126 | * Support for SPF records (Afsheen Bigdeli) 127 | * Support weighted CNAME responses 128 | * Add /48 subnet targeting for IPv6 ip targeting 129 | * Don't log metrics to stderr anymore 130 | * Make TTLs set on individual labels work 131 | * Return NOERROR for "bar" if "foo.bar" exists (Geoffrey Papilion) 132 | * Add Illinois to the us-central region group 133 | * Add benchmark tests (Miek Gieben) 134 | * Improve documentation 135 | * Use godep to track code dependencies 136 | * Don't add a '.' prefix on the record header on apex records 137 | 138 | ## 2.4.4 October 3, 2013 139 | 140 | * Fix parsing of 'targeting' option 141 | * Add server id and ip to _country responses for easier debugging. 142 | 143 | ## 2.4.3 October 1, 2013 144 | 145 | * Fix GeoIP custom directory bug (in geoip library) 146 | 147 | ## 2.4.2 September 20, 2013 148 | 149 | * Update EDNS-SUBNET option number (in dns library) 150 | 151 | ## 2.4.1 July 24, 2013 152 | 153 | * Update dns API to use new CountLabel and SplitDomainName functions 154 | * Add test for mIXed-caSE queries (fix was in dns library) 155 | 156 | ## 2.4.0 June 26, 2013 157 | 158 | * Add per-zone targeting configuration 159 | * Support targeting by region/state with GeoIPCity 160 | * Don't send backlogged zone counts to stathat when support is enabled 161 | 162 | ## 2.3.0 May 7, 2013 163 | * Fix edns-client-subnet bug in dns library so it 164 | works with OpenDNS 165 | 166 | ## 2.2.8 April 28, 2013 167 | * Support per-zone stats posted to StatHat 168 | * Support TXT records 169 | * Don't return NXDOMAIN for A queries to _status and _country 170 | * Set serial number from file modtime if not explicitly set in json 171 | * Improve record type documentation 172 | * Warn about unknown record types in zone json files 173 | * Add -version option 174 | 175 | ## 2.2.7 April 16, 2013 176 | * Count EDNS queries per zone, pretty status page 177 | * Status page has various per-zone stats 178 | * Show global query stats, etc 179 | * Add option to configure 'loggers' 180 | * Add -cpus option to use multiple CPUs 181 | * Add sample geodns.conf 182 | * Use numbers instead of strings when appropriate in websocket stream 183 | * Various refactoring and bug-fixes 184 | 185 | ## 2.2.6 April 9, 2013 186 | 187 | * Begin more detailed /status page 188 | * Make SOA record look more "normal" (cosmetic change only) 189 | 190 | ## 2.2.5 April 7, 2013 191 | 192 | * Add StatHat feature 193 | * Improve error handling for bad zone files 194 | * Don't call runtime.GC() after loading each zone 195 | * Set the minimum TTL to 10x regular TTL (up to an hour) 196 | * service script: Load identifier from env/ID if it exists 197 | * Work with latest geoip; use netmask from GeoIP in EDNS-SUBNET replies 198 | 199 | ## 2.2.4 March 5, 2013 200 | 201 | * Add licensing information 202 | * De-configure zones when the .json file is removed 203 | * Start adding support for a proper configuration file 204 | * Add -identifier command line option 205 | * Various tweaks 206 | 207 | ## 2.2.3 March 1, 2013 208 | 209 | * Always log when zones are re-read 210 | * Remove one of the runtime.GC() calls when configs are loaded 211 | * Set ulimit -n 64000 in run script 212 | * Cleanup unused Zones variable in a few places 213 | * Log when server was started to websocket /monitor interface 214 | 215 | ## 2.2.2 February 27, 2013 216 | 217 | * Fix crash when getting unknown RRs in Extra request section 218 | 219 | ## 2.2.1 February 2013 220 | 221 | * Beta EDNS-SUBNET support. 222 | * Allow A/AAAA records without a weight 223 | * Don't crash if a zone doesn't have any apex records 224 | * Show line with syntax error when parsing JSON files 225 | * Add --checkconfig parameter 226 | * More tests 227 | 228 | 229 | ## 2.2.0 December 2012 230 | 231 | * Initial EDNS-SUBNET support. 232 | * Better error messages when parsing invalid JSON. 233 | * -checkconfig command line option that loads the configuration and exits. 234 | * The CNAME configuration changed so the name of the current zone is appended 235 | to the target name if the target is not a fqdn (ends with a "."). This is a 236 | rare change not compatible with existing data. To upgrade make all cname's 237 | fqdn's until all servers are running v2.2.0 or newer. 238 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | 180 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # where to rsync builds 3 | DIST?=dist/publish 4 | DISTSUB=2020/05 5 | 6 | test: .PHONY 7 | go test -v $(shell go list ./... | grep -v /vendor/) 8 | 9 | testrace: .PHONY 10 | go test -v -race $(shell go list ./... | grep -v /vendor/) 11 | 12 | docker-test: .PHONY 13 | # test that we don't have missing dependencies 14 | docker run --rm -v `pwd`:/go/src/github.com/abh/geodns \ 15 | -v /opt/local/share/GeoIP:/opt/local/share/GeoIP \ 16 | golang:1.14-alpine3.11 -- \ 17 | go test ./... 18 | 19 | sign: 20 | drone sign --save ntppool/geodns 21 | 22 | devel: 23 | go build -tags devel 24 | 25 | bench: 26 | go test -check.b -check.bmem 27 | 28 | TARS=$(wildcard dist/geodns-*-*.tar) 29 | 30 | push: $(TARS) install.sh 31 | rsync --exclude publish install.sh $(TARS) $(DIST)/$(DISTSUB)/ 32 | $(DIST)/../push 33 | 34 | builds: linux-build linux-build-i386 freebsd-build push 35 | 36 | linux-build: 37 | GOOS=linux GOARCH=amd64 ./build 38 | 39 | linux-build-i386: 40 | GOOS=linux GOARCH=386 ./build 41 | 42 | freebsd-build: 43 | GOOS=freebsd GOARCH=amd64 ./build 44 | GOOS=freebsd GOARCH=386 ./build 45 | 46 | .PHONY: 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GeoDNS servers 2 | 3 | This is the DNS server powering the [NTP Pool](http://www.pool.ntp.org/) system 4 | and other similar services. 5 | 6 | [![OpenSSF Best Practices](https://bestpractices.coreinfrastructure.org/projects/7022/badge)](https://bestpractices.coreinfrastructure.org/projects/7022) 7 | 8 | ## Questions or suggestions? 9 | 10 | For bug reports or feature requests, please create [an 11 | issue](https://github.com/abh/geodns/issues). For questions or 12 | discussion, you can post to the [GeoDNS 13 | category](https://community.ntppool.org/c/geodns) on the NTP Pool 14 | forum. 15 | 16 | ## Installation 17 | 18 | Release builds are available in a yum repository at 19 | `https://pkgs.ntppool.org/yum/` and apt (debian, ubuntu) packages at 20 | `https://pkgs.ntppool.org/apt/`. 21 | 22 | ### From source 23 | 24 | If you don't have Go installed the easiest way to build geodns from source is to 25 | download and install Go from `https://golang.org/dl/`. 26 | 27 | GeoDNS generally requires a recent version of Go (one of the last few major versions) 28 | 29 | ```sh 30 | git clone https://github.com/abh/geodns.git 31 | cd geodns 32 | go build 33 | ./geodns -h 34 | ``` 35 | 36 | You can also build with [goreleaser](https://github.com/goreleaser/goreleaser). 37 | 38 | ## Sample configuration 39 | 40 | There's a sample configuration file in `dns/example.com.json`. This is currently 41 | derived from the `test.example.com` data used for unit tests and not an example 42 | of a "best practices" configuration. 43 | 44 | For testing there's also a bigger test file at: 45 | 46 | ```sh 47 | mkdir -p dns 48 | curl -o dns/test.ntppool.org.json http://tmp.askask.com/2012/08/dns/ntppool.org.json.big 49 | ``` 50 | 51 | ## Run it 52 | 53 | After building the server you can run it with: 54 | 55 | `./geodns -log -interface 127.1 -port 5053` 56 | 57 | To test the responses run 58 | 59 | `dig -t a test.example.com @127.1 -p 5053` 60 | 61 | or 62 | 63 | `dig -t ptr 2.1.168.192.IN-ADDR.ARPA. @127.1 -p 5053` 64 | 65 | or more simply put 66 | 67 | `dig -x 192.168.1.2 @127.1 -p 5053` 68 | 69 | The binary can be moved to /usr/local/bin, /opt/geodns/ or wherever you find appropriate. 70 | 71 | ### Configuration 72 | 73 | See the [sample configuration file](https://github.com/abh/geodns/blob/main/dns/geodns.conf.sample). 74 | 75 | Notable command line parameters (and their defaults) 76 | 77 | * -config="./dns/" 78 | 79 | Directory of zone files (and configuration named `geodns.conf`). 80 | 81 | * -checkconfig=false 82 | 83 | Check configuration file, parse zone files and exit 84 | 85 | * -interface="*" 86 | 87 | Comma separated IPs to listen on for DNS requests. 88 | 89 | * -port="53" 90 | 91 | Port number for DNS requests (UDP and TCP) 92 | 93 | * -http=":8053" 94 | 95 | Listen address for HTTP interface. Specify as `127.0.0.1:8053` to only listen on 96 | localhost. 97 | 98 | * -identifier="" 99 | 100 | Identifier for this instance (hostname, pop name or similar). 101 | 102 | It can also be a comma separated list of identifiers where the first is the "server id" 103 | and subsequent ones are "group names", for example region of the server, name of anycast 104 | cluster the server is part of, etc. This is used in (future) reporting/statistics features. 105 | 106 | * -log=false 107 | 108 | Enable to get lots of extra logging, only useful for testing and debugging. Absolutely not 109 | recommended in production unless you get very few queries (less than 1-200/second). 110 | 111 | * -cpus=4 112 | 113 | Maximum number of CPUs to use. Set to 0 to match the number of CPUs 114 | available on the system (also the default). 115 | 116 | ## Logging 117 | 118 | GeoDNS supports query logging to JSON or Avro files (see the sample configuration file 119 | for options). 120 | 121 | ## Prometheus metrics 122 | 123 | `/metrics` on the http port provides a number of metrics in Prometheus format. 124 | 125 | ### Runtime status page, Websocket metrics & StatHat integration 126 | 127 | The runtime status page, websocket feature and StatHat integration have 128 | been replaced with Prometheus metrics. 129 | 130 | ## Country and continent lookups 131 | 132 | See zone targeting options below. 133 | 134 | ## Weighted records 135 | 136 | Most records can have a 'weight' assigned. If any records of a particular type 137 | for a particular name have a weight, the system will return `max_hosts` records 138 | (default 2). 139 | 140 | If the weight for all records is 0, all matching records will be returned. The 141 | weight for a label can be any integer as long as the weights for a label and record 142 | type is less than 2 billion. 143 | 144 | As an example, if you configure 145 | 146 | 10.0.0.1, weight 10 147 | 10.0.0.2, weight 20 148 | 10.0.0.3, weight 30 149 | 10.0.0.4, weight 40 150 | 151 | with `max_hosts` 2 then .4 will be returned about 4 times more often than .1. 152 | 153 | ## Configuration file 154 | 155 | The geodns.conf file allows you to specify a specific directory for the GeoIP 156 | data files and other options. See the `geodns.conf.sample` file for example 157 | configuration. 158 | 159 | The global configuration file is not reloaded at runtime. 160 | 161 | Most of the configuration is "per zone" and done in the zone .json files. 162 | The zone configuration files are automatically reloaded when they change. 163 | 164 | ## Zone format 165 | 166 | In the zone configuration file the whole zone is a big hash (associative array). 167 | At the top level you can (optionally) set some options with the keys serial, 168 | ttl and max_hosts. 169 | 170 | The actual zone data (dns records) is in a hash under the key "data". The keys 171 | in the hash are hostnames and the value for each hostname is yet another hash 172 | where the keys are record types (lowercase) and the values an array of records. 173 | 174 | For example to setup an MX record at the zone apex and then have a different 175 | A record for users in Europe than anywhere else, use: 176 | 177 | { 178 | "serial": 1, 179 | "data": { 180 | "": { 181 | "ns": [ "ns.example.net", "ns2.example.net" ], 182 | "txt": "Example zone", 183 | "spf": [ { "spf": "v=spf1 ~all", "weight": 1 } ], 184 | "mx": { "mx": "mail.example.com", "preference": 10 } 185 | }, 186 | "mail": { "a": [ ["192.168.0.1", 100], ["192.168.10.1", 50] ] }, 187 | "mail.europe": { "a": [ ["192.168.255.1", 0] ] }, 188 | "smtp": { "alias": "mail" } 189 | } 190 | } 191 | 192 | The configuration files are automatically reloaded when they're updated. If a file 193 | can't be read (invalid JSON, for example) the previous configuration for that zone 194 | will be kept. 195 | 196 | ## Zone options 197 | 198 | * serial 199 | 200 | GeoDNS doesn't support zone transfers (AXFR), so the serial number is only used 201 | for debugging and monitoring. The default is the 'last modified' timestamp of 202 | the zone file. 203 | 204 | * ttl 205 | 206 | Set the default TTL for the zone (default 120). 207 | 208 | * targeting 209 | 210 | * max_hosts 211 | 212 | * contact 213 | 214 | Set the soa 'contact' field (default is "hostmaster.$domain"). 215 | 216 | ## Zone targeting options 217 | 218 | @ 219 | 220 | country 221 | continent 222 | 223 | region and regiongroup 224 | 225 | ## Supported record types 226 | 227 | Each label has a hash (object/associative array) of record data, the keys are the type. 228 | The supported types and their options are listed below. 229 | 230 | Adding support for more record types is relatively straight forward, please open a 231 | ticket in the issue tracker with what you are missing. 232 | 233 | ### A 234 | 235 | Each record has the format of a short array with the first element being the 236 | IP address and the second the weight. 237 | 238 | [ [ "192.168.0.1", 10], ["192.168.2.1", 5] ] 239 | 240 | See above for how the weights work. 241 | 242 | ### AAAA 243 | 244 | Same format as A records (except the record type is "aaaa"). 245 | 246 | ### Alias 247 | 248 | Internally resolved cname, of sorts. Only works internally in a zone. 249 | 250 | "foo" 251 | 252 | ### CNAME 253 | 254 | "target.example.com." 255 | "www" 256 | 257 | The target will have the current zone name appended if it's not a FQDN (since v2.2.0). 258 | 259 | ### MX 260 | 261 | MX records support a `weight` similar to A records to indicate how often the particular 262 | record should be returned. 263 | 264 | The `preference` is the MX record preference returned to the client. 265 | 266 | { "mx": "foo.example.com" } 267 | { "mx": "foo.example.com", "weight": 100 } 268 | { "mx": "foo.example.com", "weight": 100, "preference": 10 } 269 | 270 | `weight` and `preference` are optional. 271 | 272 | ### NS 273 | 274 | NS records for the label, use it on the top level empty label (`""`) to specify 275 | the nameservers for the domain. 276 | 277 | [ "ns1.example.com", "ns2.example.com" ] 278 | 279 | There's an alternate legacy syntax that has space for glue records (IPv4 addresses), 280 | but in GeoDNS the values in the object are ignored so the list syntax above is 281 | recommended. 282 | 283 | { "ns1.example.net.": null, "ns2.example.net.": null } 284 | 285 | ### TXT 286 | 287 | Simple syntax 288 | 289 | "Some text" 290 | 291 | Or with weights 292 | 293 | { "txt": "Some text", "weight": 10 } 294 | 295 | ### SPF 296 | 297 | An SPF record is semantically identical to a TXT record with the exception that the label is set to 'spf'. An example of an spf record with weights: 298 | 299 | { "spf": "v=spf1 ~all]", "weight": 1 } 300 | 301 | An spf record is typically at the root of a zone, and a label can have an array of SPF records, e.g 302 | 303 | "spf": [ { "spf": "v=spf1 ~all", "weight": 1 } , "spf": "v=spf1 10.0.0.1", "weight": 100] 304 | 305 | ### SRV 306 | 307 | An SRV record has four components: the weight, priority, port and target. The keys for these are "srv_weight", "priority", "target" and "port". Note the difference between srv_weight (the weight key for the SRV qtype) and "weight". 308 | 309 | An example srv record definition for the _sip._tcp service: 310 | 311 | "_sip._tcp": { 312 | "srv": [ { "port": 5060, "srv_weight": 100, "priority": 10, "target": "sipserver.example.com."} ] 313 | }, 314 | 315 | Much like MX records, SRV records can have multiple targets, eg: 316 | 317 | "_http._tcp": { 318 | "srv": [ 319 | { "port": 80, "srv_weight": 10, "priority": 10, "target": "www.example.com."}, 320 | { "port": 8080, "srv_weight": 10, "priority": 20, "target": "www2.example.com."} 321 | ] 322 | }, 323 | 324 | ## License and Copyright 325 | 326 | This software is Copyright 2012-2015 Ask Bjørn Hansen. For licensing information 327 | please see the file called LICENSE. 328 | -------------------------------------------------------------------------------- /appconfig/appconfig.go: -------------------------------------------------------------------------------- 1 | package appconfig 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "sync" 8 | "time" 9 | 10 | "github.com/fsnotify/fsnotify" 11 | "gopkg.in/gcfg.v1" 12 | 13 | "github.com/abh/geodns/v3/targeting/geoip2" 14 | ) 15 | 16 | type AppConfig struct { 17 | DNS struct { 18 | PublicDebugQueries bool 19 | DetailedMetrics bool 20 | } 21 | GeoIP struct { 22 | Directory string 23 | } 24 | HTTP struct { 25 | User string 26 | Password string 27 | } 28 | QueryLog struct { 29 | Path string 30 | MaxSize int 31 | Keep int 32 | } 33 | AvroLog struct { 34 | Path string 35 | MaxSize int // rotate files at this size 36 | MaxTime string // rotate active files after this time, even if small 37 | } 38 | Health struct { 39 | Directory string 40 | } 41 | Nodeping struct { 42 | Token string 43 | } 44 | Pingdom struct { 45 | Username string 46 | 47 | Password string 48 | AccountEmail string 49 | AppKey string 50 | StateMap string 51 | } 52 | } 53 | 54 | // Singleton to keep the latest read config 55 | var Config = new(AppConfig) 56 | 57 | var cfgMutex sync.RWMutex 58 | 59 | func (conf *AppConfig) GeoIPDirectory() string { 60 | cfgMutex.RLock() 61 | defer cfgMutex.RUnlock() 62 | if len(conf.GeoIP.Directory) > 0 { 63 | return conf.GeoIP.Directory 64 | } 65 | return geoip2.FindDB() 66 | } 67 | 68 | func ConfigWatcher(ctx context.Context, fileName string) error { 69 | 70 | watcher, err := fsnotify.NewWatcher() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | if err := watcher.Add(fileName); err != nil { 76 | return err 77 | } 78 | 79 | for { 80 | select { 81 | case <-ctx.Done(): 82 | return nil 83 | case ev := <-watcher.Events: 84 | if ev.Name == fileName { 85 | // Write = when the file is updated directly 86 | // Rename = when it's updated atomicly 87 | // Chmod = for `touch` 88 | if ev.Has(fsnotify.Write) || 89 | ev.Has(fsnotify.Rename) || 90 | ev.Has(fsnotify.Chmod) { 91 | time.Sleep(200 * time.Millisecond) 92 | err := ConfigReader(fileName) 93 | if err != nil { 94 | // don't quit because we'll just keep the old config at this 95 | // stage and try again next it changes 96 | log.Printf("error reading config file: %s", err) 97 | } 98 | } 99 | } 100 | case err := <-watcher.Errors: 101 | log.Printf("fsnotify error: %s", err) 102 | } 103 | } 104 | } 105 | 106 | var lastReadConfig time.Time 107 | 108 | func ConfigReader(fileName string) error { 109 | 110 | stat, err := os.Stat(fileName) 111 | if err != nil { 112 | log.Printf("Failed to find config file: %s\n", err) 113 | return err 114 | } 115 | 116 | if !stat.ModTime().After(lastReadConfig) { 117 | return err 118 | } 119 | 120 | lastReadConfig = time.Now() 121 | 122 | log.Printf("Loading config: %s\n", fileName) 123 | 124 | cfg := new(AppConfig) 125 | 126 | err = gcfg.ReadFileInto(cfg, fileName) 127 | if err != nil { 128 | log.Printf("Failed to parse config data: %s\n", err) 129 | return err 130 | } 131 | 132 | cfgMutex.Lock() 133 | *Config = *cfg // shallow copy to prevent race conditions in referring to Config.foo() 134 | cfgMutex.Unlock() 135 | 136 | return nil 137 | } 138 | -------------------------------------------------------------------------------- /applog/log.go: -------------------------------------------------------------------------------- 1 | package applog 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | ) 8 | 9 | var Enabled bool 10 | 11 | type logToFile struct { 12 | fn string 13 | file *os.File 14 | closing chan chan error // channel to close the file. Pass a 'chan error' which returns the error 15 | } 16 | 17 | var ltf *logToFile 18 | 19 | func newlogToFile(fn string) *logToFile { 20 | return &logToFile{ 21 | fn: fn, 22 | file: nil, 23 | closing: make(chan chan error), 24 | } 25 | } 26 | 27 | func Printf(format string, a ...interface{}) { 28 | if Enabled { 29 | log.Printf(format, a...) 30 | } 31 | } 32 | 33 | func Println(a ...interface{}) { 34 | if Enabled { 35 | log.Println(a...) 36 | } 37 | } 38 | 39 | func logToFileMonitor() { 40 | for { 41 | select { 42 | case errc := <-ltf.closing: // a close has been requested 43 | if ltf.file != nil { 44 | log.SetOutput(os.Stderr) 45 | ltf.file.Close() 46 | ltf.file = nil 47 | } 48 | errc <- nil // pass a 'nil' error back, as everything worked fine 49 | return 50 | case <-time.After(time.Duration(5 * time.Second)): 51 | if fi, err := os.Stat(ltf.fn); err != nil || fi.Size() == 0 { 52 | // it has rotated - first check we can open the new file 53 | if f, err := os.OpenFile(ltf.fn, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666); err != nil { 54 | // Send the error to the current log file - not ideal 55 | log.Printf("Could not open new log file: %v", err) 56 | } else { 57 | log.SetOutput(f) 58 | log.Printf("Rotating log file") 59 | ltf.file.Close() 60 | ltf.file = f 61 | } 62 | } 63 | } 64 | } 65 | } 66 | 67 | func FileOpen(fn string) { 68 | ltf = newlogToFile(fn) 69 | 70 | var err error 71 | ltf.file, err = os.OpenFile(fn, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666) 72 | if err != nil { 73 | log.Fatalf("Error writing log file: %v", err) 74 | } 75 | // we deliberately do not close logFile here, because we keep it open pretty much for ever 76 | 77 | log.SetOutput(ltf.file) 78 | log.Printf("Opening log file") 79 | 80 | go logToFileMonitor() 81 | } 82 | 83 | func FileClose() { 84 | if ltf != nil { 85 | log.Printf("Closing log file") 86 | errc := make(chan error) // pass a 'chan error' through the closing channel 87 | ltf.closing <- errc 88 | _ = <-errc // wait until the monitor has closed the log file and exited 89 | close(ltf.closing) // close our 'chan error' channel 90 | ltf = nil 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /build: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -ex 4 | 5 | echo building 6 | # git describe --always --tags --dirty --long) 7 | REVISION=`git rev-parse --short=5 HEAD` 8 | BUILDTIME=`TZ=UTC date "+%Y-%m-%dT%H:%MZ"` 9 | echo $REVISION > REVISION 10 | 11 | OS=${GOOS:-`go env GOOS`} 12 | ARCH=${GOARCH:-`go env GOARCH`} 13 | 14 | set -ex 15 | 16 | go build -o dist/geodns-$OS-$ARCH \ 17 | -trimpath \ 18 | -ldflags "-X main.gitVersion=$REVISION -X main.buildTime=$BUILDTIME" \ 19 | -v && \ 20 | cd dist && \ 21 | rm -f service && \ 22 | ln -s ../service . && \ 23 | tar -cvhf geodns-$OS-$ARCH.tar \ 24 | --exclude \*~ geodns-$OS-$ARCH service 25 | -------------------------------------------------------------------------------- /config_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abh/geodns/v3/appconfig" 7 | ) 8 | 9 | func TestConfig(t *testing.T) { 10 | // check that the sample config parses 11 | err := appconfig.ConfigReader("dns/geodns.conf.sample") 12 | if err != nil { 13 | t.Fatalf("Could not read config: %s", err) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /countries/countries.go: -------------------------------------------------------------------------------- 1 | package countries 2 | 3 | var CountryContinent = map[string]string{ 4 | "ad": "europe", 5 | "ae": "asia", 6 | "af": "asia", 7 | "ag": "north-america", 8 | "ai": "north-america", 9 | "al": "europe", 10 | "am": "asia", 11 | "an": "north-america", 12 | "ao": "africa", 13 | "ap": "asia", 14 | "aq": "antarctica", 15 | "ar": "south-america", 16 | "as": "oceania", 17 | "at": "europe", 18 | "au": "oceania", 19 | "aw": "north-america", 20 | "ax": "europe", 21 | "az": "asia", 22 | "ba": "europe", 23 | "bb": "north-america", 24 | "bd": "asia", 25 | "be": "europe", 26 | "bf": "africa", 27 | "bg": "europe", 28 | "bh": "asia", 29 | "bi": "africa", 30 | "bj": "africa", 31 | "bl": "north-america", 32 | "bm": "north-america", 33 | "bn": "asia", 34 | "bo": "south-america", 35 | "bq": "north-america", 36 | "br": "south-america", 37 | "bs": "north-america", 38 | "bt": "asia", 39 | "bv": "antarctica", 40 | "bw": "africa", 41 | "by": "europe", 42 | "bz": "north-america", 43 | "ca": "north-america", 44 | "cc": "asia", 45 | "cd": "africa", 46 | "cf": "africa", 47 | "cg": "africa", 48 | "ch": "europe", 49 | "ci": "africa", 50 | "ck": "oceania", 51 | "cl": "south-america", 52 | "cm": "africa", 53 | "cn": "asia", 54 | "co": "south-america", 55 | "cr": "north-america", 56 | "cu": "north-america", 57 | "cv": "africa", 58 | "cw": "north-america", 59 | "cx": "oceania", 60 | "cy": "europe", 61 | "cz": "europe", 62 | "de": "europe", 63 | "dj": "africa", 64 | "dk": "europe", 65 | "dm": "north-america", 66 | "do": "north-america", 67 | "dz": "africa", 68 | "ec": "south-america", 69 | "ee": "europe", 70 | "eg": "africa", 71 | "eh": "africa", 72 | "er": "africa", 73 | "es": "europe", 74 | "et": "africa", 75 | "eu": "europe", 76 | "fi": "europe", 77 | "fj": "oceania", 78 | "fk": "south-america", 79 | "fm": "oceania", 80 | "fo": "europe", 81 | "fr": "europe", 82 | "fx": "europe", 83 | "ga": "africa", 84 | "gb": "europe", 85 | "gd": "north-america", 86 | "ge": "asia", 87 | "gf": "south-america", 88 | "gg": "europe", 89 | "gh": "africa", 90 | "gi": "europe", 91 | "gl": "north-america", 92 | "gm": "africa", 93 | "gn": "africa", 94 | "gp": "north-america", 95 | "gq": "africa", 96 | "gr": "europe", 97 | "gs": "antarctica", 98 | "gt": "north-america", 99 | "gu": "oceania", 100 | "gw": "africa", 101 | "gy": "south-america", 102 | "hk": "asia", 103 | "hm": "antarctica", 104 | "hn": "north-america", 105 | "hr": "europe", 106 | "ht": "north-america", 107 | "hu": "europe", 108 | "id": "asia", 109 | "ie": "europe", 110 | "il": "asia", 111 | "im": "europe", 112 | "in": "asia", 113 | "io": "asia", 114 | "iq": "asia", 115 | "ir": "asia", 116 | "is": "europe", 117 | "it": "europe", 118 | "je": "europe", 119 | "jm": "north-america", 120 | "jo": "asia", 121 | "jp": "asia", 122 | "ke": "africa", 123 | "kg": "asia", 124 | "kh": "asia", 125 | "ki": "oceania", 126 | "km": "africa", 127 | "kn": "north-america", 128 | "kp": "asia", 129 | "kr": "asia", 130 | "kw": "asia", 131 | "ky": "north-america", 132 | "kz": "asia", 133 | "la": "asia", 134 | "lb": "asia", 135 | "lc": "north-america", 136 | "li": "europe", 137 | "lk": "asia", 138 | "lr": "africa", 139 | "ls": "africa", 140 | "lt": "europe", 141 | "lu": "europe", 142 | "lv": "europe", 143 | "ly": "africa", 144 | "ma": "africa", 145 | "mc": "europe", 146 | "md": "europe", 147 | "me": "europe", 148 | "mf": "north-america", 149 | "mg": "africa", 150 | "mh": "oceania", 151 | "mk": "europe", 152 | "ml": "africa", 153 | "mm": "asia", 154 | "mn": "asia", 155 | "mo": "asia", 156 | "mp": "oceania", 157 | "mq": "north-america", 158 | "mr": "africa", 159 | "ms": "north-america", 160 | "mt": "europe", 161 | "mu": "africa", 162 | "mv": "asia", 163 | "mw": "africa", 164 | "mx": "north-america", 165 | "my": "asia", 166 | "mz": "africa", 167 | "na": "africa", 168 | "nc": "oceania", 169 | "ne": "africa", 170 | "nf": "oceania", 171 | "ng": "africa", 172 | "ni": "north-america", 173 | "nl": "europe", 174 | "no": "europe", 175 | "np": "asia", 176 | "nr": "oceania", 177 | "nu": "oceania", 178 | "nz": "oceania", 179 | "om": "asia", 180 | "pa": "north-america", 181 | "pe": "south-america", 182 | "pf": "oceania", 183 | "pg": "oceania", 184 | "ph": "asia", 185 | "pk": "asia", 186 | "pl": "europe", 187 | "pm": "north-america", 188 | "pn": "oceania", 189 | "pr": "north-america", 190 | "ps": "asia", 191 | "pt": "europe", 192 | "pw": "oceania", 193 | "py": "south-america", 194 | "qa": "asia", 195 | "re": "africa", 196 | "ro": "europe", 197 | "rs": "europe", 198 | "ru": "europe", 199 | "rw": "africa", 200 | "sa": "asia", 201 | "sb": "oceania", 202 | "sc": "africa", 203 | "sd": "africa", 204 | "se": "europe", 205 | "sg": "asia", 206 | "sh": "africa", 207 | "si": "europe", 208 | "sj": "europe", 209 | "sk": "europe", 210 | "sl": "africa", 211 | "sm": "europe", 212 | "sn": "africa", 213 | "so": "africa", 214 | "sr": "south-america", 215 | "ss": "africa", 216 | "st": "africa", 217 | "sv": "north-america", 218 | "sx": "north-america", 219 | "sy": "asia", 220 | "sz": "africa", 221 | "tc": "north-america", 222 | "td": "africa", 223 | "tf": "antarctica", 224 | "tg": "africa", 225 | "th": "asia", 226 | "tj": "asia", 227 | "tk": "oceania", 228 | "tl": "oceania", 229 | "tm": "asia", 230 | "tn": "africa", 231 | "to": "oceania", 232 | "tr": "europe", 233 | "tt": "north-america", 234 | "tv": "oceania", 235 | "tw": "asia", 236 | "tz": "africa", 237 | "ua": "europe", 238 | "ug": "africa", 239 | "um": "oceania", 240 | "us": "north-america", 241 | "uy": "south-america", 242 | "uz": "asia", 243 | "va": "europe", 244 | "vc": "north-america", 245 | "ve": "south-america", 246 | "vg": "north-america", 247 | "vi": "north-america", 248 | "vn": "asia", 249 | "vu": "oceania", 250 | "wf": "oceania", 251 | "ws": "oceania", 252 | "xk": "europe", 253 | "ye": "asia", 254 | "yt": "africa", 255 | "za": "africa", 256 | "zm": "africa", 257 | "zw": "africa", 258 | } 259 | 260 | var ContinentCountries = map[string][]string{} 261 | 262 | func init() { 263 | for cc, co := range CountryContinent { 264 | if _, ok := ContinentCountries[co]; !ok { 265 | ContinentCountries[co] = []string{} 266 | } 267 | ContinentCountries[co] = append(ContinentCountries[co], cc) 268 | } 269 | } 270 | -------------------------------------------------------------------------------- /countries/regiongroups.go: -------------------------------------------------------------------------------- 1 | package countries 2 | 3 | import ( 4 | "log" 5 | ) 6 | 7 | var RegionGroups = map[string]string{ 8 | "us-ak": "us-west", 9 | "us-az": "us-west", 10 | "us-ca": "us-west", 11 | "us-co": "us-west", 12 | "us-hi": "us-west", 13 | "us-id": "us-west", 14 | "us-mt": "us-west", 15 | "us-nm": "us-west", 16 | "us-nv": "us-west", 17 | "us-or": "us-west", 18 | "us-ut": "us-west", 19 | "us-wa": "us-west", 20 | "us-wy": "us-west", 21 | 22 | "us-ar": "us-central", 23 | "us-ia": "us-central", 24 | "us-il": "us-central", 25 | "us-in": "us-central", 26 | "us-ks": "us-central", 27 | "us-la": "us-central", 28 | "us-mn": "us-central", 29 | "us-mo": "us-central", 30 | "us-nd": "us-central", 31 | "us-ne": "us-central", 32 | "us-ok": "us-central", 33 | "us-sd": "us-central", 34 | "us-tx": "us-central", 35 | "us-wi": "us-central", 36 | 37 | "us-al": "us-east", 38 | "us-ct": "us-east", 39 | "us-dc": "us-east", 40 | "us-de": "us-east", 41 | "us-fl": "us-east", 42 | "us-ga": "us-east", 43 | "us-ky": "us-east", 44 | "us-ma": "us-east", 45 | "us-md": "us-east", 46 | "us-me": "us-east", 47 | "us-mi": "us-east", 48 | "us-ms": "us-east", 49 | "us-nc": "us-east", 50 | "us-nh": "us-east", 51 | "us-nj": "us-east", 52 | "us-ny": "us-east", 53 | "us-oh": "us-east", 54 | "us-pa": "us-east", 55 | "us-ri": "us-east", 56 | "us-sc": "us-east", 57 | "us-tn": "us-east", 58 | "us-va": "us-east", 59 | "us-vt": "us-east", 60 | "us-wv": "us-east", 61 | 62 | // # Federal districts of Russia 63 | // Sources list (lowest priority on top) 64 | // - https://en.wikipedia.org/wiki/Federal_districts_of_Russia 65 | // - https://ru.wikipedia.org/wiki/ISO_3166-2:RU 66 | // - http://statoids.com/uru.html with updates https://en.wikipedia.org/wiki/Federal_districts_of_Russia#cite_note-15 67 | 68 | // Dal'nevostochnyy (D) Far Eastern 69 | "ru-amu": "ru-dfd", // Amur 70 | "ru-bu": "ru-dfd", // Buryat 71 | "ru-chu": "ru-dfd", // Chukot 72 | "ru-kam": "ru-dfd", // Kamchatka 73 | "ru-kha": "ru-dfd", // Khabarovsk 74 | "ru-mag": "ru-dfd", // Magadan 75 | "ru-pri": "ru-dfd", // Primor'ye 76 | "ru-sa": "ru-dfd", // Sakha 77 | "ru-sak": "ru-dfd", // Sakhalin 78 | "ru-yev": "ru-dfd", // Yevrey 79 | "ru-zab": "ru-dfd", // Zabaykal'ye 80 | 81 | // Severo-Kavkazskiy' (K) North Caucasus 82 | "ru-ce": "ru-kfd", // Chechnya 83 | "ru-da": "ru-kfd", // Dagestan 84 | "ru-in": "ru-kfd", // Ingush 85 | "ru-kb": "ru-kfd", // Kabardin-Balkar 86 | "ru-kc": "ru-kfd", // Karachay-Cherkess 87 | "ru-se": "ru-kfd", // North Ossetia 88 | "ru-sta": "ru-kfd", // Stavropol' 89 | 90 | // Privolzhskiy (P) Volga 91 | "ru-ba": "ru-pfd", // Bashkortostan 92 | "ru-cu": "ru-pfd", // Chuvash 93 | "ru-kir": "ru-pfd", // Kirov 94 | "ru-me": "ru-pfd", // Mariy-El 95 | "ru-mo": "ru-pfd", // Mordovia 96 | "ru-niz": "ru-pfd", // Nizhegorod 97 | "ru-ore": "ru-pfd", // Orenburg 98 | "ru-pnz": "ru-pfd", // Penza 99 | "ru-per": "ru-pfd", // Perm' 100 | "ru-sam": "ru-pfd", // Samara 101 | "ru-sar": "ru-pfd", // Saratov 102 | "ru-ta": "ru-pfd", // Tatarstan 103 | "ru-ud": "ru-pfd", // Udmurt 104 | "ru-uly": "ru-pfd", // Ul'yanovsk 105 | 106 | // Sibirskiy (S) Siberian 107 | "ru-alt": "ru-sfd", // Altay 108 | "ru-al": "ru-sfd", // Gorno-Altay 109 | "ru-irk": "ru-sfd", // Irkutsk 110 | "ru-kem": "ru-sfd", // Kemerovo 111 | "ru-kk": "ru-sfd", // Khakass 112 | "ru-kya": "ru-sfd", // Krasnoyarsk 113 | "ru-nvs": "ru-sfd", // Novosibirsk 114 | "ru-oms": "ru-sfd", // Omsk 115 | "ru-tom": "ru-sfd", // Tomsk 116 | "ru-ty": "ru-sfd", // Tuva 117 | 118 | // Tsentral'nyy (T) Central 119 | "ru-bel": "ru-tfd", // Belgorod 120 | "ru-bry": "ru-tfd", // Bryansk 121 | "ru-iva": "ru-tfd", // Ivanovo 122 | "ru-klu": "ru-tfd", // Kaluga 123 | "ru-kos": "ru-tfd", // Kostroma 124 | "ru-krs": "ru-tfd", // Kursk 125 | "ru-lip": "ru-tfd", // Lipetsk 126 | "ru-mow": "ru-tfd", // Moscow City 127 | "ru-mos": "ru-tfd", // Moskva 128 | "ru-orl": "ru-tfd", // Orel 129 | "ru-rya": "ru-tfd", // Ryazan' 130 | "ru-smo": "ru-tfd", // Smolensk 131 | "ru-tam": "ru-tfd", // Tambov 132 | "ru-tul": "ru-tfd", // Tula 133 | "ru-tve": "ru-tfd", // Tver' 134 | "ru-vla": "ru-tfd", // Vladimir 135 | "ru-vor": "ru-tfd", // Voronezh 136 | "ru-yar": "ru-tfd", // Yaroslavl' 137 | 138 | // Ural'skiy (U) Ural 139 | "ru-che": "ru-ufd", // Chelyabinsk 140 | "ru-khm": "ru-ufd", // Khanty-Mansiy 141 | "ru-kgn": "ru-ufd", // Kurgan 142 | "ru-sve": "ru-ufd", // Sverdlovsk 143 | "ru-tyu": "ru-ufd", // Tyumen' 144 | "ru-yan": "ru-ufd", // Yamal-Nenets 145 | 146 | // Severo-Zapadnyy (V) Northwestern 147 | "ru-ark": "ru-vfd", // Arkhangel'sk 148 | "ru-kgd": "ru-vfd", // Kaliningrad 149 | "ru-kr": "ru-vfd", // Karelia 150 | "ru-ko": "ru-vfd", // Komi 151 | "ru-len": "ru-vfd", // Leningrad 152 | "ru-mur": "ru-vfd", // Murmansk 153 | "ru-nen": "ru-vfd", // Nenets 154 | "ru-ngr": "ru-vfd", // Novgorod 155 | "ru-psk": "ru-vfd", // Pskov 156 | "ru-spe": "ru-vfd", // Saint Petersburg City 157 | "ru-vlg": "ru-vfd", // Vologda 158 | 159 | // Yuzhnyy (Y) Southern 160 | "ru-ad": "ru-yfd", // Adygey 161 | "ru-ast": "ru-yfd", // Astrakhan' 162 | "ru-kl": "ru-yfd", // Kalmyk 163 | "ru-kda": "ru-yfd", // Krasnodar 164 | "ru-ros": "ru-yfd", // Rostov 165 | "ru-vgg": "ru-yfd", // Volgograd 166 | } 167 | 168 | var RegionGroupRegions = map[string][]string{} 169 | 170 | func CountryRegionGroup(country, region string) string { 171 | 172 | if country != "us" && country != "ru" { 173 | return "" 174 | } 175 | 176 | if group, ok := RegionGroups[region]; ok { 177 | return group 178 | } 179 | 180 | log.Printf("Did not find a region group for '%s'/'%s'", country, region) 181 | return "" 182 | } 183 | 184 | func init() { 185 | for ccrc, rg := range RegionGroups { 186 | RegionGroupRegions[rg] = append(RegionGroupRegions[rg], ccrc) 187 | } 188 | } 189 | -------------------------------------------------------------------------------- /dayduration.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | // Add a function similar to time.Duration.String() to 4 | // pretty print an "uptime duration". 5 | 6 | import ( 7 | "time" 8 | ) 9 | 10 | type DayDuration struct { 11 | time.Duration 12 | } 13 | 14 | func fmtInt(buf []byte, v uint64) int { 15 | w := len(buf) 16 | if v == 0 { 17 | w-- 18 | buf[w] = '0' 19 | } else { 20 | for v > 0 { 21 | w-- 22 | buf[w] = byte(v%10) + '0' 23 | v /= 10 24 | } 25 | } 26 | return w 27 | } 28 | 29 | // DayString returns string version of the time duration without too much precision. 30 | // Copied from time/time.go 31 | func (d DayDuration) DayString() string { 32 | var buf [32]byte 33 | w := len(buf) 34 | 35 | u := uint64(d.Nanoseconds()) 36 | 37 | neg := d.Nanoseconds() < 0 38 | if neg { 39 | u = -u 40 | } 41 | 42 | if u < uint64(time.Second) { 43 | // Don't show times less than a second 44 | w -= 2 45 | buf[w] = '0' 46 | buf[w+1] = 's' 47 | } else { 48 | w-- 49 | buf[w] = 's' 50 | 51 | // Skip fractional seconds 52 | u /= uint64(time.Second) 53 | 54 | // u is now integer seconds 55 | w = fmtInt(buf[:w], u%60) 56 | u /= 60 57 | 58 | // u is now integer minutes 59 | if u > 0 { 60 | w-- 61 | buf[w] = ' ' 62 | w-- 63 | buf[w] = 'm' 64 | w = fmtInt(buf[:w], u%60) 65 | u /= 60 66 | 67 | // u is now integer hours 68 | if u > 0 { 69 | w-- 70 | buf[w] = ' ' 71 | w-- 72 | buf[w] = 'h' 73 | w = fmtInt(buf[:w], u%24) 74 | u /= 24 75 | } 76 | 77 | // u is now integer days 78 | if u > 0 { 79 | w-- 80 | buf[w] = ' ' 81 | w-- 82 | buf[w] = 'd' 83 | w = fmtInt(buf[:w], u) 84 | } 85 | 86 | } 87 | } 88 | 89 | if neg { 90 | w-- 91 | buf[w] = '-' 92 | } 93 | 94 | return string(buf[w:]) 95 | } 96 | -------------------------------------------------------------------------------- /dns/1.168.192.in-addr.arpa.json: -------------------------------------------------------------------------------- 1 | { 2 | "serial": 3, 3 | "ttl": 600, 4 | "max_hosts": 2, 5 | "origin": "1.168.192.IN-ADDR.ARPA.", 6 | "logging": { 7 | "stathat": true, 8 | "stathat_api": "abc-test" 9 | }, 10 | "targeting": "country continent @ regiongroup region ip asn", 11 | "contact": "support.bitnames.com", 12 | "data": { 13 | "": { 14 | "ns": { 15 | "ns1.example.net.": null, 16 | "ns2.example.net.": null 17 | } 18 | }, 19 | "2": { 20 | "ptr": [ 21 | [ 22 | "bar.example.com." 23 | ] 24 | ], 25 | "ttl": "601" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /dns/Makefile: -------------------------------------------------------------------------------- 1 | pretty: 2 | bash -c 'for f in *.json; do echo $$f; jq . < $$f > $$f.tmp && mv $$f.tmp $$f; done' 3 | -------------------------------------------------------------------------------- /dns/example.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "serial": 3, 3 | "ttl": 600, 4 | "max_hosts": 2, 5 | "data": { 6 | "": { 7 | "ns": { 8 | "ns1.example.net.": null, 9 | "ns2.example.net.": null 10 | }, 11 | "mx": [ 12 | { 13 | "preference": 20, 14 | "mx": "mx2.example.net", 15 | "weight": 0 16 | }, 17 | { 18 | "preference": 10, 19 | "mx": "mx.example.net.", 20 | "weight": 1 21 | } 22 | ] 23 | }, 24 | "europe": { 25 | "mx": [ 26 | { 27 | "mx": "mx-eu.example.net" 28 | } 29 | ] 30 | }, 31 | "foo": { 32 | "a": [ 33 | [ 34 | "192.168.1.2", 35 | 10 36 | ], 37 | [ 38 | "192.168.1.3", 39 | 10 40 | ], 41 | [ 42 | "192.168.1.4", 43 | 10 44 | ] 45 | ], 46 | "aaaa": [ 47 | [ 48 | "fd06:c1d3:e902::2", 49 | 10 50 | ], 51 | [ 52 | "fd06:c1d3:e902:202:a5ff:fecd:13a6:a", 53 | 10 54 | ], 55 | [ 56 | "fd06:c1d3:e902::4", 57 | 10 58 | ] 59 | ] 60 | }, 61 | "weight": { 62 | "a": [ 63 | [ 64 | "192.168.1.2", 65 | 100 66 | ], 67 | [ 68 | "192.168.1.3", 69 | 50 70 | ], 71 | [ 72 | "192.168.1.4", 73 | 25 74 | ] 75 | ], 76 | "max_hosts": "1" 77 | }, 78 | "bar": { 79 | "a": [ 80 | [ 81 | "192.168.1.2", 82 | 10 83 | ] 84 | ], 85 | "ttl": "601" 86 | }, 87 | "bar.no": { 88 | "a": [] 89 | }, 90 | "0": { 91 | "a": [ 92 | [ 93 | "192.168.0.1", 94 | 10 95 | ] 96 | ] 97 | }, 98 | "0-alias": { 99 | "alias": "0" 100 | }, 101 | "bar-alias": { 102 | "alias": "bar" 103 | }, 104 | "www-alias": { 105 | "alias": "www" 106 | }, 107 | "www": { 108 | "cname": "geo.bitnames.com." 109 | }, 110 | "cname-long-ttl": { 111 | "cname": "geo.bitnames.com.", 112 | "ttl": 86400 113 | }, 114 | "cname-internal-referal": { 115 | "cname": "bar" 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /dns/geodns.conf.sample: -------------------------------------------------------------------------------- 1 | ; GeoDNS configuration file 2 | ; 3 | ; It is recommended to distribute the configuration file globally 4 | ; with your .json zone files. 5 | 6 | [dns] 7 | # allow _status queries from anywhere (versus only localhost) 8 | publicdebugqueries = false 9 | # include query label in prometheus metrics 10 | detailedmetrics = true 11 | 12 | [geoip] 13 | ;; Directory containing the GeoIP2 .mmdb database files; defaults 14 | ;; to looking through a list of common directories looking for one 15 | ;; of those that exists. 16 | ;directory=/usr/local/share/GeoIP/ 17 | 18 | [querylog] 19 | ;; directory to save query logs; disabled if not specified 20 | path = log/queries.log 21 | ;; max size per file in megabytes before rotating (default 200) 22 | ; maxsize = 100 23 | ;; keep up to this many rotated log files (default 1) 24 | ; keep = 2 25 | 26 | 27 | ;; avro logging will replace the json querylog if configured 28 | ; [avrolog] 29 | ;; The avro schema is specified in https://github.com/abh/geodns/blob/main/querylog/querylog.avsc 30 | ;; files being written are suffixed .tmp; closed files are suffixed .avro 31 | ; path = log/avro/ 32 | ;; rotate file after it reaches this size 33 | ; maxsize = 5000000 34 | ;; rotate the file after this many seconds 35 | ; maxtime = 10s 36 | 37 | [http] 38 | ; require basic HTTP authentication; not encrypted or safe over the public internet 39 | ; user = stats 40 | ; password = Aeteereun8eoth4 41 | 42 | [health] 43 | ; directory = dns/health 44 | -------------------------------------------------------------------------------- /dns/hc.example.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "serial": 3, 3 | "ttl": 600, 4 | "max_hosts": 2, 5 | "targeting": "country continent @ regiongroup region", 6 | "data": { 7 | "": { 8 | "ns": { 9 | "ns1.example.net.": null, 10 | "ns2.example.net.": null 11 | }, 12 | "mx": [ 13 | { 14 | "preference": 20, 15 | "mx": "mx2.example.net", 16 | "weight": 0 17 | }, 18 | { 19 | "preference": 10, 20 | "mx": "mx.example.net.", 21 | "weight": 1 22 | } 23 | ] 24 | }, 25 | "tucs": { 26 | "a": [ 27 | [ 28 | "194.106.223.155", 29 | 100 30 | ], 31 | [ 32 | "199.15.176.188", 33 | 100 34 | ], 35 | [ 36 | "207.171.7.49", 37 | 100 38 | ], 39 | [ 40 | "207.171.7.59", 41 | 100 42 | ], 43 | [ 44 | "207.171.7.64", 45 | 100 46 | ], 47 | [ 48 | "207.171.7.65", 49 | 100 50 | ] 51 | ], 52 | "max_hosts": "1", 53 | "closest": true, 54 | "health": { 55 | "type": "tcp", 56 | "frequency": 15, 57 | "retry_time": 5, 58 | "retries": 2, 59 | "timeout": 3, 60 | "port": 80 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /dns/test.example.com.json: -------------------------------------------------------------------------------- 1 | { 2 | "serial": 3, 3 | "ttl": 600, 4 | "max_hosts": 2, 5 | "logging": {}, 6 | "targeting": "country continent @ regiongroup region ip asn", 7 | "contact": "support.bitnames.com", 8 | "data": { 9 | "": { 10 | "ns": { 11 | "ns1.example.net.": null, 12 | "ns2.example.net.": null 13 | }, 14 | "spf": [ 15 | { 16 | "spf": "v=spf1 ~all", 17 | "weight": 1000 18 | } 19 | ], 20 | "mx": [ 21 | { 22 | "preference": 20, 23 | "mx": "mx2.example.net", 24 | "weight": 0 25 | }, 26 | { 27 | "preference": 10, 28 | "mx": "mx.example.net.", 29 | "weight": 1 30 | } 31 | ] 32 | }, 33 | "europe": { 34 | "mx": [ 35 | { 36 | "mx": "mx-eu.example.net" 37 | } 38 | ] 39 | }, 40 | "foo": { 41 | "a": [ 42 | [ 43 | "192.168.1.2", 44 | 10 45 | ], 46 | [ 47 | "192.168.1.3", 48 | 10 49 | ], 50 | [ 51 | "192.168.1.4", 52 | 10 53 | ] 54 | ], 55 | "aaaa": [ 56 | [ 57 | "fd06:c1d3:e902::2", 58 | 10 59 | ], 60 | [ 61 | "fd06:c1d3:e902:202:a5ff:fecd:13a6:a", 62 | 10 63 | ], 64 | [ 65 | "fd06:c1d3:e902::4", 66 | 10 67 | ] 68 | ], 69 | "txt": "this is foo" 70 | }, 71 | "weight": { 72 | "a": [ 73 | [ 74 | "192.168.1.2", 75 | 100 76 | ], 77 | [ 78 | "192.168.1.3", 79 | 50 80 | ], 81 | [ 82 | "192.168.1.4", 83 | 25 84 | ] 85 | ], 86 | "txt": [ 87 | { 88 | "txt": "w10000", 89 | "weight": 10000 90 | }, 91 | { 92 | "txt": "w1", 93 | "weight": 1 94 | } 95 | ], 96 | "max_hosts": "1" 97 | }, 98 | "_sip._tcp": { 99 | "srv": [ 100 | { 101 | "port": 5060, 102 | "srv_weight": 100, 103 | "priority": 10, 104 | "target": "sipserver.example.com." 105 | } 106 | ] 107 | }, 108 | "bar": { 109 | "a": [ 110 | [ 111 | "192.168.1.2" 112 | ] 113 | ], 114 | "ttl": "601" 115 | }, 116 | "three.two.one": { 117 | "a": [ 118 | [ 119 | "192.168.1.5" 120 | ] 121 | ], 122 | "ttl": "601" 123 | }, 124 | "one": { 125 | "a": [ 126 | [ 127 | "192.168.1.6" 128 | ] 129 | ], 130 | "ttl": "601" 131 | }, 132 | "a.b.c": { 133 | "a": [ 134 | [ 135 | "192.168.1.7" 136 | ] 137 | ], 138 | "ttl": "601" 139 | }, 140 | "bar.no": { 141 | "a": [] 142 | }, 143 | "bar.as15169": { 144 | "a": [ 145 | [ 146 | "192.168.1.4" 147 | ] 148 | ] 149 | }, 150 | "bar.[1.0.0.255]": { 151 | "a": [ 152 | [ 153 | "192.168.1.3" 154 | ] 155 | ] 156 | }, 157 | "0": { 158 | "a": [ 159 | [ 160 | "192.168.0.1", 161 | 10 162 | ] 163 | ] 164 | }, 165 | "0-alias": { 166 | "alias": "0" 167 | }, 168 | "bar-alias": { 169 | "alias": "bar" 170 | }, 171 | "root-alias": { 172 | "alias": "" 173 | }, 174 | "www-alias": { 175 | "alias": "www" 176 | }, 177 | "www": { 178 | "cname": "geo.bitnames.com.", 179 | "ttl": 1800 180 | }, 181 | "www.europe": { 182 | "cname": "geo-europe.bitnames.com." 183 | }, 184 | "www.se": { 185 | "cname": [ 186 | [ 187 | "geo-europe", 188 | 10 189 | ], 190 | [ 191 | "geo-dk", 192 | 10 193 | ] 194 | ] 195 | }, 196 | "www-cname": { 197 | "cname": "bar" 198 | }, 199 | "cname-long-ttl": { 200 | "cname": "geo.bitnames.com.", 201 | "ttl": 86400 202 | }, 203 | "cname-internal-referal": { 204 | "cname": "bar" 205 | }, 206 | "closest": { 207 | "a": [ 208 | [ 209 | "194.106.223.155", 210 | 100 211 | ], 212 | [ 213 | "207.171.7.49", 214 | 100 215 | ], 216 | [ 217 | "207.171.7.59", 218 | 100 219 | ] 220 | ], 221 | "aaaa": [ 222 | { 223 | "aaaa": "2a07:2180:0:1::400" 224 | }, 225 | { 226 | "ip": "2607:f238:3::1:45" 227 | }, 228 | { 229 | "ip": "2403:300:a0c:f000::1" 230 | } 231 | ], 232 | "max_hosts": "1", 233 | "closest": true 234 | } 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /dns/test.example.org.json: -------------------------------------------------------------------------------- 1 | { 2 | "data": { 3 | "bad-example-there-really-should-be-an-ns-record-at-the-apex-here": {}, 4 | "bar": { 5 | "a": [ 6 | [ 7 | "192.168.1.2" 8 | ] 9 | ] 10 | }, 11 | "sub-alias": { 12 | "alias": "sub" 13 | }, 14 | "sub": { 15 | "ns": [ 16 | "ns1.example.com", 17 | "ns2.example.com" 18 | ] 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /edns/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | This is from github.com/coredns/coredns/plugin/pkg/edns/ 4 | -------------------------------------------------------------------------------- /edns/edns.go: -------------------------------------------------------------------------------- 1 | // Package edns provides function useful for adding/inspecting OPT records to/in messages. 2 | package edns 3 | 4 | import ( 5 | "errors" 6 | "sync" 7 | 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | var sup = &supported{m: make(map[uint16]struct{})} 12 | 13 | type supported struct { 14 | m map[uint16]struct{} 15 | sync.RWMutex 16 | } 17 | 18 | // SetSupportedOption adds a new supported option the set of EDNS0 options that we support. Plugins typically call 19 | // this in their setup code to signal support for a new option. 20 | // By default we support: 21 | // dns.EDNS0NSID, dns.EDNS0EXPIRE, dns.EDNS0COOKIE, dns.EDNS0TCPKEEPALIVE, dns.EDNS0PADDING. These 22 | // values are not in this map and checked directly in the server. 23 | func SetSupportedOption(option uint16) { 24 | sup.Lock() 25 | sup.m[option] = struct{}{} 26 | sup.Unlock() 27 | } 28 | 29 | // SupportedOption returns true if the option code is supported as an extra EDNS0 option. 30 | func SupportedOption(option uint16) bool { 31 | sup.RLock() 32 | _, ok := sup.m[option] 33 | sup.RUnlock() 34 | return ok 35 | } 36 | 37 | // Version checks the EDNS version in the request. If error 38 | // is nil everything is OK and we can invoke the plugin. If non-nil, the 39 | // returned Msg is valid to be returned to the client (and should). For some 40 | // reason this response should not contain a question RR in the question section. 41 | func Version(req *dns.Msg) (*dns.Msg, error) { 42 | opt := req.IsEdns0() 43 | if opt == nil { 44 | return nil, nil 45 | } 46 | if opt.Version() == 0 { 47 | return nil, nil 48 | } 49 | m := new(dns.Msg) 50 | m.SetReply(req) 51 | // zero out question section, wtf. 52 | m.Question = nil 53 | 54 | o := new(dns.OPT) 55 | o.Hdr.Name = "." 56 | o.Hdr.Rrtype = dns.TypeOPT 57 | o.SetVersion(0) 58 | m.Rcode = dns.RcodeBadVers 59 | o.SetExtendedRcode(dns.RcodeBadVers) 60 | m.Extra = []dns.RR{o} 61 | 62 | return m, errors.New("EDNS0 BADVERS") 63 | } 64 | 65 | // Size returns a normalized size based on proto. 66 | func Size(proto string, size uint16) uint16 { 67 | if proto == "tcp" { 68 | return dns.MaxMsgSize 69 | } 70 | if size < dns.MinMsgSize { 71 | return dns.MinMsgSize 72 | } 73 | return size 74 | } 75 | 76 | /* 77 | 78 | The below wasn't from the edns package 79 | 80 | */ 81 | 82 | // SetSizeAndDo adds an OPT record that the reflects the intent from request. 83 | func SetSizeAndDo(req, m *dns.Msg) *dns.OPT { 84 | o := req.IsEdns0() 85 | if o == nil { 86 | return nil 87 | } 88 | 89 | if mo := m.IsEdns0(); mo != nil { 90 | mo.Hdr.Name = "." 91 | mo.Hdr.Rrtype = dns.TypeOPT 92 | mo.SetVersion(0) 93 | mo.SetUDPSize(o.UDPSize()) 94 | mo.Hdr.Ttl &= 0xff00 // clear flags 95 | 96 | // Assume if the message m has options set, they are OK and represent what an upstream can do. 97 | 98 | if o.Do() { 99 | mo.SetDo() 100 | } 101 | return mo 102 | } 103 | 104 | // Reuse the request's OPT record and tack it to m. 105 | o.Hdr.Name = "." 106 | o.Hdr.Rrtype = dns.TypeOPT 107 | o.SetVersion(0) 108 | o.Hdr.Ttl &= 0xff00 // clear flags 109 | 110 | if len(o.Option) > 0 { 111 | o.Option = SupportedOptions(o.Option) 112 | } 113 | 114 | m.Extra = append(m.Extra, o) 115 | return o 116 | } 117 | 118 | func SupportedOptions(o []dns.EDNS0) []dns.EDNS0 { 119 | var supported = make([]dns.EDNS0, 0, 3) 120 | // For as long as possible try avoid looking up in the map, because that need an Rlock. 121 | for _, opt := range o { 122 | switch code := opt.Option(); code { 123 | case dns.EDNS0NSID: 124 | fallthrough 125 | case dns.EDNS0COOKIE: 126 | fallthrough 127 | case dns.EDNS0SUBNET: 128 | supported = append(supported, opt) 129 | default: 130 | if SupportedOption(code) { 131 | supported = append(supported, opt) 132 | 133 | } 134 | } 135 | } 136 | return supported 137 | } 138 | -------------------------------------------------------------------------------- /geodns.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | /* 4 | Copyright 2012-2015 Ask Bjørn Hansen 5 | 6 | Licensed under the Apache License, Version 2.0 (the "License"); 7 | you may not use this file except in compliance with the License. 8 | You may obtain a copy of the License at 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0 11 | 12 | Unless required by applicable law or agreed to in writing, software 13 | distributed under the License is distributed on an "AS IS" BASIS, 14 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | See the License for the specific language governing permissions and 16 | limitations under the License. 17 | */ 18 | 19 | import ( 20 | "context" 21 | "flag" 22 | "fmt" 23 | "log" 24 | "net" 25 | "os" 26 | "os/signal" 27 | "path/filepath" 28 | "runtime" 29 | "runtime/pprof" 30 | "strings" 31 | "syscall" 32 | "time" 33 | 34 | "github.com/pborman/uuid" 35 | "golang.org/x/sync/errgroup" 36 | 37 | "go.ntppool.org/common/version" 38 | 39 | "github.com/abh/geodns/v3/appconfig" 40 | "github.com/abh/geodns/v3/applog" 41 | "github.com/abh/geodns/v3/health" 42 | "github.com/abh/geodns/v3/monitor" 43 | "github.com/abh/geodns/v3/querylog" 44 | "github.com/abh/geodns/v3/server" 45 | "github.com/abh/geodns/v3/targeting" 46 | "github.com/abh/geodns/v3/targeting/geoip2" 47 | "github.com/abh/geodns/v3/zones" 48 | ) 49 | 50 | var ( 51 | serverInfo *monitor.ServerInfo 52 | ) 53 | 54 | var ( 55 | flagconfig = flag.String("config", "./dns/", "directory of zone files") 56 | flagconfigfile = flag.String("configfile", "geodns.conf", "filename of config file (in 'config' directory)") 57 | flagcheckconfig = flag.Bool("checkconfig", false, "check configuration and exit") 58 | flagidentifier = flag.String("identifier", "", "identifier (hostname, pop name or similar)") 59 | flaginter = flag.String("interface", "*", "set the listener address") 60 | flagport = flag.String("port", "53", "default port number") 61 | flaghttp = flag.String("http", ":8053", "http listen address (:8053)") 62 | flaglog = flag.Bool("log", false, "be more verbose") 63 | flagcpus = flag.Int("cpus", 0, "Set the maximum number of CPUs to use") 64 | flagLogFile = flag.String("logfile", "", "log to file") 65 | 66 | flagShowVersion = flag.Bool("version", false, "Show GeoDNS version") 67 | 68 | cpuprofile = flag.String("cpuprofile", "", "write cpu profile to file") 69 | memprofile = flag.String("memprofile", "", "write memory profile to this file") 70 | ) 71 | 72 | func init() { 73 | 74 | log.SetPrefix("geodns ") 75 | log.SetFlags(log.Lmicroseconds | log.Lshortfile) 76 | 77 | serverInfo = &monitor.ServerInfo{} 78 | serverInfo.Version = version.Version() 79 | serverInfo.UUID = uuid.New() 80 | serverInfo.Started = time.Now() 81 | 82 | } 83 | 84 | func main() { 85 | flag.Parse() 86 | 87 | if *memprofile != "" { 88 | runtime.MemProfileRate = 1024 89 | } 90 | 91 | if *flagShowVersion { 92 | fmt.Printf("geodns %s\n", version.Version()) 93 | os.Exit(0) 94 | } 95 | 96 | if *flaglog { 97 | applog.Enabled = true 98 | } 99 | 100 | if len(*flagLogFile) > 0 { 101 | applog.FileOpen(*flagLogFile) 102 | } 103 | 104 | if len(*flagidentifier) > 0 { 105 | ids := strings.Split(*flagidentifier, ",") 106 | serverInfo.ID = ids[0] 107 | if len(ids) > 1 { 108 | serverInfo.Groups = ids[1:] 109 | } 110 | } 111 | 112 | var configFileName string 113 | 114 | if filepath.IsAbs(*flagconfigfile) { 115 | configFileName = *flagconfigfile 116 | } else { 117 | configFileName = filepath.Clean(filepath.Join(*flagconfig, *flagconfigfile)) 118 | } 119 | 120 | if *flagcheckconfig { 121 | err := appconfig.ConfigReader(configFileName) 122 | if err != nil { 123 | log.Println("Errors reading config", err) 124 | os.Exit(2) 125 | } 126 | 127 | dirName := *flagconfig 128 | 129 | _, err = zones.NewMuxManager(dirName, &zones.NilReg{}) 130 | if err != nil { 131 | log.Println("Errors reading zones", err) 132 | os.Exit(2) 133 | } 134 | 135 | // todo: setup health stuff when configured 136 | 137 | return 138 | } 139 | 140 | if *flagcpus > 0 { 141 | runtime.GOMAXPROCS(*flagcpus) 142 | } 143 | 144 | log.Printf("Starting geodns %s\n", version.Version()) 145 | 146 | ctx, _ := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill, syscall.SIGTERM) 147 | g, ctx := errgroup.WithContext(ctx) 148 | 149 | g.Go(func() error { 150 | <-ctx.Done() 151 | log.Printf("server shutting down") 152 | go func() { 153 | time.Sleep(time.Second * 5) 154 | log.Fatal("shutdown appears stalled; force exit") 155 | os.Exit(99) 156 | }() 157 | return nil 158 | }) 159 | 160 | if *cpuprofile != "" { 161 | prof, err := os.Create(*cpuprofile) 162 | if err != nil { 163 | panic(err.Error()) 164 | } 165 | 166 | pprof.StartCPUProfile(prof) 167 | defer func() { 168 | log.Println("closing file") 169 | prof.Close() 170 | }() 171 | defer func() { 172 | log.Println("stopping profile") 173 | pprof.StopCPUProfile() 174 | }() 175 | } 176 | 177 | // load geodns.conf config 178 | err := appconfig.ConfigReader(configFileName) 179 | if err != nil { 180 | log.Printf("error reading config file %s: %s", configFileName, err) 181 | os.Exit(2) 182 | } 183 | 184 | if len(appconfig.Config.Health.Directory) > 0 { 185 | go health.DirectoryReader(appconfig.Config.Health.Directory) 186 | } 187 | 188 | // load (and re-load) zone data 189 | g.Go(func() error { 190 | err := appconfig.ConfigWatcher(ctx, configFileName) 191 | if err != nil { 192 | log.Printf("config watcher error: %s", err) 193 | return err 194 | } 195 | return nil 196 | }) 197 | 198 | if *flaginter == "*" { 199 | addrs, _ := net.InterfaceAddrs() 200 | ips := make([]string, 0) 201 | for _, addr := range addrs { 202 | ip, _, err := net.ParseCIDR(addr.String()) 203 | if err != nil { 204 | continue 205 | } 206 | if !(ip.IsLoopback() || ip.IsGlobalUnicast()) { 207 | continue 208 | } 209 | ips = append(ips, ip.String()) 210 | } 211 | *flaginter = strings.Join(ips, ",") 212 | } 213 | 214 | inter := getInterfaces() 215 | 216 | if len(appconfig.Config.GeoIPDirectory()) > 0 { 217 | geoProvider, err := geoip2.New(appconfig.Config.GeoIPDirectory()) 218 | if err != nil { 219 | log.Printf("Configuring geo provider: %s", err) 220 | } 221 | if geoProvider != nil { 222 | targeting.Setup(geoProvider) 223 | } 224 | } 225 | 226 | srv := server.NewServer(appconfig.Config, serverInfo) 227 | 228 | if qlc := appconfig.Config.AvroLog; len(qlc.Path) > 0 { 229 | 230 | maxsize := qlc.MaxSize 231 | if maxsize < 50000 { 232 | maxsize = 1000000 233 | } 234 | maxtime, err := time.ParseDuration(qlc.MaxTime) 235 | if err != nil { 236 | log.Printf("could not parse avrolog maxtime setting %q: %s", qlc.MaxTime, err) 237 | } 238 | if maxtime < 1*time.Second { 239 | maxtime = 1 * time.Second 240 | } 241 | 242 | ql, err := querylog.NewAvroLogger(qlc.Path, maxsize, maxtime) 243 | if err != nil { 244 | log.Fatalf("Could not start avro query logger: %s", err) 245 | } 246 | srv.SetQueryLogger(ql) 247 | 248 | } else if qlc := appconfig.Config.QueryLog; len(qlc.Path) > 0 { 249 | ql, err := querylog.NewFileLogger(qlc.Path, qlc.MaxSize, qlc.Keep) 250 | if err != nil { 251 | log.Fatalf("Could not start file query logger: %s", err) 252 | } 253 | srv.SetQueryLogger(ql) 254 | } 255 | 256 | muxm, err := zones.NewMuxManager(*flagconfig, srv) 257 | if err != nil { 258 | log.Printf("error loading zones: %s", err) 259 | } 260 | 261 | g.Go(func() error { 262 | muxm.Run(ctx) 263 | return nil 264 | }) 265 | 266 | for _, host := range inter { 267 | host := host 268 | g.Go(func() error { 269 | return srv.ListenAndServe(ctx, host) 270 | }) 271 | } 272 | 273 | g.Go(func() error { 274 | <-ctx.Done() 275 | log.Printf("shutting down DNS servers") 276 | err = srv.Shutdown() 277 | if err != nil { 278 | return err 279 | } 280 | return nil 281 | }) 282 | 283 | if len(*flaghttp) > 0 { 284 | g.Go(func() error { 285 | hs := NewHTTPServer(muxm, serverInfo) 286 | err := hs.Run(ctx, *flaghttp) 287 | return err 288 | }) 289 | } 290 | 291 | err = g.Wait() 292 | if err != nil { 293 | log.Printf("server error: %s", err) 294 | } 295 | 296 | if *memprofile != "" { 297 | f, err := os.Create(*memprofile) 298 | if err != nil { 299 | log.Fatal(err) 300 | } 301 | pprof.WriteHeapProfile(f) 302 | f.Close() 303 | } 304 | applog.FileClose() 305 | } 306 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/abh/geodns/v3 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/abh/errorutil v1.0.0 7 | github.com/fsnotify/fsnotify v1.6.0 8 | github.com/golang/geo v0.0.0-20230421003525-6adc56603217 9 | github.com/hamba/avro/v2 v2.16.0 10 | github.com/miekg/dns v1.1.56 11 | github.com/oschwald/geoip2-golang v1.9.0 12 | github.com/pborman/uuid v1.2.1 13 | github.com/prometheus/client_golang v1.17.0 14 | github.com/stretchr/testify v1.8.4 15 | go.ntppool.org/common v0.2.1 16 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d 17 | golang.org/x/sync v0.4.0 18 | gopkg.in/gcfg.v1 v1.2.3 19 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 20 | ) 21 | 22 | require ( 23 | github.com/beorn7/perks v1.0.1 // indirect 24 | github.com/cespare/xxhash/v2 v2.2.0 // indirect 25 | github.com/davecgh/go-spew v1.1.1 // indirect 26 | github.com/golang/protobuf v1.5.3 // indirect 27 | github.com/golang/snappy v0.0.4 // indirect 28 | github.com/google/uuid v1.3.1 // indirect 29 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 30 | github.com/json-iterator/go v1.1.12 // indirect 31 | github.com/kr/text v0.2.0 // indirect 32 | github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect 33 | github.com/mitchellh/mapstructure v1.5.0 // indirect 34 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 35 | github.com/modern-go/reflect2 v1.0.2 // indirect 36 | github.com/oschwald/maxminddb-golang v1.12.0 // indirect 37 | github.com/pmezard/go-difflib v1.0.0 // indirect 38 | github.com/prometheus/client_model v0.5.0 // indirect 39 | github.com/prometheus/common v0.44.0 // indirect 40 | github.com/prometheus/procfs v0.12.0 // indirect 41 | github.com/spf13/cobra v1.7.0 // indirect 42 | github.com/spf13/pflag v1.0.5 // indirect 43 | golang.org/x/mod v0.13.0 // indirect 44 | golang.org/x/net v0.17.0 // indirect 45 | golang.org/x/sys v0.13.0 // indirect 46 | golang.org/x/tools v0.14.0 // indirect 47 | google.golang.org/protobuf v1.31.0 // indirect 48 | gopkg.in/warnings.v0 v0.1.2 // indirect 49 | gopkg.in/yaml.v3 v3.0.1 // indirect 50 | ) 51 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/abh/errorutil v1.0.0 h1:Ooe51ceN0MgmIWhagdOUFQSPQMvxpGoZT0HJE228Ojg= 2 | github.com/abh/errorutil v1.0.0/go.mod h1:EY+X82MG0wLqrNvLnxHdhPPLeHOe9EL2Skq1PvgqIco= 3 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 4 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 5 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 6 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 10 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= 13 | github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= 14 | github.com/golang/geo v0.0.0-20230421003525-6adc56603217 h1:HKlyj6in2JV6wVkmQ4XmG/EIm+SCYlPZ+V4GWit7Z+I= 15 | github.com/golang/geo v0.0.0-20230421003525-6adc56603217/go.mod h1:8wI0hitZ3a1IxZfeH3/5I97CI8i5cLGsYe7xNhQGs9U= 16 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 17 | github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 18 | github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= 19 | github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 20 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 21 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 22 | github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 23 | github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= 24 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 25 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 26 | github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 27 | github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= 28 | github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 29 | github.com/google/uuid v1.3.1 h1:KjJaJ9iWZ3jOFZIf1Lqf4laDRCasjl0BCmnEGxkdLb4= 30 | github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 31 | github.com/hamba/avro/v2 v2.14.1 h1:mRkiRKjRTTs+yx0nVuM6z/q5zg3VBZfOe/01ngAnU6A= 32 | github.com/hamba/avro/v2 v2.14.1/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= 33 | github.com/hamba/avro/v2 v2.16.0 h1:0XhyP65Hs8iMLtdSR0v7ZrwRjsbIZdvr7KzYgmx1Mbo= 34 | github.com/hamba/avro/v2 v2.16.0/go.mod h1:Q9YK+qxAhtVrNqOhwlZTATLgLA8qxG2vtvkhK8fJ7Jo= 35 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 36 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 37 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 38 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 39 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 40 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 41 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 42 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 43 | github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= 44 | github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= 45 | github.com/miekg/dns v1.1.55 h1:GoQ4hpsj0nFLYe+bWiCToyrBEJXkQfOOIvFGFy0lEgo= 46 | github.com/miekg/dns v1.1.55/go.mod h1:uInx36IzPl7FYnDcMeVWxj9byh7DutNykX4G9Sj60FY= 47 | github.com/miekg/dns v1.1.56 h1:5imZaSeoRNvpM9SzWNhEcP9QliKiz20/dA2QabIGVnE= 48 | github.com/miekg/dns v1.1.56/go.mod h1:cRm6Oo2C8TY9ZS/TqsSrseAcncm74lfK5G+ikN2SWWY= 49 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 50 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 51 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 52 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 53 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 54 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 55 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 56 | github.com/oschwald/geoip2-golang v1.9.0 h1:uvD3O6fXAXs+usU+UGExshpdP13GAqp4GBrzN7IgKZc= 57 | github.com/oschwald/geoip2-golang v1.9.0/go.mod h1:BHK6TvDyATVQhKNbQBdrj9eAvuwOMi2zSFXizL3K81Y= 58 | github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs= 59 | github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY= 60 | github.com/pborman/uuid v1.2.1 h1:+ZZIw58t/ozdjRaXh/3awHfmWRbzYxJoAdNJxe/3pvw= 61 | github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= 62 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 63 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 64 | github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= 65 | github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= 66 | github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= 67 | github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= 68 | github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= 69 | github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= 70 | github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= 71 | github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= 72 | github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= 73 | github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= 74 | github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= 75 | github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= 76 | github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= 77 | github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= 78 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 79 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 80 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 81 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 82 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 83 | github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= 84 | github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= 85 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 86 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 87 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 88 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 89 | github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 90 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 91 | go.ntppool.org/common v0.2.0 h1:ufVBJoflAwq1HzT1/kezUBPTP2lkYEBPRmg1wkuqDbo= 92 | go.ntppool.org/common v0.2.0/go.mod h1:2vW9Wsc+N45GkBoo+i8gn4a2dPeGP9gLQntzw6aKH6E= 93 | go.ntppool.org/common v0.2.1 h1:UZFFn/39Rn6esx+gzVceY4v5oznyNORJ7JugixdmKzM= 94 | go.ntppool.org/common v0.2.1/go.mod h1:rTTb+LHJRogQ8rdmu3lZsa7zwWA9vg33fNaM6u/EKtI= 95 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63 h1:m64FZMko/V45gv0bNmrNYoDEq8U5YUhetc9cBWKS1TQ= 96 | golang.org/x/exp v0.0.0-20230817173708-d852ddb80c63/go.mod h1:0v4NqG35kSWCMzLaMeX+IQrlSnVE/bqGSyC2cz/9Le8= 97 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= 98 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= 99 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= 100 | golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= 101 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 102 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 103 | golang.org/x/mod v0.13.0 h1:I/DsJXRlw/8l/0c24sM9yb0T4z9liZTduXvdAWYiysY= 104 | golang.org/x/mod v0.13.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 105 | golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= 106 | golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= 107 | golang.org/x/net v0.15.0 h1:ugBLEUaxABaB5AJqW9enI0ACdci2RUd4eP51NTBvuJ8= 108 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 109 | golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= 110 | golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= 111 | golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 112 | golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= 113 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 114 | golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ= 115 | golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 116 | golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 117 | golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= 118 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 119 | golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= 120 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 121 | golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= 122 | golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 123 | golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846 h1:Vve/L0v7CXXuxUmaMGIEK/dEeq7uiqb5qBgQrZzIE7E= 124 | golang.org/x/tools v0.12.1-0.20230815132531-74c255bcf846/go.mod h1:Sc0INKfu04TlqNoRA1hgpFZbhYXHPr4V5DzpSBTPqQM= 125 | golang.org/x/tools v0.13.0 h1:Iey4qkscZuv0VvIt8E0neZjtPVQFSc870HQ448QgEmQ= 126 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 127 | golang.org/x/tools v0.14.0 h1:jvNa2pY0M4r62jkRQ6RwEZZyPcymeL9XZMLBbV7U2nc= 128 | golang.org/x/tools v0.14.0/go.mod h1:uYBEerGOWcJyEORxN+Ek8+TT266gXkNlHdJBwexUsBg= 129 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 130 | google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 131 | google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 132 | google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= 133 | google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 134 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 135 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 136 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 137 | gopkg.in/gcfg.v1 v1.2.3 h1:m8OOJ4ccYHnx2f4gQwpno8nAX5OGOh7RLaaz0pj3Ogs= 138 | gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= 139 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= 140 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= 141 | gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= 142 | gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= 143 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 144 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 145 | -------------------------------------------------------------------------------- /health/health.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/abh/geodns/v3/typeutil" 7 | ) 8 | 9 | type HealthTester interface { 10 | // Test(record string) bool 11 | Name(record string) string 12 | String() string 13 | } 14 | 15 | type HealthReference struct { 16 | name string 17 | } 18 | 19 | func (hr *HealthReference) Name(record string) string { 20 | if len(record) > 0 { 21 | return hr.name + "/" + record 22 | } 23 | return hr.name 24 | } 25 | 26 | func (hr *HealthReference) String() string { 27 | return hr.name 28 | } 29 | 30 | func NewReferenceFromMap(i map[string]interface{}) (HealthTester, error) { 31 | var name, ts string 32 | 33 | if ti, ok := i["type"]; ok { 34 | ts = typeutil.ToString(ti) 35 | } 36 | 37 | if ni, ok := i["name"]; ok { 38 | name = typeutil.ToString(ni) 39 | } 40 | 41 | if len(name) == 0 { 42 | name = ts 43 | } 44 | 45 | if len(name) == 0 { 46 | return nil, fmt.Errorf("name or type required") 47 | } 48 | 49 | tester := &HealthReference{name: name} 50 | return tester, nil 51 | } 52 | 53 | // func (hr *HealthReference) RecordTest(rec *zones.Record) { 54 | // key := ht.String() 55 | // htr.entryMutex.Lock() 56 | // defer htr.entryMutex.Unlock() 57 | // if t, ok := htr.entries[key]; ok { 58 | // // we already have an instance of this test running. Record we are using it 59 | // t.references[ref] = true 60 | // } else { 61 | // // a test that isn't running. Record we are using it and start the test 62 | // t := &HealthTestRunnerEntry{ 63 | // HealthTest: *ht.copy(ht.ipAddress), 64 | // references: make(map[string]bool), 65 | // } 66 | // if t.global { 67 | // t.ipAddress = nil 68 | // } 69 | // // we know it is not started, so no need for the mutex 70 | // t.healthy = ht.healthy 71 | // t.references[ref] = true 72 | // t.start() 73 | // htr.entries[key] = t 74 | // } 75 | // } 76 | -------------------------------------------------------------------------------- /health/healthtest/healthtest.go: -------------------------------------------------------------------------------- 1 | package healthtest 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "net" 7 | "sync" 8 | "time" 9 | 10 | "github.com/abh/geodns/v3/applog" 11 | "github.com/abh/geodns/v3/typeutil" 12 | 13 | "github.com/miekg/dns" 14 | ) 15 | 16 | var ( 17 | Qtypes = []uint16{dns.TypeA, dns.TypeAAAA} 18 | ) 19 | 20 | type HealthTester interface { 21 | String() string 22 | Test(*HealthTest) bool 23 | } 24 | 25 | type HealthTestParameters struct { 26 | frequency time.Duration 27 | retryTime time.Duration 28 | timeout time.Duration 29 | retries int 30 | healthyInitially bool 31 | testName string 32 | global bool 33 | } 34 | 35 | type HealthTest struct { 36 | HealthTestParameters 37 | ipAddress net.IP 38 | healthy bool 39 | healthyMutex sync.RWMutex 40 | closing chan chan error 41 | health chan bool 42 | tester *HealthTester 43 | globalMap map[string]bool 44 | } 45 | 46 | type HealthTestRunnerEntry struct { 47 | HealthTest 48 | references map[string]bool 49 | } 50 | 51 | type HealthTestRunner struct { 52 | entries map[string]*HealthTestRunnerEntry 53 | entryMutex sync.RWMutex 54 | } 55 | 56 | var TestRunner = &HealthTestRunner{ 57 | entries: make(map[string]*HealthTestRunnerEntry), 58 | } 59 | 60 | func defaultHealthTestParameters() HealthTestParameters { 61 | return HealthTestParameters{ 62 | frequency: 30 * time.Second, 63 | retryTime: 5 * time.Second, 64 | timeout: 5 * time.Second, 65 | retries: 3, 66 | healthyInitially: false, 67 | } 68 | } 69 | 70 | func NewTest(ipAddress net.IP, htp HealthTestParameters, tester *HealthTester) *HealthTest { 71 | ht := HealthTest{ 72 | ipAddress: ipAddress, 73 | HealthTestParameters: htp, 74 | healthy: true, 75 | tester: tester, 76 | globalMap: make(map[string]bool), 77 | } 78 | ht.healthy = ht.healthyInitially 79 | if ht.frequency < time.Second { 80 | ht.frequency = time.Second 81 | } 82 | if ht.retryTime < time.Second { 83 | ht.retryTime = time.Second 84 | } 85 | if ht.timeout < time.Second { 86 | ht.timeout = time.Second 87 | } 88 | return &ht 89 | } 90 | 91 | // Format the health test as a string - used to compare two tests and as an index for the hash 92 | func (ht *HealthTest) String() string { 93 | ip := ht.ipAddress.String() 94 | if ht.HealthTestParameters.global { 95 | ip = "" // ensure we have a single instance of a global health check with the same paramaters 96 | } 97 | return fmt.Sprintf("%s/%v/%s", ip, ht.HealthTestParameters, (*ht.tester).String()) 98 | } 99 | 100 | // safe copy function that copies the parameters but not (e.g.) the 101 | // mutex 102 | func (ht *HealthTest) copy(ipAddress net.IP) *HealthTest { 103 | return NewTest(ipAddress, ht.HealthTestParameters, ht.tester) 104 | } 105 | 106 | func (ht *HealthTest) setGlobal(g map[string]bool) { 107 | ht.healthyMutex.Lock() 108 | defer ht.healthyMutex.Unlock() 109 | ht.globalMap = g 110 | } 111 | 112 | func (ht *HealthTest) getGlobal(k string) (bool, bool) { 113 | ht.healthyMutex.RLock() 114 | defer ht.healthyMutex.RUnlock() 115 | healthy, ok := ht.globalMap[k] 116 | return healthy, ok 117 | } 118 | 119 | func (ht *HealthTest) run() { 120 | randomDelay := rand.Int63n(ht.frequency.Nanoseconds()) 121 | if !ht.isHealthy() { 122 | randomDelay = rand.Int63n(ht.retryTime.Nanoseconds()) 123 | } 124 | var nextPoll time.Time = time.Now().Add(time.Duration(randomDelay)) 125 | var pollStart time.Time 126 | failCount := 0 127 | for { 128 | var pollDelay time.Duration 129 | if now := time.Now(); nextPoll.After(now) { 130 | pollDelay = nextPoll.Sub(now) 131 | } 132 | var startPoll <-chan time.Time 133 | var closingPoll <-chan chan error 134 | if pollStart.IsZero() { 135 | closingPoll = ht.closing 136 | startPoll = time.After(pollDelay) 137 | } 138 | select { 139 | case errc := <-closingPoll: // don't close while we are polling or we send to a closed channel 140 | errc <- nil 141 | return 142 | case <-startPoll: 143 | pollStart = time.Now() 144 | go ht.poll() 145 | case h := <-ht.health: 146 | nextPoll = pollStart.Add(ht.frequency) 147 | if h { 148 | ht.setHealthy(true) 149 | failCount = 0 150 | } else { 151 | failCount++ 152 | applog.Printf("Failure for %s, retry count=%d, healthy=%v", ht.ipAddress, failCount, ht.isHealthy()) 153 | if failCount >= ht.retries { 154 | ht.setHealthy(false) 155 | nextPoll = pollStart.Add(ht.retryTime) 156 | } 157 | } 158 | pollStart = time.Time{} 159 | applog.Printf("Check result for %s health=%v, next poll at %s", ht.ipAddress, h, nextPoll) 160 | //randomDelay := rand.Int63n(time.Second.Nanoseconds()) 161 | //nextPoll = nextPoll.Add(time.Duration(randomDelay)) 162 | } 163 | } 164 | } 165 | 166 | func (ht *HealthTest) poll() { 167 | applog.Printf("Checking health of %s", ht.ipAddress) 168 | result := (*ht.tester).Test(ht) 169 | applog.Printf("Checked health of %s, healthy=%v", ht.ipAddress, result) 170 | ht.health <- result 171 | } 172 | 173 | func (ht *HealthTest) start() { 174 | ht.closing = make(chan chan error) 175 | ht.health = make(chan bool) 176 | applog.Printf("Starting health test on %s, frequency=%s, retry_time=%s, timeout=%s, retries=%d", ht.ipAddress, ht.frequency, ht.retryTime, ht.timeout, ht.retries) 177 | go ht.run() 178 | } 179 | 180 | // Stop the health check from running 181 | func (ht *HealthTest) Stop() (err error) { 182 | // Check it's been started by existing of the closing channel 183 | if ht.closing == nil { 184 | return nil 185 | } 186 | applog.Printf("Stopping health test on %s", ht.ipAddress) 187 | errc := make(chan error) 188 | ht.closing <- errc 189 | err = <-errc 190 | close(ht.closing) 191 | ht.closing = nil 192 | close(ht.health) 193 | ht.health = nil 194 | return err 195 | } 196 | 197 | func (ht *HealthTest) IP() net.IP { 198 | return ht.ipAddress 199 | } 200 | func (ht *HealthTest) IsHealthy() bool { 201 | return ht.isHealthy() 202 | } 203 | 204 | func (ht *HealthTest) isHealthy() bool { 205 | ht.healthyMutex.RLock() 206 | h := ht.healthy 207 | ht.healthyMutex.RUnlock() 208 | return h 209 | } 210 | 211 | func (ht *HealthTest) setHealthy(h bool) { 212 | ht.healthyMutex.Lock() 213 | old := ht.healthy 214 | ht.healthy = h 215 | ht.healthyMutex.Unlock() 216 | if old != h { 217 | applog.Printf("Changing health status of %s from %v to %v", ht.ipAddress, old, h) 218 | } 219 | } 220 | 221 | func (htr *HealthTestRunner) addTest(ht *HealthTest, ref string) { 222 | key := ht.String() 223 | htr.entryMutex.Lock() 224 | defer htr.entryMutex.Unlock() 225 | if t, ok := htr.entries[key]; ok { 226 | // we already have an instance of this test running. Record we are using it 227 | t.references[ref] = true 228 | } else { 229 | // a test that isn't running. Record we are using it and start the test 230 | t := &HealthTestRunnerEntry{ 231 | HealthTest: *ht.copy(ht.ipAddress), 232 | references: make(map[string]bool), 233 | } 234 | if t.global { 235 | t.ipAddress = nil 236 | } 237 | // we know it is not started, so no need for the mutex 238 | t.healthy = ht.healthy 239 | t.references[ref] = true 240 | t.start() 241 | htr.entries[key] = t 242 | } 243 | } 244 | 245 | func (htr *HealthTestRunner) removeTest(ht *HealthTest, ref string) { 246 | key := ht.String() 247 | htr.entryMutex.Lock() 248 | defer htr.entryMutex.Unlock() 249 | if t, ok := htr.entries[key]; ok { 250 | delete(t.references, ref) 251 | // record the last state of health 252 | ht.healthyMutex.Lock() 253 | ht.healthy = t.isHealthy() 254 | ht.healthyMutex.Unlock() 255 | if len(t.references) == 0 { 256 | // no more references, delete the test 257 | t.Stop() 258 | delete(htr.entries, key) 259 | } 260 | } 261 | } 262 | 263 | func (htr *HealthTestRunner) refAllGlobalHealthChecks(ref string, add bool) { 264 | htr.entryMutex.Lock() 265 | defer htr.entryMutex.Unlock() 266 | for key, t := range htr.entries { 267 | if t.global { 268 | if add { 269 | t.references[ref] = true 270 | } else { 271 | delete(t.references, ref) 272 | if len(t.references) == 0 { 273 | // no more references, delete the test 274 | t.Stop() 275 | delete(htr.entries, key) 276 | } 277 | } 278 | } 279 | } 280 | } 281 | 282 | func (htr *HealthTestRunner) IsHealthy(ht *HealthTest) bool { 283 | return htr.isHealthy(ht) 284 | } 285 | 286 | func (htr *HealthTestRunner) isHealthy(ht *HealthTest) bool { 287 | key := ht.String() 288 | htr.entryMutex.RLock() 289 | defer htr.entryMutex.RUnlock() 290 | if t, ok := htr.entries[key]; ok { 291 | if t.global { 292 | healthy, ok := t.getGlobal(ht.ipAddress.String()) 293 | if ok { 294 | return healthy 295 | } 296 | } else { 297 | return t.isHealthy() 298 | } 299 | } 300 | return ht.isHealthy() 301 | } 302 | 303 | func NewFromMap(i map[string]interface{}) (*HealthTest, error) { 304 | ts := typeutil.ToString(i["type"]) 305 | 306 | if len(ts) == 0 { 307 | return nil, fmt.Errorf("type required") 308 | } 309 | 310 | htp := defaultHealthTestParameters() 311 | nh, ok := HealthTesterMap[ts] 312 | if !ok { 313 | return nil, fmt.Errorf("Bad health test type '%s'", ts) 314 | } 315 | 316 | htp.testName = ts 317 | h := nh(i, &htp) 318 | 319 | for k, v := range i { 320 | switch k { 321 | case "frequency": 322 | htp.frequency = time.Duration(typeutil.ToInt(v)) * time.Second 323 | case "retry_time": 324 | htp.retryTime = time.Duration(typeutil.ToInt(v)) * time.Second 325 | case "timeout": 326 | htp.retryTime = time.Duration(typeutil.ToInt(v)) * time.Second 327 | case "retries": 328 | htp.retries = typeutil.ToInt(v) 329 | case "healthy_initially": 330 | htp.healthyInitially = typeutil.ToBool(v) 331 | // applog.Printf("HealthyInitially for %s is %v", l.Label, htp.healthyInitially) 332 | } 333 | } 334 | 335 | tester := NewTest(nil, htp, &h) 336 | return tester, nil 337 | 338 | } 339 | -------------------------------------------------------------------------------- /health/status.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | // todo: how to deal with multiple files? 11 | // specified in zone and a status object for each? 12 | 13 | type StatusType uint8 14 | 15 | const ( 16 | StatusUnknown StatusType = iota 17 | StatusUnhealthy 18 | StatusHealthy 19 | ) 20 | 21 | type Status interface { 22 | GetStatus(string) StatusType 23 | Reload() error 24 | Close() error 25 | } 26 | 27 | type statusRegistry struct { 28 | mu sync.RWMutex 29 | m map[string]Status 30 | } 31 | 32 | var registry statusRegistry 33 | 34 | type Service struct { 35 | Status StatusType 36 | } 37 | 38 | func init() { 39 | registry = statusRegistry{ 40 | m: make(map[string]Status), 41 | } 42 | } 43 | 44 | func (r *statusRegistry) Add(name string, status Status) error { 45 | r.mu.Lock() 46 | defer r.mu.Unlock() 47 | r.m[name] = status 48 | return nil 49 | } 50 | 51 | func (st StatusType) String() string { 52 | switch st { 53 | case StatusHealthy: 54 | return "healthy" 55 | case StatusUnhealthy: 56 | return "unhealthy" 57 | case StatusUnknown: 58 | return "unknown" 59 | default: 60 | return fmt.Sprintf("status=%d", st) 61 | } 62 | } 63 | 64 | func GetStatus(name string) StatusType { 65 | check := strings.SplitN(name, "/", 2) 66 | if len(check) != 2 { 67 | return StatusUnknown 68 | } 69 | registry.mu.RLock() 70 | status, ok := registry.m[check[0]] 71 | registry.mu.RUnlock() 72 | 73 | log.Printf("looking up health for '%s', status register: '%s', found: %t", name, check[0], ok) 74 | 75 | if !ok { 76 | return StatusUnknown 77 | } 78 | return status.GetStatus(check[1]) 79 | } 80 | -------------------------------------------------------------------------------- /health/status_file.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "path" 9 | "strings" 10 | "sync" 11 | "time" 12 | ) 13 | 14 | type StatusFile struct { 15 | filename string 16 | mu sync.RWMutex 17 | m StatusFileData 18 | } 19 | 20 | type StatusFileData map[string]*Service 21 | 22 | func NewStatusFile(filename string) *StatusFile { 23 | return &StatusFile{ 24 | m: make(StatusFileData), 25 | filename: filename, 26 | } 27 | } 28 | 29 | // DirectoryReader loads (and regularly re-loads) health 30 | // .json files from the specified files into the default 31 | // health registry. 32 | func DirectoryReader(dir string) { 33 | for { 34 | err := reloadDirectory(dir) 35 | if err != nil { 36 | log.Printf("loading health data: %s", err) 37 | } 38 | time.Sleep(1 * time.Second) 39 | } 40 | } 41 | 42 | func reloadDirectory(dir string) error { 43 | dirlist, err := ioutil.ReadDir(dir) 44 | if err != nil { 45 | return fmt.Errorf("could not read '%s': %s", dir, err) 46 | } 47 | 48 | seen := map[string]bool{} 49 | 50 | var parseErr error 51 | 52 | for _, file := range dirlist { 53 | fileName := file.Name() 54 | if !strings.HasSuffix(strings.ToLower(fileName), ".json") || 55 | strings.HasPrefix(path.Base(fileName), ".") || 56 | file.IsDir() { 57 | continue 58 | } 59 | statusName := fileName[0:strings.LastIndex(fileName, ".")] 60 | 61 | registry.mu.Lock() 62 | s, ok := registry.m[statusName] 63 | registry.mu.Unlock() 64 | 65 | seen[statusName] = true 66 | 67 | if ok { 68 | s.Reload() 69 | } else { 70 | s := NewStatusFile(path.Join(dir, fileName)) 71 | err := s.Reload() 72 | if err != nil { 73 | log.Printf("error loading '%s': %s", fileName, err) 74 | parseErr = err 75 | } 76 | registry.Add(statusName, s) 77 | } 78 | } 79 | 80 | registry.mu.Lock() 81 | for n, _ := range registry.m { 82 | if !seen[n] { 83 | registry.m[n].Close() 84 | delete(registry.m, n) 85 | } 86 | } 87 | registry.mu.Unlock() 88 | 89 | return parseErr 90 | } 91 | 92 | func (s *StatusFile) Reload() error { 93 | if len(s.filename) > 0 { 94 | return s.Load(s.filename) 95 | } 96 | return nil 97 | } 98 | 99 | // Load imports the data atomically into the status map. If there's 100 | // a JSON error the old data is preserved. 101 | func (s *StatusFile) Load(filename string) error { 102 | n := StatusFileData{} 103 | b, err := ioutil.ReadFile(filename) 104 | if err != nil { 105 | return err 106 | } 107 | err = json.Unmarshal(b, &n) 108 | if err != nil { 109 | return err 110 | } 111 | s.mu.Lock() 112 | s.m = n 113 | s.mu.Unlock() 114 | 115 | return nil 116 | } 117 | 118 | func (s *StatusFile) Close() error { 119 | s.mu.Lock() 120 | s.m = nil 121 | s.mu.Unlock() 122 | return nil 123 | } 124 | 125 | func (s *StatusFile) GetStatus(check string) StatusType { 126 | s.mu.RLock() 127 | defer s.mu.RUnlock() 128 | 129 | if s.m == nil { 130 | return StatusUnknown 131 | } 132 | 133 | st, ok := s.m[check] 134 | if !ok { 135 | log.Printf("Not found '%s'", check) 136 | return StatusUnknown 137 | } 138 | return st.Status 139 | } 140 | 141 | // UnmarshalJSON implements the json.Unmarshaler interface. 142 | func (srv *Service) UnmarshalJSON(b []byte) error { 143 | var i int64 144 | if err := json.Unmarshal(b, &i); err != nil { 145 | return err 146 | } 147 | *srv = Service{Status: StatusType(i)} 148 | return nil 149 | } 150 | 151 | // UnmarshalJSON implements the json.Marshaler interface. 152 | // func (srv *Service) MarshalJSON() ([]byte, error) { 153 | // return 154 | // } 155 | -------------------------------------------------------------------------------- /health/status_test.go: -------------------------------------------------------------------------------- 1 | package health 2 | 3 | import "testing" 4 | 5 | func TestStatusFile(t *testing.T) { 6 | sf := NewStatusFile("test.json") 7 | err := sf.Load("test.json") 8 | if err != nil { 9 | t.Fatalf("could not load test.json: %s", err) 10 | } 11 | x := sf.GetStatus("bad") 12 | 13 | t.Logf("bad=%d", x) 14 | 15 | if x != StatusUnhealthy { 16 | t.Errorf("'bad' should have been unhealthy but was %s", x.String()) 17 | } 18 | registry.Add("test", sf) 19 | } 20 | -------------------------------------------------------------------------------- /health/test.json: -------------------------------------------------------------------------------- 1 | {"ukn":0,"bad":1,"good":2} -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "log" 9 | "net/http" 10 | "strconv" 11 | "time" 12 | 13 | "github.com/abh/geodns/v3/appconfig" 14 | "github.com/abh/geodns/v3/monitor" 15 | "github.com/abh/geodns/v3/zones" 16 | "github.com/prometheus/client_golang/prometheus/promhttp" 17 | "golang.org/x/sync/errgroup" 18 | ) 19 | 20 | type httpServer struct { 21 | mux *http.ServeMux 22 | zones *zones.MuxManager 23 | serverInfo *monitor.ServerInfo 24 | } 25 | 26 | type rate struct { 27 | Name string 28 | Count int64 29 | Metrics zones.ZoneMetrics 30 | } 31 | type rates []*rate 32 | 33 | func (s rates) Len() int { return len(s) } 34 | func (s rates) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 35 | 36 | type ratesByCount struct{ rates } 37 | 38 | func (s ratesByCount) Less(i, j int) bool { 39 | ic := s.rates[i].Count 40 | jc := s.rates[j].Count 41 | if ic == jc { 42 | return s.rates[i].Name < s.rates[j].Name 43 | } 44 | return ic > jc 45 | } 46 | 47 | func topParam(req *http.Request, def int) int { 48 | req.ParseForm() 49 | 50 | topOption := def 51 | topParam := req.Form["top"] 52 | 53 | if len(topParam) > 0 { 54 | var err error 55 | topOption, err = strconv.Atoi(topParam[0]) 56 | if err != nil { 57 | topOption = def 58 | } 59 | } 60 | 61 | return topOption 62 | } 63 | 64 | func NewHTTPServer(mm *zones.MuxManager, serverInfo *monitor.ServerInfo) *httpServer { 65 | 66 | hs := &httpServer{ 67 | zones: mm, 68 | mux: &http.ServeMux{}, 69 | serverInfo: serverInfo, 70 | } 71 | hs.mux.HandleFunc("/", hs.mainServer) 72 | hs.mux.Handle("/metrics", promhttp.Handler()) 73 | 74 | return hs 75 | } 76 | 77 | func (hs *httpServer) Mux() *http.ServeMux { 78 | return hs.mux 79 | } 80 | 81 | func (hs *httpServer) Run(ctx context.Context, listen string) error { 82 | log.Println("Starting HTTP interface on", listen) 83 | 84 | srv := http.Server{ 85 | Addr: listen, 86 | Handler: &basicauth{h: hs.mux}, 87 | ReadTimeout: 5 * time.Second, 88 | IdleTimeout: 10 * time.Second, 89 | WriteTimeout: 10 * time.Second, 90 | } 91 | 92 | g, ctx := errgroup.WithContext(ctx) 93 | 94 | g.Go(func() error { 95 | err := srv.ListenAndServe() 96 | if err != nil { 97 | if !errors.Is(err, http.ErrServerClosed) { 98 | return err 99 | } 100 | } 101 | return nil 102 | }) 103 | 104 | g.Go(func() error { 105 | <-ctx.Done() 106 | log.Printf("shutting down http server") 107 | return srv.Shutdown(ctx) 108 | }) 109 | 110 | return g.Wait() 111 | } 112 | 113 | func (hs *httpServer) mainServer(w http.ResponseWriter, req *http.Request) { 114 | if req.RequestURI != "/version" { 115 | http.NotFound(w, req) 116 | return 117 | } 118 | w.Header().Set("Content-Type", "text/plain") 119 | w.WriteHeader(200) 120 | io.WriteString(w, `GeoDNS `+hs.serverInfo.Version+`\n`) 121 | } 122 | 123 | type basicauth struct { 124 | h http.Handler 125 | } 126 | 127 | func (b *basicauth) ServeHTTP(w http.ResponseWriter, r *http.Request) { 128 | 129 | // cfgMutex.RLock() 130 | user := appconfig.Config.HTTP.User 131 | password := appconfig.Config.HTTP.Password 132 | // cfgMutex.RUnlock() 133 | 134 | if len(user) == 0 { 135 | b.h.ServeHTTP(w, r) 136 | return 137 | } 138 | 139 | ruser, rpass, ok := r.BasicAuth() 140 | if ok { 141 | if ruser == user && rpass == password { 142 | b.h.ServeHTTP(w, r) 143 | return 144 | } 145 | } 146 | 147 | w.Header().Set("WWW-Authenticate", fmt.Sprintf(`Basic realm=%q`, "GeoDNS Status")) 148 | http.Error(w, http.StatusText(http.StatusUnauthorized), http.StatusUnauthorized) 149 | } 150 | -------------------------------------------------------------------------------- /http_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | 12 | "github.com/abh/geodns/v3/targeting" 13 | "github.com/abh/geodns/v3/targeting/geoip2" 14 | "github.com/abh/geodns/v3/zones" 15 | ) 16 | 17 | func TestHTTP(t *testing.T) { 18 | geoprovider, err := geoip2.New(geoip2.FindDB()) 19 | if err == nil { 20 | targeting.Setup(geoprovider) 21 | } 22 | 23 | mm, err := zones.NewMuxManager("dns", &zones.NilReg{}) 24 | if err != nil { 25 | t.Fatalf("loading zones: %s", err) 26 | } 27 | hs := NewHTTPServer(mm, serverInfo) 28 | 29 | srv := httptest.NewServer(hs.Mux()) 30 | 31 | baseurl := srv.URL 32 | t.Logf("server base url: '%s'", baseurl) 33 | 34 | // metrics := NewMetrics() 35 | // go metrics.Updater() 36 | 37 | res, err := http.Get(baseurl + "/version") 38 | require.Nil(t, err) 39 | page, _ := io.ReadAll(res.Body) 40 | 41 | if !bytes.HasPrefix(page, []byte("GeoDNS ")) { 42 | t.Log("/version didn't start with 'GeoDNS '") 43 | t.Fail() 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /monitor/hub.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | -------------------------------------------------------------------------------- /monitor/monitor.go: -------------------------------------------------------------------------------- 1 | package monitor 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // ServerInfo has the configured ID and groups and the first IP 8 | // address for the server among other 'who am I' information. The 9 | // UUID is reset on each restart. 10 | type ServerInfo struct { 11 | Version string 12 | ID string 13 | IP string 14 | UUID string 15 | Groups []string 16 | Started time.Time 17 | } 18 | -------------------------------------------------------------------------------- /querylog/avro.go: -------------------------------------------------------------------------------- 1 | package querylog 2 | 3 | import ( 4 | "context" 5 | _ "embed" 6 | "fmt" 7 | "log" 8 | "os" 9 | "path" 10 | "strings" 11 | "sync" 12 | "sync/atomic" 13 | "time" 14 | 15 | "golang.org/x/exp/slices" 16 | 17 | "github.com/hamba/avro/v2" 18 | "github.com/hamba/avro/v2/ocf" 19 | ) 20 | 21 | //go:embed querylog.avsc 22 | var schemaJson string 23 | 24 | type AvroLogger struct { 25 | path string 26 | maxsize int 27 | maxtime time.Duration 28 | 29 | schema avro.Schema 30 | 31 | ctx context.Context 32 | cancel context.CancelCauseFunc 33 | wg sync.WaitGroup 34 | 35 | ch chan *Entry 36 | } 37 | 38 | func NewAvroLogger(path string, maxsize int, maxtime time.Duration) (*AvroLogger, error) { 39 | 40 | schema, err := AvroSchema() 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | ctx, cancel := context.WithCancelCause(context.Background()) 46 | l := &AvroLogger{ 47 | ctx: ctx, 48 | cancel: cancel, 49 | path: path, 50 | maxsize: maxsize, 51 | maxtime: maxtime, 52 | schema: schema, 53 | ch: make(chan *Entry, 2000), 54 | wg: sync.WaitGroup{}, 55 | } 56 | 57 | go l.writer(ctx) 58 | return l, nil 59 | } 60 | 61 | func AvroSchema() (avro.Schema, error) { 62 | schema, err := avro.Parse(schemaJson) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return schema, nil 67 | } 68 | 69 | func (l *AvroLogger) Write(e *Entry) error { 70 | select { 71 | case l.ch <- e: 72 | return nil 73 | default: 74 | return fmt.Errorf("buffer full") 75 | } 76 | } 77 | 78 | // func (l *AvroFile) 79 | 80 | type avroFile struct { 81 | fh *os.File 82 | enc *ocf.Encoder 83 | open bool 84 | count int 85 | } 86 | 87 | func (l *AvroLogger) writer(ctx context.Context) { 88 | 89 | mu := sync.Mutex{} 90 | 91 | timer := time.After(l.maxtime) 92 | 93 | openFiles := []*avroFile{} 94 | 95 | var fileCounter atomic.Int32 96 | 97 | openFile := func() (*avroFile, error) { 98 | // todo: communicate back to the main process when this goes wrong 99 | 100 | now := time.Now().UTC().Format("20060102-150405") 101 | 102 | fileCounter.Add(1) 103 | 104 | f, err := os.OpenFile(path.Join(l.path, fmt.Sprintf("log.%s.%d.avro.tmp", now, fileCounter.Load())), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660) 105 | if err != nil { 106 | return nil, err 107 | } 108 | 109 | enc, err := ocf.NewEncoder(schemaJson, f, ocf.WithCodec(ocf.Snappy)) 110 | 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | l.wg.Add(1) 116 | a := &avroFile{fh: f, enc: enc, open: true} 117 | 118 | // log.Printf("opened %s", a.fh.Name()) 119 | 120 | mu.Lock() 121 | defer mu.Unlock() 122 | openFiles = append([]*avroFile{a}, openFiles...) 123 | 124 | timer = time.After(l.maxtime) 125 | 126 | return a, nil 127 | } 128 | 129 | currentFile, err := openFile() 130 | if err != nil { 131 | log.Fatalf("openfile error: %s", err) 132 | } 133 | 134 | closeFile := func(af *avroFile) error { 135 | 136 | mu.Lock() 137 | idx := slices.Index(openFiles, af) 138 | if idx >= 0 { 139 | openFiles = slices.Delete(openFiles, idx, idx+1) 140 | } else { 141 | log.Printf("could not find avroFile for closing in openFiles list") 142 | } 143 | 144 | if !af.open { 145 | mu.Unlock() 146 | log.Printf("called closeFile on file already being closed %s", af.fh.Name()) 147 | return nil 148 | } 149 | 150 | af.open = false 151 | mu.Unlock() 152 | 153 | defer l.wg.Done() 154 | 155 | // log.Printf("closing %s", af.fh.Name()) 156 | 157 | if err := af.enc.Flush(); err != nil { 158 | return err 159 | } 160 | if err := af.fh.Sync(); err != nil { 161 | return err 162 | } 163 | if err := af.fh.Close(); err != nil { 164 | return err 165 | } 166 | 167 | tmpName := af.fh.Name() 168 | newName := strings.TrimSuffix(tmpName, ".tmp") 169 | if tmpName == newName { 170 | return fmt.Errorf("unexpected tmp file name %s", tmpName) 171 | } 172 | 173 | // log.Printf("renaming to %s", newName) 174 | if err := os.Rename(tmpName, newName); err != nil { 175 | return err 176 | } 177 | return nil 178 | } 179 | 180 | for { 181 | select { 182 | case e := <-l.ch: 183 | currentFile.count++ 184 | err := currentFile.enc.Encode(e) 185 | if err != nil { 186 | log.Fatal(err) 187 | } 188 | if currentFile.count%1000 == 0 { 189 | size, err := currentFile.fh.Seek(0, 2) 190 | if err != nil { 191 | log.Printf("could not seek avro file: %s", err) 192 | continue 193 | } 194 | if size > int64(l.maxsize) { 195 | // log.Printf("rotating avro file for size") 196 | currentFile, err = openFile() 197 | if err != nil { 198 | log.Printf("could not open new avro file: %s", err) 199 | } 200 | } 201 | } 202 | 203 | case <-ctx.Done(): 204 | log.Printf("closing avro files") 205 | 206 | // drain the buffer within reason 207 | count := 0 208 | drain: 209 | for { 210 | select { 211 | case e := <-l.ch: 212 | count++ 213 | err := currentFile.enc.Encode(e) 214 | if err != nil { 215 | log.Fatal(err) 216 | } 217 | if count > 40000 { 218 | break drain 219 | } 220 | default: 221 | break drain 222 | } 223 | } 224 | 225 | for i := len(openFiles) - 1; i >= 0; i-- { 226 | err := closeFile(openFiles[i]) 227 | if err != nil { 228 | log.Printf("error closing file: %s", err) 229 | } 230 | } 231 | return 232 | 233 | case <-timer: 234 | if currentFile.count == 0 { 235 | timer = time.After(l.maxtime) 236 | continue 237 | } 238 | 239 | // log.Printf("rotating avro file for time") 240 | 241 | var err error 242 | currentFile, err = openFile() 243 | if err != nil { 244 | log.Printf("could not open new avrofile: %s", err) 245 | } else { 246 | for i, af := range openFiles { 247 | if i == 0 || af == currentFile { 248 | continue 249 | } 250 | err := closeFile(af) 251 | if err != nil { 252 | log.Printf("error closing old avro files: %s", err) 253 | } 254 | } 255 | } 256 | } 257 | } 258 | 259 | } 260 | 261 | func (l *AvroLogger) Close() error { 262 | l.cancel(fmt.Errorf("closing")) 263 | <-l.ctx.Done() 264 | l.wg.Wait() // wait for all files to be closed 265 | return nil 266 | } 267 | -------------------------------------------------------------------------------- /querylog/avro_test.go: -------------------------------------------------------------------------------- 1 | package querylog 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "os" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func TestAvro(t *testing.T) { 12 | 13 | tmppath, err := os.MkdirTemp("", "geodns.avro") 14 | if err != nil { 15 | t.Fatalf("could not create temp dir: %s", err) 16 | } 17 | 18 | lg, err := NewAvroLogger(tmppath, 5000000, 4*time.Second) 19 | if err != nil { 20 | t.Log(err) 21 | t.FailNow() 22 | } 23 | 24 | dataFh, err := os.Open("testdata/queries.log") 25 | if err != nil { 26 | t.Log("no test data available") 27 | t.SkipNow() 28 | } 29 | dec := json.NewDecoder(dataFh) 30 | 31 | count := 0 32 | for { 33 | e := Entry{} 34 | err := dec.Decode(&e) 35 | if err != nil { 36 | if err == io.EOF { 37 | break 38 | } 39 | t.Logf("could not decode test data: %s", err) 40 | continue 41 | } 42 | count++ 43 | lg.Write(&e) 44 | } 45 | 46 | t.Logf("Write count: %d", count) 47 | 48 | // time.Sleep(time.Second * 2) 49 | 50 | err = lg.Close() 51 | if err != nil { 52 | t.Log(err) 53 | t.Fail() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /querylog/file.go: -------------------------------------------------------------------------------- 1 | package querylog 2 | 3 | import ( 4 | "encoding/json" 5 | 6 | "gopkg.in/natefinch/lumberjack.v2" 7 | ) 8 | 9 | type FileLogger struct { 10 | logger lumberjack.Logger 11 | } 12 | 13 | func NewFileLogger(filename string, maxsize int, keep int) (*FileLogger, error) { 14 | fl := &FileLogger{} 15 | fl.logger = lumberjack.Logger{ 16 | Filename: filename, 17 | MaxSize: maxsize, // megabytes 18 | MaxBackups: keep, 19 | } 20 | return fl, nil 21 | } 22 | 23 | func (l *FileLogger) Write(e *Entry) error { 24 | js, err := json.Marshal(e) 25 | if err != nil { 26 | return err 27 | } 28 | js = append(js, []byte("\n")...) 29 | _, err = l.logger.Write(js) 30 | return err 31 | } 32 | 33 | func (l *FileLogger) Close() error { 34 | return l.logger.Close() 35 | } 36 | -------------------------------------------------------------------------------- /querylog/querylog.avsc: -------------------------------------------------------------------------------- 1 | { 2 | "type": "record", 3 | "name": "GeodnsQuery", 4 | "namespace": "develooper", 5 | "fields" : [ 6 | {"name": "Time", "type": "long", "logicalType": "timestamp-micros"}, 7 | {"name": "Hostname", "type": "string"}, 8 | {"name": "Origin", "type": "string"}, 9 | {"name": "Name", "type": "string", "default": "" }, 10 | {"name": "Qtype", "type": "int"}, 11 | {"name": "Rcode", "type": "int"}, 12 | {"name": "AnswerCount", "type": "int"}, 13 | {"name": "Targets", 14 | "type": { 15 | "type": "array", 16 | "items": "string", 17 | "default": [] 18 | } 19 | }, 20 | {"name": "AnswerData", 21 | "type": { 22 | "type": "array", 23 | "items": "string", 24 | "default": [] 25 | } 26 | }, 27 | {"name": "LabelName", "type": "string"}, 28 | {"name": "RemoteAddr", "type": "string"}, 29 | {"name": "ClientAddr", "type": "string"}, 30 | {"name": "HasECS", "type": "boolean"}, 31 | {"name": "IsTCP", "type": "boolean"}, 32 | {"name": "Version", "type": "string"} 33 | ] 34 | } -------------------------------------------------------------------------------- /querylog/querylog.go: -------------------------------------------------------------------------------- 1 | package querylog 2 | 3 | type QueryLogger interface { 4 | Write(*Entry) error 5 | Close() error 6 | } 7 | 8 | type Entry struct { 9 | Time int64 10 | Hostname string `json:",omitempty"` // not filled in by geodns 11 | Origin string 12 | Name string 13 | Qtype uint16 14 | Rcode int 15 | AnswerCount int `json:"Answers"` 16 | Targets []string 17 | AnswerData []string 18 | LabelName string 19 | RemoteAddr string 20 | ClientAddr string 21 | HasECS bool 22 | IsTCP bool 23 | Version string 24 | } 25 | -------------------------------------------------------------------------------- /querylog/testdata/queries.log: -------------------------------------------------------------------------------- 1 | {"Time":1688414139649978091,"Origin":"pool.ntp.org","Name":"0.ubuntu.pool.ntp.org.","Qtype":2,"Rcode":0,"Answers":0,"Targets":["de","europe","@"],"LabelName":"","RemoteAddr":"141.35.40.33","ClientAddr":"141.35.40.33/32","HasECS":false} 2 | {"Time":1688414139650499477,"Origin":"pool.ntp.org","Name":"pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["in","asia","@"],"LabelName":"","RemoteAddr":"2405:200:1632:1957:78::4","ClientAddr":"2405:200:1632:1957:78::4/128","HasECS":false} 3 | {"Time":1688414139650694570,"Origin":"pool.ntp.org","Name":"2.debian.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["us","north-america","@"],"LabelName":"2.us","RemoteAddr":"192.178.36.9","ClientAddr":"70.225.160.0/24","HasECS":true} 4 | {"Time":1688414139650814098,"Origin":"pool.ntp.org","Name":"ru.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["br","south-america","@"],"LabelName":"ru","RemoteAddr":"187.58.136.98","ClientAddr":"187.58.136.98/32","HasECS":false} 5 | {"Time":1688414139650917846,"Origin":"pool.ntp.org","Name":"pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["in","asia","@"],"LabelName":"","RemoteAddr":"2405:200:1961:3937:78::5","ClientAddr":"2405:200:1961:3937:78::5/128","HasECS":false} 6 | {"Time":1688414139651209653,"Origin":"pool.ntp.org","Name":"0.fr.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["fr","europe","@"],"LabelName":"0.fr","RemoteAddr":"2a00:1db8:0:3::22","ClientAddr":"2a00:1db8:0:3::22/128","HasECS":false} 7 | {"Time":1688414139651529434,"Origin":"pool.ntp.org","Name":"android.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["de","europe","@"],"LabelName":"de","RemoteAddr":"85.22.54.58","ClientAddr":"85.22.54.58/32","HasECS":false} 8 | {"Time":1688414139651670493,"Origin":"pool.ntp.org","Name":"2.sonostime.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["cn","asia","@"],"LabelName":"2.cn","RemoteAddr":"2409:8018:2001::2","ClientAddr":"2409:8018:2001::2/128","HasECS":false} 9 | {"Time":1688414139651980436,"Origin":"pool.ntp.org","Name":"pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["in","asia","@"],"LabelName":"","RemoteAddr":"49.45.29.164","ClientAddr":"49.45.29.164/32","HasECS":false} 10 | {"Time":1688414139652110915,"Origin":"pool.ntp.org","Name":"ubnt.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["us","north-america","@"],"LabelName":"us","RemoteAddr":"67.146.32.23","ClientAddr":"67.146.32.23/32","HasECS":false} 11 | {"Time":1688414139652558730,"Origin":"pool.ntp.org","Name":"0.openwrt.pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["cn","asia","@"],"LabelName":"","RemoteAddr":"172.253.5.2","ClientAddr":"112.226.217.0/24","HasECS":true} 12 | {"Time":1688414139652799400,"Origin":"pool.ntp.org","Name":"0.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["us","north-america","@"],"LabelName":"0.us","RemoteAddr":"74.125.191.8","ClientAddr":"209.236.115.0/24","HasECS":true} 13 | {"Time":1688414139653295247,"Origin":"pool.ntp.org","Name":"hk.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["cn","asia","@"],"LabelName":"hk","RemoteAddr":"2409:8020:2000:924::d","ClientAddr":"2409:8020:2000:924::d/128","HasECS":false} 14 | {"Time":1688414139653833966,"Origin":"pool.ntp.org","Name":"pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["in","asia","@"],"LabelName":"","RemoteAddr":"2405:200:160b:1957:78::5","ClientAddr":"2405:200:160b:1957:78::5/128","HasECS":false} 15 | {"Time":1688414139653988631,"Origin":"pool.ntp.org","Name":"0.formlabs.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":2,"Targets":["kr","asia","@"],"LabelName":"0.kr","RemoteAddr":"106.241.133.17","ClientAddr":"106.241.133.17/32","HasECS":false} 16 | {"Time":1688414139654098471,"Origin":"pool.ntp.org","Name":"ru.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":4,"Targets":["bd","asia","@"],"LabelName":"ru","RemoteAddr":"2404:6800:4000:101d::105","ClientAddr":"103.206.231.0/24","HasECS":true} 17 | {"Time":1688414139654304414,"Origin":"pool.ntp.org","Name":"3.debian.pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["tw","asia","@"],"LabelName":"","RemoteAddr":"2001:b030:2154:fffc::1","ClientAddr":"2001:b030:2154:fffc::1/128","HasECS":false} 18 | {"Time":1688414139654809920,"Origin":"pool.ntp.org","Name":"0.pool.ntp.org.","Qtype":1,"Rcode":0,"Answers":2,"Targets":["bd","asia","@"],"LabelName":"0.bd","RemoteAddr":"2404:6800:4000:1002::102","ClientAddr":"103.106.164.0/24","HasECS":true} 19 | {"Time":1688414139654945178,"Origin":"pool.ntp.org","Name":"europe.pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["cn","asia","@"],"LabelName":"","RemoteAddr":"172.253.6.3","ClientAddr":"123.149.74.0/24","HasECS":true} 20 | {"Time":1688414139655044528,"Origin":"pool.ntp.org","Name":"sg.pool.ntp.org.","Qtype":28,"Rcode":0,"Answers":0,"Targets":["my","asia","@"],"LabelName":"","RemoteAddr":"123.136.100.101","ClientAddr":"123.136.100.101/32","HasECS":false} 21 | -------------------------------------------------------------------------------- /scripts/defaults: -------------------------------------------------------------------------------- 1 | beta4_key= 2 | beta6_key= 3 | beta4_url=https://www.beta.grundclock.com/monitor 4 | beta6_url=https://www.beta.grundclock.com/monitor 5 | 6 | prod4_key= 7 | prod6_key= 8 | prod4_url=https://api.ntppool.org/monitor 9 | prod6_url=https://api.ntppool.org/monitor 10 | -------------------------------------------------------------------------------- /scripts/download-release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | BASE=$1 4 | BUILD=$2 5 | DIR=$3 6 | 7 | set -euo pipefail 8 | 9 | if [ -z "$DIR" ]; then 10 | echo run with $0 NAME BUILD_NUMBER DIR 11 | exit 2 12 | fi 13 | 14 | mkdir -p $DIR 15 | 16 | BASE=https://geodns.bitnames.com/${BASE}/builds/${BUILD} 17 | 18 | files=`curl -sSf ${BASE}/checksums.txt | awk '{print $2}'` 19 | metafiles="checksums.txt metadata.json CHANGELOG.md artifacts.json" 20 | 21 | for f in $metafiles; do 22 | url=$BASE/$f 23 | echo downloading $url 24 | curl --remove-on-error -sSfRo $DIR/$f $url || true 25 | done 26 | 27 | 28 | for f in $files; do 29 | url=$BASE/$f 30 | echo downloading $url 31 | curl --remove-on-error -sSfRo $DIR/$f $url 32 | done 33 | -------------------------------------------------------------------------------- /scripts/download-test-geoip: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | DIR=/usr/local/share/GeoIP 6 | 7 | mkdir -p $DIR 8 | 9 | for f in GeoLite2-ASN.mmdb GeoLite2-City.mmdb GeoLite2-Country.mmdb; do 10 | Z="" 11 | if [ -e $DIR/$f ]; then 12 | Z="-z $DIR/$f" 13 | fi 14 | curl $Z -sfo $DIR/$f https://geodns.bitnames.com/geoip/$f 15 | done 16 | -------------------------------------------------------------------------------- /scripts/fury-publish: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | account=$1 4 | 5 | set -euo pipefail 6 | 7 | if [ -z "$account" ]; then 8 | echo specify account as the first parameter 9 | exit 2 10 | fi 11 | 12 | for f in dist/*.rpm dist/*.deb; do 13 | echo Uploading $f 14 | curl -sf -F package=@$f https://${FURY_TOKEN}@push.fury.io/${account}/ 15 | done 16 | -------------------------------------------------------------------------------- /scripts/geodns.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=GeoDNS server 3 | 4 | [Service] 5 | Type=simple 6 | EnvironmentFile=-/etc/default/geodns 7 | ExecStart=/usr/bin/geodns 8 | Restart=always 9 | TimeoutStartSec=10 10 | RestartSec=10 11 | 12 | [Install] 13 | WantedBy=multi-user.target 14 | -------------------------------------------------------------------------------- /scripts/postinstall.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | systemctl daemon-reload -------------------------------------------------------------------------------- /scripts/run-goreleaser: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | go install github.com/goreleaser/goreleaser@v1.20.0 6 | 7 | DRONE_TAG=${DRONE_TAG-""} 8 | 9 | is_snapshot="" 10 | 11 | if [ -z "$DRONE_TAG" ]; then 12 | is_snapshot="--snapshot" 13 | fi 14 | 15 | goreleaser release $is_snapshot -p 6 --skip-publish 16 | -------------------------------------------------------------------------------- /server/querylog_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/abh/geodns/v3/querylog" 7 | "github.com/miekg/dns" 8 | ) 9 | 10 | type testLogger struct { 11 | lastLog querylog.Entry 12 | } 13 | 14 | func (l *testLogger) Close() error { 15 | return nil 16 | } 17 | 18 | func (l *testLogger) Write(ql *querylog.Entry) error { 19 | l.lastLog = *ql 20 | return nil 21 | } 22 | 23 | func (l *testLogger) Last() querylog.Entry { 24 | // l.logged = false 25 | return l.lastLog 26 | } 27 | 28 | func testQueryLog(srv *Server) func(*testing.T) { 29 | 30 | tlog := &testLogger{} 31 | 32 | srv.SetQueryLogger(tlog) 33 | 34 | return func(t *testing.T) { 35 | 36 | r := exchange(t, "www-alias.example.com.", dns.TypeA) 37 | expected := "geo.bitnames.com." 38 | answer := r.Answer[0].(*dns.CNAME).Target 39 | if answer != expected { 40 | t.Logf("expected CNAME %s, got %s", expected, answer) 41 | t.Fail() 42 | } 43 | 44 | last := tlog.Last() 45 | // t.Logf("last log: %+v", last) 46 | 47 | if last.Name != "www-alias.example.com." { 48 | t.Logf("didn't get qname in Name querylog") 49 | t.Fail() 50 | } 51 | if last.LabelName != "www" { 52 | t.Logf("LabelName didn't contain resolved label") 53 | t.Fail() 54 | } 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /server/serve.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "encoding/hex" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "net" 9 | "net/netip" 10 | "os" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/abh/geodns/v3/applog" 16 | "github.com/abh/geodns/v3/edns" 17 | "github.com/abh/geodns/v3/querylog" 18 | "github.com/abh/geodns/v3/zones" 19 | 20 | "github.com/miekg/dns" 21 | "github.com/prometheus/client_golang/prometheus" 22 | ) 23 | 24 | func getQuestionName(z *zones.Zone, fqdn string) string { 25 | lx := dns.SplitDomainName(fqdn) 26 | ql := lx[0 : len(lx)-z.LabelCount] 27 | return strings.ToLower(strings.Join(ql, ".")) 28 | } 29 | 30 | func (srv *Server) serve(w dns.ResponseWriter, req *dns.Msg, z *zones.Zone) { 31 | 32 | qnamefqdn := req.Question[0].Name 33 | qtype := req.Question[0].Qtype 34 | 35 | var qle *querylog.Entry 36 | 37 | if srv.queryLogger != nil { 38 | 39 | var isTcp bool 40 | if net := w.LocalAddr().Network(); net == "tcp" { 41 | isTcp = true 42 | } 43 | 44 | qle = &querylog.Entry{ 45 | Time: time.Now().UnixNano(), 46 | Origin: z.Origin, 47 | Name: strings.ToLower(qnamefqdn), 48 | Qtype: qtype, 49 | Version: srv.info.Version, 50 | IsTCP: isTcp, 51 | } 52 | defer srv.queryLogger.Write(qle) 53 | } 54 | 55 | applog.Printf("[zone %s] incoming %s %s (id %d) from %s\n", z.Origin, qnamefqdn, 56 | dns.TypeToString[qtype], req.Id, w.RemoteAddr()) 57 | 58 | applog.Println("Got request", req) 59 | 60 | // qlabel is the qname without the zone origin suffix 61 | qlabel := getQuestionName(z, qnamefqdn) 62 | 63 | z.Metrics.LabelStats.Add(qlabel) 64 | 65 | // IP that's talking to us (not EDNS CLIENT SUBNET) 66 | var realIP net.IP 67 | 68 | if addr, ok := w.RemoteAddr().(*net.UDPAddr); ok { 69 | realIP = make(net.IP, len(addr.IP)) 70 | copy(realIP, addr.IP) 71 | } else if addr, ok := w.RemoteAddr().(*net.TCPAddr); ok { 72 | realIP = make(net.IP, len(addr.IP)) 73 | copy(realIP, addr.IP) 74 | } 75 | if qle != nil { 76 | qle.RemoteAddr = realIP.String() 77 | } 78 | 79 | z.Metrics.ClientStats.Add(realIP.String()) 80 | 81 | var ip net.IP // EDNS CLIENT SUBNET or real IP 82 | var ecs *dns.EDNS0_SUBNET 83 | 84 | if option := req.IsEdns0(); option != nil { 85 | for _, s := range option.Option { 86 | switch e := s.(type) { 87 | case *dns.EDNS0_SUBNET: 88 | applog.Println("Got edns-client-subnet", e.Address, e.Family, e.SourceNetmask, e.SourceScope) 89 | if e.Address != nil { 90 | ecs = e 91 | 92 | if ecsip, ok := netip.AddrFromSlice(e.Address); ok { 93 | if ecsip.IsGlobalUnicast() && 94 | !(ecsip.IsPrivate() || 95 | ecsip.IsLinkLocalMulticast() || 96 | ecsip.IsInterfaceLocalMulticast()) { 97 | ip = ecsip.AsSlice() 98 | } 99 | } 100 | 101 | if qle != nil { 102 | qle.HasECS = true 103 | qle.ClientAddr = fmt.Sprintf("%s/%d", ip, e.SourceNetmask) 104 | } 105 | } 106 | } 107 | } 108 | } 109 | 110 | if len(ip) == 0 { // no edns client subnet 111 | ip = realIP 112 | if qle != nil { 113 | qle.ClientAddr = fmt.Sprintf("%s/%d", ip, len(ip)*8) 114 | } 115 | } 116 | 117 | targets, netmask, location := z.Options.Targeting.GetTargets(ip, z.HasClosest) 118 | 119 | // if the ECS IP didn't get targets, try the real IP instead 120 | if l := len(targets); (l == 0 || l == 1 && targets[0] == "@") && !ip.Equal(realIP) { 121 | targets, netmask, location = z.Options.Targeting.GetTargets(realIP, z.HasClosest) 122 | } 123 | 124 | m := &dns.Msg{} 125 | 126 | // setup logging of answers and rcode 127 | if qle != nil { 128 | qle.Targets = targets 129 | defer func() { 130 | qle.Rcode = m.Rcode 131 | qle.AnswerCount = len(m.Answer) 132 | 133 | for _, rr := range m.Answer { 134 | var s string 135 | switch a := rr.(type) { 136 | case *dns.A: 137 | s = a.A.String() 138 | case *dns.AAAA: 139 | s = a.AAAA.String() 140 | case *dns.CNAME: 141 | s = a.Target 142 | case *dns.MX: 143 | s = a.Mx 144 | case *dns.NS: 145 | s = a.Ns 146 | case *dns.SRV: 147 | s = a.Target 148 | case *dns.TXT: 149 | s = strings.Join(a.Txt, " ") 150 | } 151 | if len(s) > 0 { 152 | qle.AnswerData = append(qle.AnswerData, s) 153 | } 154 | } 155 | }() 156 | } 157 | 158 | mv, err := edns.Version(req) 159 | if err != nil { 160 | m = mv 161 | err := w.WriteMsg(m) 162 | if err != nil { 163 | applog.Printf("could not write response: %s", err) 164 | } 165 | return 166 | } 167 | 168 | m.SetReply(req) 169 | 170 | if option := edns.SetSizeAndDo(req, m); option != nil { 171 | 172 | for _, s := range option.Option { 173 | switch e := s.(type) { 174 | case *dns.EDNS0_NSID: 175 | e.Code = dns.EDNS0NSID 176 | e.Nsid = hex.EncodeToString([]byte(srv.info.ID)) 177 | case *dns.EDNS0_SUBNET: 178 | // access e.Family, e.Address, etc. 179 | // TODO: set scope to 0 if there are no alternate responses 180 | if ecs.Family != 0 { 181 | if netmask < 16 { 182 | netmask = 16 183 | } 184 | e.SourceScope = uint8(netmask) 185 | } 186 | } 187 | } 188 | } 189 | 190 | m.Authoritative = true 191 | 192 | labelMatches := z.FindLabels(qlabel, targets, []uint16{dns.TypeMF, dns.TypeCNAME, qtype}) 193 | 194 | if len(labelMatches) == 0 { 195 | 196 | permitDebug := srv.PublicDebugQueries || (realIP != nil && realIP.IsLoopback()) 197 | 198 | firstLabel := (strings.Split(qlabel, "."))[0] 199 | 200 | if qle != nil { 201 | qle.LabelName = firstLabel 202 | } 203 | 204 | if permitDebug && firstLabel == "_status" { 205 | if qtype == dns.TypeANY || qtype == dns.TypeTXT { 206 | m.Answer = srv.statusRR(qlabel + "." + z.Origin + ".") 207 | } else { 208 | m.Ns = append(m.Ns, z.SoaRR()) 209 | } 210 | m.Authoritative = true 211 | w.WriteMsg(m) 212 | return 213 | } 214 | 215 | if permitDebug && firstLabel == "_health" { 216 | if qtype == dns.TypeANY || qtype == dns.TypeTXT { 217 | baseLabel := strings.Join((strings.Split(qlabel, "."))[1:], ".") 218 | m.Answer = z.HealthRR(qlabel+"."+z.Origin+".", baseLabel) 219 | m.Authoritative = true 220 | w.WriteMsg(m) 221 | return 222 | } 223 | m.Ns = append(m.Ns, z.SoaRR()) 224 | m.Authoritative = true 225 | w.WriteMsg(m) 226 | return 227 | } 228 | 229 | if firstLabel == "_country" { 230 | if qtype == dns.TypeANY || qtype == dns.TypeTXT { 231 | h := dns.RR_Header{Ttl: 1, Class: dns.ClassINET, Rrtype: dns.TypeTXT} 232 | h.Name = qnamefqdn 233 | 234 | txt := []string{ 235 | w.RemoteAddr().String(), 236 | ip.String(), 237 | } 238 | 239 | targets, netmask, location := z.Options.Targeting.GetTargets(ip, z.HasClosest) 240 | txt = append(txt, strings.Join(targets, " ")) 241 | txt = append(txt, fmt.Sprintf("/%d", netmask), srv.info.ID, srv.info.IP) 242 | if location != nil { 243 | txt = append(txt, fmt.Sprintf("(%.3f,%.3f)", location.Latitude, location.Longitude)) 244 | } else { 245 | txt = append(txt, "()") 246 | } 247 | 248 | m.Answer = []dns.RR{&dns.TXT{Hdr: h, 249 | Txt: txt, 250 | }} 251 | } else { 252 | m.Ns = append(m.Ns, z.SoaRR()) 253 | } 254 | 255 | m.Authoritative = true 256 | 257 | w.WriteMsg(m) 258 | return 259 | } 260 | 261 | // return NXDOMAIN 262 | m.SetRcode(req, dns.RcodeNameError) 263 | srv.metrics.Queries.With( 264 | prometheus.Labels{ 265 | "zone": z.Origin, 266 | "qtype": dns.TypeToString[qtype], 267 | "qname": "_error", 268 | "rcode": dns.RcodeToString[m.Rcode], 269 | }).Inc() 270 | m.Authoritative = true 271 | 272 | m.Ns = []dns.RR{z.SoaRR()} 273 | 274 | w.WriteMsg(m) 275 | return 276 | } 277 | 278 | for _, match := range labelMatches { 279 | label := match.Label 280 | labelQtype := match.Type 281 | 282 | if !label.Closest { 283 | location = nil 284 | } 285 | 286 | if servers := z.Picker(label, labelQtype, label.MaxHosts, location); servers != nil { 287 | var rrs []dns.RR 288 | for _, record := range servers { 289 | rr := dns.Copy(record.RR) 290 | rr.Header().Name = qnamefqdn 291 | rrs = append(rrs, rr) 292 | } 293 | m.Answer = rrs 294 | } 295 | if len(m.Answer) > 0 { 296 | // maxHosts only matter within a "targeting group"; at least that's 297 | // how it has been working, so we stop looking for answers as soon 298 | // as we have some. 299 | 300 | if qle != nil { 301 | qle.LabelName = label.Label 302 | qle.AnswerCount = len(m.Answer) 303 | } 304 | 305 | break 306 | } 307 | } 308 | 309 | if len(m.Answer) == 0 { 310 | // Return a SOA so the NOERROR answer gets cached 311 | m.Ns = append(m.Ns, z.SoaRR()) 312 | } 313 | 314 | qlabelMetric := "_" 315 | if srv.DetailedMetrics { 316 | qlabelMetric = qlabel 317 | } 318 | 319 | srv.metrics.Queries.With( 320 | prometheus.Labels{ 321 | "zone": z.Origin, 322 | "qtype": dns.TypeToString[qtype], 323 | "qname": qlabelMetric, 324 | "rcode": dns.RcodeToString[m.Rcode], 325 | }).Inc() 326 | 327 | applog.Println(m) 328 | 329 | if qle != nil { 330 | // should this be in the match loop above? 331 | qle.Rcode = m.Rcode 332 | } 333 | err = w.WriteMsg(m) 334 | if err != nil { 335 | // if Pack'ing fails the Write fails. Return SERVFAIL. 336 | applog.Printf("Error writing packet: %q, %s", err, m) 337 | dns.HandleFailed(w, req) 338 | } 339 | 340 | } 341 | 342 | func (srv *Server) statusRR(label string) []dns.RR { 343 | h := dns.RR_Header{Ttl: 1, Class: dns.ClassINET, Rrtype: dns.TypeTXT} 344 | h.Name = label 345 | 346 | status := map[string]string{"v": srv.info.Version, "id": srv.info.ID} 347 | 348 | hostname, err := os.Hostname() 349 | if err == nil { 350 | status["h"] = hostname 351 | } 352 | 353 | status["up"] = strconv.Itoa(int(time.Since(srv.info.Started).Seconds())) 354 | 355 | js, err := json.Marshal(status) 356 | if err != nil { 357 | log.Printf("error marshaling json status: %s", err) 358 | } 359 | 360 | return []dns.RR{&dns.TXT{Hdr: h, Txt: []string{string(js)}}} 361 | } 362 | -------------------------------------------------------------------------------- /server/serve_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "reflect" 7 | "strings" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | "github.com/stretchr/testify/require" 13 | 14 | "github.com/abh/geodns/v3/appconfig" 15 | "github.com/abh/geodns/v3/monitor" 16 | "github.com/abh/geodns/v3/zones" 17 | "github.com/miekg/dns" 18 | ) 19 | 20 | const ( 21 | PORT = ":8853" 22 | ) 23 | 24 | func TestServe(t *testing.T) { 25 | serverInfo := &monitor.ServerInfo{} 26 | 27 | srv := NewServer(appconfig.Config, serverInfo) 28 | ctx, cancel := context.WithCancel(context.Background()) 29 | 30 | mm, err := zones.NewMuxManager("../dns", srv) 31 | if err != nil { 32 | t.Fatalf("Loading test zones: %s", err) 33 | } 34 | go mm.Run(ctx) 35 | 36 | go func() { 37 | srv.ListenAndServe(ctx, PORT) 38 | }() 39 | 40 | // ensure service has properly started before we query it 41 | time.Sleep(500 * time.Millisecond) 42 | 43 | t.Run("Serving", testServing) 44 | 45 | t.Run("QueryLog", testQueryLog(srv)) 46 | 47 | // todo: run test queries? 48 | 49 | cancel() 50 | 51 | srv.Shutdown() 52 | } 53 | 54 | func testServing(t *testing.T) { 55 | r := exchange(t, "_status.pgeodns.", dns.TypeTXT) 56 | require.Len(t, r.Answer, 1, "1 txt record for _status.pgeodns") 57 | txt := r.Answer[0].(*dns.TXT).Txt[0] 58 | if !strings.HasPrefix(txt, "{") { 59 | t.Log("Unexpected result for _status.pgeodns", txt) 60 | t.Fail() 61 | } 62 | 63 | // Allow _country and _status queries as long as the first label is that 64 | r = exchange(t, "_country.foo.pgeodns.", dns.TypeTXT) 65 | txt = r.Answer[0].(*dns.TXT).Txt[0] 66 | // Got appropriate response for _country txt query 67 | if !strings.HasPrefix(txt, "127.0.0.1:") { 68 | t.Log("Unexpected result for _country.foo.pgeodns", txt) 69 | t.Fail() 70 | } 71 | 72 | // Make sure A requests for _status doesn't NXDOMAIN 73 | r = exchange(t, "_status.pgeodns.", dns.TypeA) 74 | if len(r.Answer) != 0 { 75 | t.Log("got A record for _status.pgeodns") 76 | t.Fail() 77 | } 78 | if len(r.Ns) != 1 { 79 | t.Logf("Expected 1 SOA record, got %d", len(r.Ns)) 80 | t.Fail() 81 | } 82 | // NOERROR for A request 83 | checkRcode(t, r.Rcode, dns.RcodeSuccess, "_status.pgeodns") 84 | 85 | // bar is an alias 86 | r = exchange(t, "bar.test.example.com.", dns.TypeA) 87 | ip := r.Answer[0].(*dns.A).A 88 | if ip.String() != "192.168.1.2" { 89 | t.Logf("unexpected A record for bar.test.example.com: %s", ip.String()) 90 | t.Fail() 91 | } 92 | 93 | // bar is an alias to test, the SOA record should be for test 94 | r = exchange(t, "_.root-alias.test.example.com.", dns.TypeA) 95 | if len(r.Answer) > 0 { 96 | t.Errorf("got answers for _.root-alias.test.example.com") 97 | } 98 | if len(r.Ns) == 0 { 99 | t.Fatalf("_.root-alias.test didn't return auth section") 100 | } 101 | if n := r.Ns[0].(*dns.SOA).Header().Name; n != "test.example.com." { 102 | t.Fatalf("_.root-alias.test didn't have test.example.com soa: %s", n) 103 | } 104 | 105 | // root-alias is an alias to test (apex), but the NS records shouldn't be on root-alias 106 | r = exchange(t, "root-alias.test.example.com.", dns.TypeNS) 107 | if len(r.Answer) > 0 { 108 | t.Errorf("got unexpected answers for root-alias.test.example.com NS") 109 | } 110 | if len(r.Ns) == 0 { 111 | t.Fatalf("root-alias.test NS didn't return auth section") 112 | } 113 | 114 | r = exchange(t, "test.example.com.", dns.TypeSOA) 115 | soa := r.Answer[0].(*dns.SOA) 116 | serial := soa.Serial 117 | assert.Equal(t, 3, int(serial)) 118 | 119 | // no AAAA records for 'bar', so check we get a soa record back 120 | r = exchange(t, "bar.test.example.com.", dns.TypeAAAA) 121 | soa2 := r.Ns[0].(*dns.SOA) 122 | if !reflect.DeepEqual(soa, soa2) { 123 | t.Errorf("AAAA empty NOERROR soa record different from SOA request") 124 | } 125 | 126 | // CNAMEs 127 | r = exchange(t, "www.test.example.com.", dns.TypeA) 128 | // c.Check(r.Answer[0].(*dns.CNAME).Target, Equals, "geo.bitnames.com.") 129 | if int(r.Answer[0].Header().Ttl) != 1800 { 130 | t.Logf("unexpected ttl '%d' for geo.bitnames.com (expected %d)", int(r.Answer[0].Header().Ttl), 1800) 131 | t.Fail() 132 | } 133 | 134 | //SPF 135 | r = exchange(t, "test.example.com.", dns.TypeSPF) 136 | assert.Equal(t, r.Answer[0].(*dns.SPF).Txt[0], "v=spf1 ~all") 137 | 138 | //SRV 139 | r = exchange(t, "_sip._tcp.test.example.com.", dns.TypeSRV) 140 | assert.Equal(t, r.Answer[0].(*dns.SRV).Target, "sipserver.example.com.") 141 | assert.Equal(t, r.Answer[0].(*dns.SRV).Port, uint16(5060)) 142 | assert.Equal(t, r.Answer[0].(*dns.SRV).Priority, uint16(10)) 143 | assert.Equal(t, r.Answer[0].(*dns.SRV).Weight, uint16(100)) 144 | 145 | // MX 146 | r = exchange(t, "test.example.com.", dns.TypeMX) 147 | assert.Equal(t, r.Answer[0].(*dns.MX).Mx, "mx.example.net.") 148 | assert.Equal(t, r.Answer[1].(*dns.MX).Mx, "mx2.example.net.") 149 | assert.Equal(t, r.Answer[1].(*dns.MX).Preference, uint16(20)) 150 | 151 | // Verify the first A record was created 152 | r = exchange(t, "a.b.c.test.example.com.", dns.TypeA) 153 | ip = r.Answer[0].(*dns.A).A 154 | assert.Equal(t, ip.String(), "192.168.1.7") 155 | 156 | // Verify sub-labels are created 157 | r = exchange(t, "b.c.test.example.com.", dns.TypeA) 158 | assert.Len(t, r.Answer, 0, "expect 0 answer records for b.c.test.example.com") 159 | checkRcode(t, r.Rcode, dns.RcodeSuccess, "b.c.test.example.com") 160 | 161 | r = exchange(t, "c.test.example.com.", dns.TypeA) 162 | assert.Len(t, r.Answer, 0, "expect 0 answer records for c.test.example.com") 163 | checkRcode(t, r.Rcode, dns.RcodeSuccess, "c.test.example.com") 164 | 165 | // Verify the first A record was created 166 | r = exchange(t, "three.two.one.test.example.com.", dns.TypeA) 167 | ip = r.Answer[0].(*dns.A).A 168 | 169 | assert.Equal(t, ip.String(), "192.168.1.5", "three.two.one.test.example.com A record") 170 | 171 | // Verify single sub-labels is created and no record is returned 172 | r = exchange(t, "two.one.test.example.com.", dns.TypeA) 173 | assert.Len(t, r.Answer, 0, "expect 0 answer records for two.one.test.example.com") 174 | checkRcode(t, r.Rcode, dns.RcodeSuccess, "two.one.test.example.com") 175 | 176 | // Verify the A record wasn't over written 177 | r = exchange(t, "one.test.example.com.", dns.TypeA) 178 | ip = r.Answer[0].(*dns.A).A 179 | assert.Equal(t, ip.String(), "192.168.1.6", "one.test.example.com A record") 180 | 181 | // PTR 182 | r = exchange(t, "2.1.168.192.IN-ADDR.ARPA.", dns.TypePTR) 183 | assert.Len(t, r.Answer, 1, "expect 1 answer records for 2.1.168.192.IN-ADDR.ARPA") 184 | checkRcode(t, r.Rcode, dns.RcodeSuccess, "2.1.168.192.IN-ADDR.ARPA") 185 | 186 | name := r.Answer[0].(*dns.PTR).Ptr 187 | assert.Equal(t, name, "bar.example.com.", "PTR record") 188 | } 189 | 190 | // func TestServingMixedCase(t *testing.T) { 191 | 192 | // r := exchange(c, "_sTaTUs.pGEOdns.", dns.TypeTXT) 193 | // checkRcode(t, r.Rcode, dns.RcodeSuccess, "_sTaTUs.pGEOdns.") 194 | 195 | // txt := r.Answer[0].(*dns.TXT).Txt[0] 196 | // if !strings.HasPrefix(txt, "{") { 197 | // t.Log("Unexpected result for _status.pgeodns", txt) 198 | // t.Fail() 199 | // } 200 | 201 | // n := "baR.test.eXAmPLe.cOM." 202 | // r = exchange(c, n, dns.TypeA) 203 | // ip := r.Answer[0].(*dns.A).A 204 | // c.Check(ip.String(), Equals, "192.168.1.2") 205 | // c.Check(r.Answer[0].Header().Name, Equals, n) 206 | 207 | // } 208 | 209 | // func TestCname(t *testing.T) { 210 | // // Cname, two possible results 211 | 212 | // results := make(map[string]int) 213 | 214 | // for i := 0; i < 10; i++ { 215 | // r := exchange(c, "www.se.test.example.com.", dns.TypeA) 216 | // // only return one CNAME even if there are multiple options 217 | // c.Check(r.Answer, HasLen, 1) 218 | // target := r.Answer[0].(*dns.CNAME).Target 219 | // results[target]++ 220 | // } 221 | 222 | // // Two possible results from this cname 223 | // c.Check(results, HasLen, 2) 224 | // } 225 | 226 | // func testUnknownDomain(t *testing.T) { 227 | // r := exchange(t, "no.such.domain.", dns.TypeAAAA) 228 | // c.Assert(r.Rcode, Equals, dns.RcodeRefused) 229 | // } 230 | 231 | // func testServingAliases(t *testing.T) { 232 | // // Alias, no geo matches 233 | // r := exchange(c, "bar-alias.test.example.com.", dns.TypeA) 234 | // ip := r.Answer[0].(*dns.A).A 235 | // c.Check(ip.String(), Equals, "192.168.1.2") 236 | 237 | // // Alias to a cname record 238 | // r = exchange(c, "www-alias.test.example.com.", dns.TypeA) 239 | // c.Check(r.Answer[0].(*dns.CNAME).Target, Equals, "geo.bitnames.com.") 240 | 241 | // // Alias returning a cname, with geo overrides 242 | // r = exchangeSubnet(c, "www-alias.test.example.com.", dns.TypeA, "194.239.134.1") 243 | // c.Check(r.Answer, HasLen, 1) 244 | // if len(r.Answer) > 0 { 245 | // c.Check(r.Answer[0].(*dns.CNAME).Target, Equals, "geo-europe.bitnames.com.") 246 | // } 247 | 248 | // // Alias to Ns records 249 | // r = exchange(c, "sub-alias.test.example.org.", dns.TypeNS) 250 | // c.Check(r.Answer[0].(*dns.NS).Ns, Equals, "ns1.example.com.") 251 | 252 | // } 253 | 254 | // func testServingEDNS(t *testing.T) { 255 | // // MX test 256 | // r := exchangeSubnet(t, "test.example.com.", dns.TypeMX, "194.239.134.1") 257 | // c.Check(r.Answer, HasLen, 1) 258 | // if len(r.Answer) > 0 { 259 | // c.Check(r.Answer[0].(*dns.MX).Mx, Equals, "mx-eu.example.net.") 260 | // } 261 | 262 | // c.Log("Testing www.test.example.com from .dk, should match www.europe (a cname)") 263 | 264 | // r = exchangeSubnet(c, "www.test.example.com.", dns.TypeA, "194.239.134.0") 265 | // // www.test from .dk IP address gets at least one answer 266 | // c.Check(r.Answer, HasLen, 1) 267 | // if len(r.Answer) > 0 { 268 | // // EDNS-SUBNET test (request A, respond CNAME) 269 | // c.Check(r.Answer[0].(*dns.CNAME).Target, Equals, "geo-europe.bitnames.com.") 270 | // } 271 | 272 | // } 273 | 274 | // func TestServeRace(t *testing.T) { 275 | // wg := sync.WaitGroup{} 276 | // for i := 0; i < 5; i++ { 277 | // wg.Add(1) 278 | // go func() { 279 | // s.TestServing(t) 280 | // wg.Done() 281 | // }() 282 | // } 283 | // wg.Wait() 284 | // } 285 | 286 | // func BenchmarkServingCountryDebug(b *testing.B) { 287 | // for i := 0; i < b.N; i++ { 288 | // exchange(b, "_country.foo.pgeodns.", dns.TypeTXT) 289 | // } 290 | // } 291 | 292 | // func BenchmarkServing(b *testing.B) { 293 | 294 | // // a deterministic seed is the default anyway, but let's be explicit we want it here. 295 | // rnd := rand.NewSource(1) 296 | 297 | // testNames := []string{"foo.test.example.com.", "one.test.example.com.", 298 | // "weight.test.example.com.", "three.two.one.test.example.com.", 299 | // "bar.test.example.com.", "0-alias.test.example.com.", 300 | // } 301 | 302 | // for i := 0; i < c.N; i++ { 303 | // name := testNames[rnd.Int63()%int64(len(testNames))] 304 | // exchange(t, name, dns.TypeA) 305 | // } 306 | // } 307 | 308 | func checkRcode(t *testing.T, rcode int, expected int, name string) { 309 | if rcode != expected { 310 | t.Logf("'%s': rcode!=%s: %s", name, dns.RcodeToString[expected], dns.RcodeToString[rcode]) 311 | t.Fail() 312 | } 313 | } 314 | 315 | func exchangeSubnet(t *testing.T, name string, dnstype uint16, ip string) *dns.Msg { 316 | msg := new(dns.Msg) 317 | 318 | msg.SetQuestion(name, dnstype) 319 | 320 | o := new(dns.OPT) 321 | o.Hdr.Name = "." 322 | o.Hdr.Rrtype = dns.TypeOPT 323 | e := new(dns.EDNS0_SUBNET) 324 | e.Code = dns.EDNS0SUBNET 325 | e.SourceScope = 0 326 | e.Address = net.ParseIP(ip) 327 | e.Family = 1 // IP4 328 | e.SourceNetmask = net.IPv4len * 8 329 | o.Option = append(o.Option, e) 330 | msg.Extra = append(msg.Extra, o) 331 | 332 | t.Log("msg", msg) 333 | 334 | return dorequest(t, msg) 335 | } 336 | 337 | func exchange(t *testing.T, name string, dnstype uint16) *dns.Msg { 338 | msg := new(dns.Msg) 339 | 340 | msg.SetQuestion(name, dnstype) 341 | return dorequest(t, msg) 342 | } 343 | 344 | func dorequest(t *testing.T, msg *dns.Msg) *dns.Msg { 345 | cli := new(dns.Client) 346 | // cli.ReadTimeout = 2 * time.Second 347 | r, _, err := cli.Exchange(msg, "127.0.0.1"+PORT) 348 | if err != nil { 349 | t.Logf("request err '%s': %s", msg.String(), err) 350 | t.Fail() 351 | } 352 | return r 353 | } 354 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "log" 7 | "sync" 8 | "time" 9 | 10 | "github.com/abh/geodns/v3/appconfig" 11 | "github.com/abh/geodns/v3/monitor" 12 | "github.com/abh/geodns/v3/querylog" 13 | "github.com/abh/geodns/v3/zones" 14 | "go.ntppool.org/common/version" 15 | "golang.org/x/sync/errgroup" 16 | 17 | "github.com/miekg/dns" 18 | "github.com/prometheus/client_golang/prometheus" 19 | ) 20 | 21 | type serverMetrics struct { 22 | Queries *prometheus.CounterVec 23 | } 24 | 25 | // Server ... 26 | type Server struct { 27 | PublicDebugQueries bool 28 | DetailedMetrics bool 29 | 30 | queryLogger querylog.QueryLogger 31 | mux *dns.ServeMux 32 | info *monitor.ServerInfo 33 | metrics *serverMetrics 34 | 35 | lock sync.Mutex 36 | dnsServers []*dns.Server 37 | } 38 | 39 | // NewServer ... 40 | func NewServer(config *appconfig.AppConfig, si *monitor.ServerInfo) *Server { 41 | mux := dns.NewServeMux() 42 | 43 | queries := prometheus.NewCounterVec( 44 | prometheus.CounterOpts{ 45 | Name: "dns_queries_total", 46 | Help: "Number of served queries", 47 | }, 48 | []string{"zone", "qtype", "qname", "rcode"}, 49 | ) 50 | prometheus.MustRegister(queries) 51 | 52 | version.RegisterMetric("geodns", prometheus.DefaultRegisterer) 53 | 54 | instanceInfo := prometheus.NewGaugeVec( 55 | prometheus.GaugeOpts{ 56 | Name: "geodns_instance_info", 57 | Help: "GeoDNS instance information", 58 | }, 59 | []string{"ID", "IP", "Group"}, 60 | ) 61 | prometheus.MustRegister(instanceInfo) 62 | group := "" 63 | if len(si.Groups) > 0 { 64 | group = si.Groups[0] 65 | } 66 | instanceInfo.WithLabelValues(si.ID, si.IP, group).Set(1) 67 | 68 | startTime := prometheus.NewGauge( 69 | prometheus.GaugeOpts{ 70 | Name: "geodns_start_time_seconds", 71 | Help: "Unix time process started", 72 | }, 73 | ) 74 | prometheus.MustRegister(startTime) 75 | 76 | nano := si.Started.UnixNano() 77 | startTime.Set(float64(nano) / 1e9) 78 | 79 | metrics := &serverMetrics{ 80 | Queries: queries, 81 | } 82 | 83 | return &Server{ 84 | PublicDebugQueries: appconfig.Config.DNS.PublicDebugQueries, 85 | DetailedMetrics: appconfig.Config.DNS.DetailedMetrics, 86 | 87 | mux: mux, 88 | info: si, 89 | metrics: metrics, 90 | } 91 | } 92 | 93 | // SetQueryLogger configures the query logger. For now it only supports writing to 94 | // a file (and all zones get logged to the same file). 95 | func (srv *Server) SetQueryLogger(logger querylog.QueryLogger) { 96 | srv.queryLogger = logger 97 | } 98 | 99 | // Add adds the Zone to be handled under the specified name 100 | func (srv *Server) Add(name string, zone *zones.Zone) { 101 | srv.mux.HandleFunc(name, srv.setupServerFunc(zone)) 102 | } 103 | 104 | // Remove removes the zone name from being handled by the server 105 | func (srv *Server) Remove(name string) { 106 | srv.mux.HandleRemove(name) 107 | } 108 | 109 | func (srv *Server) setupServerFunc(zone *zones.Zone) func(dns.ResponseWriter, *dns.Msg) { 110 | return func(w dns.ResponseWriter, r *dns.Msg) { 111 | srv.serve(w, r, zone) 112 | } 113 | } 114 | 115 | // ServeDNS calls ServeDNS in the dns package 116 | func (srv *Server) ServeDNS(w dns.ResponseWriter, r *dns.Msg) { 117 | srv.mux.ServeDNS(w, r) 118 | } 119 | 120 | func (srv *Server) addDNSServer(dnsServer *dns.Server) { 121 | srv.lock.Lock() 122 | defer srv.lock.Unlock() 123 | srv.dnsServers = append(srv.dnsServers, dnsServer) 124 | } 125 | 126 | // ListenAndServe starts the DNS server on the specified IP 127 | // (both tcp and udp). It returns an error if 128 | // something goes wrong. 129 | func (srv *Server) ListenAndServe(ctx context.Context, ip string) error { 130 | 131 | prots := []string{"udp", "tcp"} 132 | 133 | g, _ := errgroup.WithContext(ctx) 134 | 135 | for _, prot := range prots { 136 | 137 | p := prot 138 | 139 | g.Go(func() error { 140 | server := &dns.Server{ 141 | Addr: ip, 142 | Net: p, 143 | Handler: srv, 144 | } 145 | 146 | srv.addDNSServer(server) 147 | 148 | log.Printf("Opening on %s %s", ip, p) 149 | if err := server.ListenAndServe(); err != nil { 150 | log.Printf("geodns: failed to setup %s %s: %s", ip, p, err) 151 | return err 152 | } 153 | return nil 154 | }) 155 | } 156 | 157 | // the servers will be shutdown when Shutdown() is called 158 | return g.Wait() 159 | } 160 | 161 | // Shutdown gracefully shuts down the server 162 | func (srv *Server) Shutdown() error { 163 | var errs []error 164 | 165 | for _, dnsServer := range srv.dnsServers { 166 | timeoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 167 | defer cancel() 168 | err := dnsServer.ShutdownContext(timeoutCtx) 169 | if err != nil { 170 | errs = append(errs, err) 171 | } 172 | } 173 | 174 | if srv.queryLogger != nil { 175 | err := srv.queryLogger.Close() 176 | if err != nil { 177 | errs = append(errs, err) 178 | } 179 | } 180 | 181 | err := errors.Join(errs...) 182 | 183 | return err 184 | } 185 | -------------------------------------------------------------------------------- /service/log/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec setuidgid nobody multilog s20000000 n5 /var/log/geodns 3 | -------------------------------------------------------------------------------- /service/run: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | exec 2>&1 3 | sleep 1 # just in case we spin for some reason 4 | 5 | cd /opt/geodns 6 | 7 | INTERFACE="" 8 | if [ -e env/IP ]; then 9 | IP=`head -1 env/IP` 10 | if [ ! -z "$IP" ]; then 11 | INTERFACE="--interface=$IP" 12 | fi 13 | fi 14 | 15 | ID="" 16 | if [ -e env/ID ]; then 17 | ID=`head -1 env/ID` 18 | if [ ! -z "$ID" ]; then 19 | ID="--identifier=$ID" 20 | fi 21 | fi 22 | 23 | CONFIG=dns 24 | if [ -e env/CONFIG ]; then 25 | CONFIG=`head -1 env/CONFIG` 26 | fi 27 | 28 | if [ -e env/ARGS ]; then 29 | XARGS=`cat env/ARGS` 30 | fi 31 | 32 | ulimit -n 64000 33 | 34 | exec softlimit -d500000000 ./geodns $INTERFACE $ID --config="$CONFIG" $XARGS 35 | -------------------------------------------------------------------------------- /targeting/geo/geo.go: -------------------------------------------------------------------------------- 1 | package geo 2 | 3 | import ( 4 | "math" 5 | "net" 6 | 7 | "github.com/golang/geo/s2" 8 | ) 9 | 10 | // Provider is the interface for geoip providers 11 | type Provider interface { 12 | HasCountry() (bool, error) 13 | GetCountry(ip net.IP) (country, continent string, netmask int) 14 | HasASN() (bool, error) 15 | GetASN(net.IP) (asn string, netmask int, err error) 16 | HasLocation() (bool, error) 17 | GetLocation(ip net.IP) (location *Location, err error) 18 | } 19 | 20 | // MaxDistance is the distance returned if Distance() is 21 | // called with a nil location 22 | const MaxDistance = 360 23 | 24 | // Location is the struct the GeoIP provider packages use to 25 | // return location details for an IP. 26 | type Location struct { 27 | Country string 28 | Continent string 29 | RegionGroup string 30 | Region string 31 | Latitude float64 32 | Longitude float64 33 | Netmask int 34 | } 35 | 36 | // MaxDistance() returns the MaxDistance constant 37 | func (l *Location) MaxDistance() float64 { 38 | return MaxDistance 39 | } 40 | 41 | // Distance returns the distance between the two locations 42 | func (l *Location) Distance(to *Location) float64 { 43 | if to == nil { 44 | return MaxDistance 45 | } 46 | ll1 := s2.LatLngFromDegrees(l.Latitude, l.Longitude) 47 | ll2 := s2.LatLngFromDegrees(to.Latitude, to.Longitude) 48 | angle := ll1.Distance(ll2) 49 | return math.Abs(angle.Degrees()) 50 | } 51 | -------------------------------------------------------------------------------- /targeting/geoip2/geoip2.go: -------------------------------------------------------------------------------- 1 | package geoip2 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "log" 7 | "net" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "github.com/abh/geodns/v3/countries" 15 | "github.com/abh/geodns/v3/targeting/geo" 16 | gdb "github.com/oschwald/geoip2-golang" 17 | ) 18 | 19 | // GeoIP2 contains the geoip implementation of the GeoDNS geo 20 | // targeting interface 21 | type GeoIP2 struct { 22 | dir string 23 | country geodb 24 | city geodb 25 | asn geodb 26 | } 27 | 28 | type geodb struct { 29 | active bool 30 | lastModified int64 // Epoch time 31 | fp string // FilePath 32 | db *gdb.Reader // Database reader 33 | l sync.RWMutex // Individual lock for separate DB access and reload -- Future? 34 | } 35 | 36 | // FindDB returns a guess at a directory path for GeoIP data files 37 | func FindDB() string { 38 | dirs := []string{ 39 | "/usr/share/GeoIP/", // Linux default 40 | "/usr/share/local/GeoIP/", // source install? 41 | "/usr/local/share/GeoIP/", // FreeBSD 42 | "/opt/local/share/GeoIP/", // MacPorts 43 | "/opt/homebrew/var/GeoIP", // Homebrew 44 | } 45 | for _, dir := range dirs { 46 | if _, err := os.Stat(dir); err != nil { 47 | if os.IsExist(err) { 48 | log.Println(err) 49 | } 50 | continue 51 | } 52 | return dir 53 | } 54 | return "" 55 | } 56 | 57 | // open will create a filehandle for the provided GeoIP2 database. If opened once before and a newer modification time is present, the function will reopen the file with its new contents 58 | func (g *GeoIP2) open(v *geodb, fns ...string) error { 59 | var fi fs.FileInfo 60 | var err error 61 | if v.fp == "" { 62 | // We're opening this file for the first time 63 | for _, i := range fns { 64 | fp := filepath.Join(g.dir, i) 65 | fi, err = os.Stat(fp) 66 | if err != nil { 67 | continue 68 | } 69 | v.fp = fp 70 | } 71 | } 72 | if v.fp == "" { // Recheck for empty string in case none of the DB files are found 73 | return fmt.Errorf("no files found for db") 74 | } 75 | if fi == nil { // We have not set fileInfo and v.fp is set 76 | fi, err = os.Stat(v.fp) 77 | } 78 | if err != nil { 79 | return err 80 | } 81 | if v.lastModified >= fi.ModTime().UTC().Unix() { // No update to existing file 82 | return nil 83 | } 84 | // Delay the lock to here because we're only 85 | v.l.Lock() 86 | defer v.l.Unlock() 87 | 88 | o, e := gdb.Open(v.fp) 89 | if e != nil { 90 | return e 91 | } 92 | v.db = o 93 | v.active = true 94 | v.lastModified = fi.ModTime().UTC().Unix() 95 | 96 | return nil 97 | } 98 | 99 | // watchFiles spawns a goroutine to check for new files every minute, reloading if the modtime is newer than the original file's modtime 100 | func (g *GeoIP2) watchFiles() { 101 | // Not worried about goroutines leaking because only one geoip2.New call is made in main (outside of testing) 102 | ticker := time.NewTicker(1 * time.Minute) 103 | for { // We forever-loop here because we only run this function in a separate goroutine 104 | select { 105 | case <-ticker.C: 106 | // Iterate through each db, check modtime. If new, reload file 107 | cityErr := g.open(&g.city, "GeoIP2-City.mmdb", "GeoLite2-City.mmdb") 108 | if cityErr != nil { 109 | log.Printf("Failed to update City: %v\n", cityErr) 110 | } 111 | countryErr := g.open(&g.country, "GeoIP2-Country.mmdb", "GeoLite2-Country.mmdb") 112 | if countryErr != nil { 113 | log.Printf("failed to update Country: %v\n", countryErr) 114 | } 115 | asnErr := g.open(&g.asn, "GeoIP2-ASN.mmdb", "GeoLite2-ASN.mmdb") 116 | if asnErr != nil { 117 | log.Printf("failed to update ASN: %v\n", asnErr) 118 | } 119 | } 120 | } 121 | } 122 | 123 | func (g *GeoIP2) anyActive() bool { 124 | return g.country.active || g.city.active || g.asn.active 125 | } 126 | 127 | // New returns a new GeoIP2 provider 128 | func New(dir string) (g *GeoIP2, err error) { 129 | g = &GeoIP2{ 130 | dir: dir, 131 | } 132 | // This routine MUST load the database files at least once. 133 | cityErr := g.open(&g.city, "GeoIP2-City.mmdb", "GeoLite2-City.mmdb") 134 | if cityErr != nil { 135 | log.Printf("failed to load City DB: %v\n", cityErr) 136 | err = cityErr 137 | } 138 | countryErr := g.open(&g.country, "GeoIP2-Country.mmdb", "GeoLite2-Country.mmdb") 139 | if countryErr != nil { 140 | log.Printf("failed to load Country DB: %v\n", countryErr) 141 | err = countryErr 142 | } 143 | asnErr := g.open(&g.asn, "GeoIP2-ASN.mmdb", "GeoLite2-ASN.mmdb") 144 | if asnErr != nil { 145 | log.Printf("failed to load ASN DB: %v\n", asnErr) 146 | err = asnErr 147 | } 148 | if !g.anyActive() { 149 | return nil, err 150 | } 151 | go g.watchFiles() // Launch goroutine to load and monitor 152 | return 153 | } 154 | 155 | // HasASN returns if we can do ASN lookups 156 | func (g *GeoIP2) HasASN() (bool, error) { 157 | return g.asn.active, nil 158 | } 159 | 160 | // GetASN returns the ASN for the IP (as a "as123" string) and the netmask 161 | func (g *GeoIP2) GetASN(ip net.IP) (string, int, error) { 162 | g.asn.l.RLock() 163 | defer g.asn.l.RUnlock() 164 | 165 | if !g.asn.active { 166 | return "", 0, fmt.Errorf("ASN db not active") 167 | } 168 | 169 | c, err := g.asn.db.ASN(ip) 170 | if err != nil { 171 | return "", 0, fmt.Errorf("lookup ASN for '%s': %s", ip.String(), err) 172 | } 173 | asn := c.AutonomousSystemNumber 174 | netmask := 24 175 | if ip.To4() != nil { 176 | netmask = 48 177 | } 178 | return fmt.Sprintf("as%d", asn), netmask, nil 179 | } 180 | 181 | // HasCountry checks if the GeoIP country database is available 182 | func (g *GeoIP2) HasCountry() (bool, error) { 183 | return g.country.active, nil 184 | } 185 | 186 | // GetCountry returns the country, continent and netmask for the given IP 187 | func (g *GeoIP2) GetCountry(ip net.IP) (country, continent string, netmask int) { 188 | // Need a read-lock because return value of Country is a pointer, not copy of the struct/object 189 | g.country.l.RLock() 190 | defer g.country.l.RUnlock() 191 | 192 | if !g.country.active { 193 | return "", "", 0 194 | } 195 | 196 | c, err := g.country.db.Country(ip) 197 | if err != nil { 198 | log.Printf("Could not lookup country for '%s': %s", ip.String(), err) 199 | return "", "", 0 200 | } 201 | 202 | country = c.Country.IsoCode 203 | 204 | if len(country) > 0 { 205 | country = strings.ToLower(country) 206 | continent = countries.CountryContinent[country] 207 | } 208 | 209 | return country, continent, 0 210 | } 211 | 212 | // HasLocation returns if the city database is available to return lat/lon information for an IP 213 | func (g *GeoIP2) HasLocation() (bool, error) { 214 | return g.city.active, nil 215 | } 216 | 217 | // GetLocation returns a geo.Location object for the given IP 218 | func (g *GeoIP2) GetLocation(ip net.IP) (l *geo.Location, err error) { 219 | // Need a read-lock because return value of City is a pointer, not copy of the struct/object 220 | g.city.l.RLock() 221 | defer g.city.l.RUnlock() 222 | 223 | if !g.city.active { 224 | return nil, fmt.Errorf("city db not active") 225 | } 226 | 227 | c, err := g.city.db.City(ip) 228 | if err != nil { 229 | log.Printf("Could not lookup CountryRegion for '%s': %s", ip.String(), err) 230 | return 231 | } 232 | 233 | l = &geo.Location{ 234 | Latitude: float64(c.Location.Latitude), 235 | Longitude: float64(c.Location.Longitude), 236 | Country: strings.ToLower(c.Country.IsoCode), 237 | } 238 | 239 | if len(c.Subdivisions) > 0 { 240 | l.Region = strings.ToLower(c.Subdivisions[0].IsoCode) 241 | } 242 | if len(l.Country) > 0 { 243 | l.Continent = countries.CountryContinent[l.Country] 244 | if len(l.Region) > 0 { 245 | l.Region = l.Country + "-" + l.Region 246 | l.RegionGroup = countries.CountryRegionGroup(l.Country, l.Region) 247 | } 248 | } 249 | 250 | return 251 | } 252 | -------------------------------------------------------------------------------- /targeting/targeting.go: -------------------------------------------------------------------------------- 1 | package targeting 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net" 7 | "strings" 8 | 9 | "github.com/abh/geodns/v3/targeting/geo" 10 | ) 11 | 12 | type TargetOptions int 13 | 14 | const ( 15 | TargetGlobal = 1 << iota 16 | TargetContinent 17 | TargetCountry 18 | TargetRegionGroup 19 | TargetRegion 20 | TargetASN 21 | TargetIP 22 | ) 23 | 24 | var cidr48Mask net.IPMask 25 | 26 | func init() { 27 | cidr48Mask = net.CIDRMask(48, 128) 28 | } 29 | 30 | var g geo.Provider 31 | 32 | // Setup sets the global geo provider 33 | func Setup(gn geo.Provider) error { 34 | g = gn 35 | return nil 36 | } 37 | 38 | // Geo returns the global geo provider 39 | func Geo() geo.Provider { 40 | return g 41 | } 42 | 43 | func (t TargetOptions) getGeoTargets(ip net.IP, hasClosest bool) ([]string, int, *geo.Location) { 44 | 45 | targets := make([]string, 0) 46 | 47 | if t&TargetASN > 0 { 48 | asn, _, err := g.GetASN(ip) 49 | if err != nil { 50 | log.Printf("GetASN error: %s", err) 51 | } 52 | if len(asn) > 0 { 53 | targets = append(targets, asn) 54 | } 55 | } 56 | 57 | var country, continent, region, regionGroup string 58 | var netmask int 59 | var location *geo.Location 60 | 61 | if t&TargetRegion > 0 || t&TargetRegionGroup > 0 || hasClosest { 62 | var err error 63 | location, err = g.GetLocation(ip) 64 | if location == nil || err != nil { 65 | return targets, 0, nil 66 | } 67 | // log.Printf("Location for '%s' (err: %s): %+v", ip, err, location) 68 | country = location.Country 69 | continent = location.Continent 70 | region = location.Region 71 | regionGroup = location.RegionGroup 72 | // continent, regionGroup, region, netmask, 73 | 74 | } else if t&TargetCountry > 0 || t&TargetContinent > 0 { 75 | country, continent, netmask = g.GetCountry(ip) 76 | } 77 | 78 | if t&TargetRegion > 0 && len(region) > 0 { 79 | targets = append(targets, region) 80 | } 81 | if t&TargetRegionGroup > 0 && len(regionGroup) > 0 { 82 | targets = append(targets, regionGroup) 83 | } 84 | 85 | if t&TargetCountry > 0 && len(country) > 0 { 86 | targets = append(targets, country) 87 | } 88 | 89 | if t&TargetContinent > 0 && len(continent) > 0 { 90 | targets = append(targets, continent) 91 | } 92 | 93 | return targets, netmask, location 94 | } 95 | 96 | func (t TargetOptions) GetTargets(ip net.IP, hasClosest bool) ([]string, int, *geo.Location) { 97 | 98 | targets := make([]string, 0) 99 | var location *geo.Location 100 | var netmask int 101 | 102 | if t&TargetIP > 0 { 103 | ipStr := ip.String() 104 | targets = append(targets, "["+ipStr+"]") 105 | ip4 := ip.To4() 106 | if ip4 != nil { 107 | if ip4[3] != 0 { 108 | ip4[3] = 0 109 | targets = append(targets, "["+ip4.String()+"]") 110 | } 111 | } else { 112 | // v6 address, also target the /48 address 113 | ip48 := ip.Mask(cidr48Mask) 114 | targets = append(targets, "["+ip48.String()+"]") 115 | } 116 | } 117 | 118 | if g != nil { 119 | var geotargets []string 120 | geotargets, netmask, location = t.getGeoTargets(ip, hasClosest) 121 | targets = append(targets, geotargets...) 122 | } 123 | 124 | if t&TargetGlobal > 0 { 125 | targets = append(targets, "@") 126 | } 127 | return targets, netmask, location 128 | } 129 | 130 | func (t TargetOptions) String() string { 131 | targets := make([]string, 0) 132 | if t&TargetGlobal > 0 { 133 | targets = append(targets, "@") 134 | } 135 | if t&TargetContinent > 0 { 136 | targets = append(targets, "continent") 137 | } 138 | if t&TargetCountry > 0 { 139 | targets = append(targets, "country") 140 | } 141 | if t&TargetRegionGroup > 0 { 142 | targets = append(targets, "regiongroup") 143 | } 144 | if t&TargetRegion > 0 { 145 | targets = append(targets, "region") 146 | } 147 | if t&TargetASN > 0 { 148 | targets = append(targets, "asn") 149 | } 150 | if t&TargetIP > 0 { 151 | targets = append(targets, "ip") 152 | } 153 | return strings.Join(targets, " ") 154 | } 155 | 156 | func ParseTargets(v string) (tgt TargetOptions, err error) { 157 | targets := strings.Split(v, " ") 158 | for _, t := range targets { 159 | var x TargetOptions 160 | switch t { 161 | case "@": 162 | x = TargetGlobal 163 | case "country": 164 | x = TargetCountry 165 | case "continent": 166 | x = TargetContinent 167 | case "regiongroup": 168 | x = TargetRegionGroup 169 | case "region": 170 | x = TargetRegion 171 | case "asn": 172 | x = TargetASN 173 | case "ip": 174 | x = TargetIP 175 | default: 176 | err = fmt.Errorf("unknown targeting option '%s'", t) 177 | } 178 | tgt = tgt | x 179 | } 180 | return 181 | } 182 | -------------------------------------------------------------------------------- /targeting/targeting_test.go: -------------------------------------------------------------------------------- 1 | package targeting 2 | 3 | import ( 4 | "net" 5 | "reflect" 6 | "testing" 7 | 8 | "github.com/abh/geodns/v3/targeting/geoip2" 9 | ) 10 | 11 | func TestTargetString(t *testing.T) { 12 | tgt := TargetOptions(TargetGlobal + TargetCountry + TargetContinent) 13 | 14 | str := tgt.String() 15 | if str != "@ continent country" { 16 | t.Logf("wrong target string '%s'", str) 17 | t.Fail() 18 | } 19 | } 20 | 21 | func TestTargetParse(t *testing.T) { 22 | tgt, err := ParseTargets("@ foo country") 23 | str := tgt.String() 24 | if str != "@ country" { 25 | t.Logf("Expected '@ country', got '%s'", str) 26 | t.Fail() 27 | } 28 | if err.Error() != "unknown targeting option 'foo'" { 29 | t.Log("Failed erroring on an unknown targeting option") 30 | t.Fail() 31 | } 32 | 33 | tests := [][]string{ 34 | {"@ continent country asn", "@ continent country asn"}, 35 | {"asn country", "country asn"}, 36 | {"continent @ country", "@ continent country"}, 37 | } 38 | 39 | for _, strs := range tests { 40 | tgt, err = ParseTargets(strs[0]) 41 | if err != nil { 42 | t.Fatalf("Parsing '%s': %s", strs[0], err) 43 | } 44 | if tgt.String() != strs[1] { 45 | t.Logf("Unexpected result parsing '%s', got '%s', expected '%s'", 46 | strs[0], tgt.String(), strs[1]) 47 | t.Fail() 48 | } 49 | } 50 | } 51 | 52 | func TestGetTargets(t *testing.T) { 53 | ip := net.ParseIP("93.184.216.34") 54 | 55 | g, err := geoip2.New(geoip2.FindDB()) 56 | if err != nil { 57 | t.Skipf("opening geoip2: %s", err) 58 | } 59 | Setup(g) 60 | 61 | tgt, _ := ParseTargets("@ continent country") 62 | targets, _, _ := tgt.GetTargets(ip, false) 63 | expect := []string{"us", "north-america", "@"} 64 | if !reflect.DeepEqual(targets, expect) { 65 | t.Fatalf("Unexpected parse results of targets, got '%s', expected '%s'", targets, expect) 66 | } 67 | 68 | if ok, err := g.HasLocation(); !ok { 69 | t.Logf("City GeoIP database required for these tests: %s", err) 70 | return 71 | } 72 | 73 | type test struct { 74 | Str string 75 | Targets []string 76 | IP string 77 | } 78 | 79 | tests := []test{ 80 | { 81 | "@ continent country region ", 82 | []string{"us-ma", "us", "north-america", "@"}, 83 | "", 84 | }, 85 | { 86 | "@ continent regiongroup country region ", 87 | []string{"us-ma", "us-east", "us", "north-america", "@"}, 88 | "", 89 | }, 90 | { 91 | "ip", 92 | []string{"[2607:f238:2::ff:4]", "[2607:f238:2::]"}, 93 | "2607:f238:2:0::ff:4", 94 | }, 95 | { 96 | // GeoLite2 doesn't have cities/regions for IPv6 addresses? 97 | "country", 98 | []string{"us"}, 99 | "2606:2800:220:1:248:1893:25c8:1946", 100 | }, 101 | } 102 | 103 | if ok, _ := g.HasASN(); ok { 104 | tests = append(tests, 105 | test{ 106 | "@ continent regiongroup country region asn ip", 107 | []string{"[98.248.0.1]", "[98.248.0.0]", "as7922", "us-ca", "us-west", "us", "north-america", "@"}, 108 | "98.248.0.1", 109 | }, 110 | test{ 111 | "country asn", 112 | []string{"as8674", "se"}, 113 | "2a01:3f0:1:3::1", 114 | }, 115 | ) 116 | } 117 | 118 | for _, test := range tests { 119 | if len(test.IP) > 0 { 120 | ip = net.ParseIP(test.IP) 121 | } 122 | 123 | tgt, _ = ParseTargets(test.Str) 124 | targets, _, _ = tgt.GetTargets(ip, false) 125 | 126 | t.Logf("testing %s, got %q", ip, targets) 127 | 128 | if !reflect.DeepEqual(targets, test.Targets) { 129 | t.Logf("For IP '%s' targets '%s' expected '%s', got '%s'", ip, test.Str, test.Targets, targets) 130 | t.Fail() 131 | } 132 | 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /typeutil/typeutil.go: -------------------------------------------------------------------------------- 1 | package typeutil 2 | 3 | import ( 4 | "log" 5 | "strconv" 6 | ) 7 | 8 | func ToBool(v interface{}) (rv bool) { 9 | switch v.(type) { 10 | case bool: 11 | rv = v.(bool) 12 | case string: 13 | str := v.(string) 14 | switch str { 15 | case "true": 16 | rv = true 17 | case "1": 18 | rv = true 19 | } 20 | case float64: 21 | if v.(float64) > 0 { 22 | rv = true 23 | } 24 | default: 25 | log.Println("Can't convert", v, "to bool") 26 | panic("Can't convert value") 27 | } 28 | return rv 29 | 30 | } 31 | 32 | func ToString(v interface{}) (rv string) { 33 | switch v.(type) { 34 | case string: 35 | rv = v.(string) 36 | case float64: 37 | rv = strconv.FormatFloat(v.(float64), 'f', -1, 64) 38 | default: 39 | log.Println("Can't convert", v, "to string") 40 | panic("Can't convert value") 41 | } 42 | return rv 43 | } 44 | 45 | func ToInt(v interface{}) (rv int) { 46 | switch v.(type) { 47 | case string: 48 | i, err := strconv.Atoi(v.(string)) 49 | if err != nil { 50 | panic("Error converting weight to integer") 51 | } 52 | rv = i 53 | case float64: 54 | rv = int(v.(float64)) 55 | default: 56 | log.Println("Can't convert", v, "to integer") 57 | panic("Can't convert value") 58 | } 59 | return rv 60 | } 61 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "strings" 7 | ) 8 | 9 | func getInterfaces() []string { 10 | 11 | var inter []string 12 | uniq := make(map[string]bool) 13 | 14 | for _, host := range strings.Split(*flaginter, ",") { 15 | ip, port, err := net.SplitHostPort(host) 16 | if err != nil { 17 | switch { 18 | case strings.Contains(err.Error(), "missing port in address"): 19 | // 127.0.0.1 20 | ip = host 21 | case strings.Contains(err.Error(), "too many colons in address") && 22 | // [a:b::c] 23 | strings.LastIndex(host, "]") == len(host)-1: 24 | ip = host[1 : len(host)-1] 25 | port = "" 26 | case strings.Contains(err.Error(), "too many colons in address"): 27 | // a:b::c 28 | ip = host 29 | port = "" 30 | default: 31 | log.Fatalf("Could not parse %s: %s\n", host, err) 32 | } 33 | } 34 | if len(port) == 0 { 35 | port = *flagport 36 | } 37 | host = net.JoinHostPort(ip, port) 38 | if uniq[host] { 39 | continue 40 | } 41 | uniq[host] = true 42 | 43 | // default to the first interfaces 44 | // todo: skip 127.0.0.1 and ::1 ? 45 | 46 | if ip != "127.0.0.1" { 47 | if len(serverInfo.ID) == 0 { 48 | serverInfo.ID = ip 49 | } 50 | if len(serverInfo.IP) == 0 { 51 | serverInfo.IP = ip 52 | } 53 | } 54 | 55 | inter = append(inter, host) 56 | 57 | } 58 | 59 | return inter 60 | } 61 | -------------------------------------------------------------------------------- /zones/muxmanager.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/hex" 7 | "fmt" 8 | "log" 9 | "os" 10 | "path" 11 | "strings" 12 | "time" 13 | 14 | "github.com/miekg/dns" 15 | ) 16 | 17 | type RegistrationAPI interface { 18 | Add(string, *Zone) 19 | Remove(string) 20 | } 21 | 22 | type MuxManager struct { 23 | reg RegistrationAPI 24 | zonelist ZoneList 25 | path string 26 | lastRead map[string]*zoneReadRecord 27 | } 28 | 29 | type NilReg struct{} 30 | 31 | func (r *NilReg) Add(string, *Zone) {} 32 | func (r *NilReg) Remove(string) {} 33 | 34 | // track when each zone was read last 35 | type zoneReadRecord struct { 36 | time time.Time 37 | hash string 38 | } 39 | 40 | func NewMuxManager(path string, reg RegistrationAPI) (*MuxManager, error) { 41 | mm := &MuxManager{ 42 | reg: reg, 43 | path: path, 44 | zonelist: make(ZoneList), 45 | lastRead: map[string]*zoneReadRecord{}, 46 | } 47 | 48 | mm.setupRootZone() 49 | mm.setupPgeodnsZone() 50 | 51 | err := mm.reload() 52 | 53 | return mm, err 54 | } 55 | 56 | func (mm *MuxManager) Run(ctx context.Context) { 57 | for { 58 | err := mm.reload() 59 | if err != nil { 60 | log.Printf("error reading zones: %s", err) 61 | } 62 | select { 63 | case <-time.After(2 * time.Second): 64 | case <-ctx.Done(): 65 | return 66 | } 67 | } 68 | } 69 | 70 | // Zones returns the list of currently active zones in the mux manager. 71 | func (mm *MuxManager) Zones() ZoneList { 72 | return mm.zonelist 73 | } 74 | 75 | func (mm *MuxManager) reload() error { 76 | dir, err := os.ReadDir(mm.path) 77 | if err != nil { 78 | return fmt.Errorf("could not read '%s': %s", mm.path, err) 79 | } 80 | 81 | seenZones := map[string]bool{} 82 | 83 | var parseErr error 84 | 85 | for _, file := range dir { 86 | fileName := file.Name() 87 | if !strings.HasSuffix(strings.ToLower(fileName), ".json") || 88 | strings.HasPrefix(path.Base(fileName), ".") || 89 | file.IsDir() { 90 | continue 91 | } 92 | 93 | fileInfo, err := file.Info() 94 | if err != nil { 95 | return err 96 | } 97 | modTime := fileInfo.ModTime() 98 | 99 | zoneName := fileName[0:strings.LastIndex(fileName, ".")] 100 | 101 | seenZones[zoneName] = true 102 | 103 | if _, ok := mm.lastRead[zoneName]; !ok || modTime.After(mm.lastRead[zoneName].time) { 104 | if ok { 105 | log.Printf("Reloading %s\n", fileName) 106 | mm.lastRead[zoneName].time = modTime 107 | } else { 108 | log.Printf("Reading new file %s\n", fileName) 109 | mm.lastRead[zoneName] = &zoneReadRecord{time: modTime} 110 | } 111 | 112 | filename := path.Join(mm.path, fileName) 113 | 114 | // Check the sha256 of the file has not changed. It's worth an explanation of 115 | // why there isn't a TOCTOU race here. Conceivably after checking whether the 116 | // SHA has changed, the contents then change again before we actually load 117 | // the JSON. This can occur in two situations: 118 | // 119 | // 1. The SHA has not changed when we read the file for the SHA, but then 120 | // changes before we process the JSON 121 | // 122 | // 2. The SHA has changed when we read the file for the SHA, but then changes 123 | // again before we process the JSON 124 | // 125 | // In circumstance (1) we won't reread the file the first time, but the subsequent 126 | // change should alter the mtime again, causing us to reread it. This reflects 127 | // the fact there were actually two changes. 128 | // 129 | // In circumstance (2) we have already reread the file once, and then when the 130 | // contents are changed the mtime changes again 131 | // 132 | // Provided files are replaced atomically, this should be OK. If files are not 133 | // replaced atomically we have other problems (e.g. partial reads). 134 | 135 | sha256 := sha256File(filename) 136 | if mm.lastRead[zoneName].hash == sha256 { 137 | log.Printf("Skipping new file %s as hash is unchanged\n", filename) 138 | continue 139 | } 140 | 141 | zone := NewZone(zoneName) 142 | err := zone.ReadZoneFile(filename) 143 | if zone == nil || err != nil { 144 | parseErr = fmt.Errorf("error reading zone '%s': %s", zoneName, err) 145 | log.Println(parseErr.Error()) 146 | continue 147 | } 148 | 149 | (mm.lastRead[zoneName]).hash = sha256 150 | 151 | mm.addHandler(zoneName, zone) 152 | } 153 | } 154 | 155 | for zoneName, zone := range mm.zonelist { 156 | if zoneName == "pgeodns" { 157 | continue 158 | } 159 | if ok := seenZones[zoneName]; ok { 160 | continue 161 | } 162 | log.Println("Removing zone", zone.Origin) 163 | zone.Close() 164 | mm.removeHandler(zoneName) 165 | } 166 | 167 | return parseErr 168 | } 169 | 170 | func (mm *MuxManager) addHandler(name string, zone *Zone) { 171 | oldZone := mm.zonelist[name] 172 | zone.SetupMetrics(oldZone) 173 | zone.setupHealthTests() 174 | mm.zonelist[name] = zone 175 | mm.reg.Add(name, zone) 176 | } 177 | 178 | func (mm *MuxManager) removeHandler(name string) { 179 | delete(mm.lastRead, name) 180 | delete(mm.zonelist, name) 181 | mm.reg.Remove(name) 182 | } 183 | 184 | func (mm *MuxManager) setupPgeodnsZone() { 185 | zoneName := "pgeodns" 186 | zone := NewZone(zoneName) 187 | label := new(Label) 188 | label.Records = make(map[uint16]Records) 189 | label.Weight = make(map[uint16]int) 190 | zone.Labels[""] = label 191 | zone.AddSOA() 192 | mm.addHandler(zoneName, zone) 193 | } 194 | 195 | func (mm *MuxManager) setupRootZone() { 196 | dns.HandleFunc(".", func(w dns.ResponseWriter, r *dns.Msg) { 197 | m := new(dns.Msg) 198 | m.SetRcode(r, dns.RcodeRefused) 199 | w.WriteMsg(m) 200 | }) 201 | } 202 | 203 | func sha256File(fn string) string { 204 | data, err := os.ReadFile(fn) 205 | if err != nil { 206 | return "" 207 | } 208 | hasher := sha256.New() 209 | hasher.Write(data) 210 | return hex.EncodeToString(hasher.Sum(nil)) 211 | } 212 | -------------------------------------------------------------------------------- /zones/picker.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "math/rand" 5 | 6 | "github.com/abh/geodns/v3/health" 7 | "github.com/abh/geodns/v3/targeting/geo" 8 | 9 | "github.com/miekg/dns" 10 | ) 11 | 12 | // AlwaysWeighted types always honors 'MaxHosts', even 13 | // without an explicit weight set. (This list is slightly arbitrary). 14 | var AlwaysWeighted = []uint16{ 15 | dns.TypeA, dns.TypeAAAA, dns.TypeCNAME, 16 | } 17 | 18 | var alwaysWeighted map[uint16]struct{} 19 | 20 | func init() { 21 | alwaysWeighted = map[uint16]struct{}{} 22 | for _, t := range AlwaysWeighted { 23 | alwaysWeighted[t] = struct{}{} 24 | } 25 | } 26 | 27 | func (zone *Zone) filterHealth(servers Records) (Records, int) { 28 | // Remove any unhealthy servers 29 | tmpServers := servers[:0] 30 | 31 | sum := 0 32 | for i, s := range servers { 33 | if len(servers[i].Test) == 0 || health.GetStatus(servers[i].Test) == health.StatusHealthy { 34 | tmpServers = append(tmpServers, s) 35 | sum += s.Weight 36 | } 37 | } 38 | return tmpServers, sum 39 | } 40 | 41 | // Picker picks the best results from a label matching the qtype, 42 | // up to 'max' results. If location is specified Picker will get 43 | // return the "closests" results, otherwise they are returned weighted 44 | // randomized. 45 | func (zone *Zone) Picker(label *Label, qtype uint16, max int, location *geo.Location) Records { 46 | 47 | if qtype == dns.TypeANY { 48 | var result Records 49 | for rtype := range label.Records { 50 | 51 | rtypeRecords := zone.Picker(label, rtype, max, location) 52 | 53 | tmpResult := make(Records, len(result)+len(rtypeRecords)) 54 | 55 | copy(tmpResult, result) 56 | copy(tmpResult[len(result):], rtypeRecords) 57 | result = tmpResult 58 | } 59 | 60 | return result 61 | } 62 | 63 | labelRR := label.Records[qtype] 64 | if labelRR == nil { 65 | // we don't have anything of the correct type 66 | return nil 67 | 68 | } 69 | 70 | sum := label.Weight[qtype] 71 | 72 | servers := make(Records, len(labelRR)) 73 | copy(servers, labelRR) 74 | 75 | if label.Test != nil { 76 | servers, sum = zone.filterHealth(servers) 77 | // sum re-check to mirror the label.Weight[] check below 78 | if sum == 0 { 79 | // todo: this is wrong for cname since it misses 80 | // the 'max_hosts' setting 81 | return servers 82 | } 83 | } 84 | 85 | // not "balanced", just return all -- It's been working 86 | // this way since the first prototype, it might not make 87 | // sense anymore. This probably makes NS records and such 88 | // work as expected. 89 | // A, AAAA and CNAME records ("AlwaysWeighted") are always given 90 | // a weight so MaxHosts works for those even if weight isn't set. 91 | if label.Weight[qtype] == 0 { 92 | return servers 93 | } 94 | 95 | if qtype == dns.TypeCNAME || qtype == dns.TypeMF { 96 | max = 1 97 | } 98 | 99 | rrCount := len(servers) 100 | if max > rrCount { 101 | max = rrCount 102 | } 103 | result := make(Records, max) 104 | 105 | // Find the distance to each server, and find the servers that are 106 | // closer to the querier than the max'th furthest server, or within 107 | // 5% thereof. What this means in practice is that if we have a nearby 108 | // cluster of servers that are close, they all get included, so load 109 | // balancing works 110 | if location != nil && (qtype == dns.TypeA || qtype == dns.TypeAAAA) && max < rrCount { 111 | // First we record the distance to each server 112 | distances := make([]float64, rrCount) 113 | for i, s := range servers { 114 | distance := location.Distance(s.Loc) 115 | distances[i] = distance 116 | } 117 | 118 | // though this looks like O(n^2), typically max is small (e.g. 2) 119 | // servers often have the same geographic location 120 | // and rrCount is pretty small too, so the gain of an 121 | // O(n log n) sort is small. 122 | chosen := 0 123 | choose := make([]bool, rrCount) 124 | 125 | for chosen < max { 126 | // Determine the minimum distance of servers not yet chosen 127 | minDist := location.MaxDistance() 128 | for i := range servers { 129 | if !choose[i] && distances[i] <= minDist { 130 | minDist = distances[i] 131 | } 132 | } 133 | // The threshold for inclusion on the this pass is 5% more 134 | // than the minimum distance 135 | minDist = minDist * 1.05 136 | // Choose all the servers within the distance 137 | for i := range servers { 138 | if !choose[i] && distances[i] <= minDist { 139 | choose[i] = true 140 | chosen++ 141 | } 142 | } 143 | } 144 | 145 | // Now choose only the chosen servers, using filtering without allocation 146 | // slice trick. Meanwhile recalculate the total weight 147 | tmpServers := servers[:0] 148 | sum = 0 149 | for i, s := range servers { 150 | if choose[i] { 151 | tmpServers = append(tmpServers, s) 152 | sum += s.Weight 153 | } 154 | } 155 | servers = tmpServers 156 | } 157 | 158 | for si := 0; si < max; si++ { 159 | n := rand.Intn(sum + 1) 160 | s := 0 161 | 162 | for i := range servers { 163 | s += int(servers[i].Weight) 164 | if s >= n { 165 | sum -= servers[i].Weight 166 | result[si] = servers[i] 167 | 168 | // remove the server from the list 169 | servers = append(servers[:i], servers[i+1:]...) 170 | break 171 | } 172 | } 173 | } 174 | 175 | return result 176 | } 177 | -------------------------------------------------------------------------------- /zones/reader_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "testing" 8 | 9 | "github.com/abh/geodns/v3/targeting" 10 | "github.com/abh/geodns/v3/targeting/geoip2" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func loadZones(t *testing.T) *MuxManager { 15 | 16 | if targeting.Geo() == nil { 17 | t.Logf("Setting up geo provider") 18 | dbDir := geoip2.FindDB() 19 | if len(dbDir) == 0 { 20 | t.Skip("Could not find geoip directory") 21 | } 22 | geoprovider, err := geoip2.New(dbDir) 23 | if err == nil { 24 | targeting.Setup(geoprovider) 25 | } else { 26 | t.Fatalf("error setting up geo provider: %s", err) 27 | } 28 | } 29 | 30 | muxm, err := NewMuxManager("../dns", &NilReg{}) 31 | if err != nil { 32 | t.Logf("loading zones: %s", err) 33 | t.Fail() 34 | } 35 | 36 | // Just check that example.com and test.example.org loaded, too. 37 | for _, zonename := range []string{"example.com", "test.example.com", "hc.example.com"} { 38 | 39 | if z, ok := muxm.zonelist[zonename]; ok { 40 | if z.Origin != zonename { 41 | t.Logf("zone '%s' doesn't have that Origin '%s'", zonename, z.Origin) 42 | t.Fail() 43 | } 44 | if z.Options.Serial == 0 { 45 | t.Logf("Zone '%s' Serial number is 0, should be set by file timestamp", zonename) 46 | t.Fail() 47 | } 48 | } else { 49 | t.Fatalf("Didn't load '%s'", zonename) 50 | } 51 | } 52 | return muxm 53 | } 54 | 55 | func TestReadConfigs(t *testing.T) { 56 | 57 | muxm := loadZones(t) 58 | 59 | // The real tests are in test.example.com so we have a place 60 | // to make nutty configuration entries 61 | tz := muxm.zonelist["test.example.com"] 62 | 63 | // test.example.com was loaded 64 | 65 | if tz.Options.MaxHosts != 2 { 66 | t.Logf("MaxHosts=%d, expected 2", tz.Options.MaxHosts) 67 | t.Fail() 68 | } 69 | 70 | if tz.Options.Contact != "support.bitnames.com" { 71 | t.Logf("Contact='%s', expected support.bitnames.com", tz.Options.Contact) 72 | t.Fail() 73 | } 74 | 75 | assert.Equal(t, tz.Options.Targeting.String(), "@ continent country regiongroup region asn ip", "Targeting.String()") 76 | assert.Equal(t, tz.Labels["weight"].MaxHosts, 1, "weight label has max_hosts=1") 77 | 78 | // /* test different cname targets */ 79 | // c.Check(tz.Labels["www"]. 80 | // FirstRR(dns.TypeCNAME).(*dns.CNAME). 81 | // Target, Equals, "geo.bitnames.com.") 82 | 83 | // c.Check(tz.Labels["www-cname"]. 84 | // FirstRR(dns.TypeCNAME).(*dns.CNAME). 85 | // Target, Equals, "bar.test.example.com.") 86 | 87 | // c.Check(tz.Labels["www-alias"]. 88 | // FirstRR(dns.TypeMF).(*dns.MF). 89 | // Mf, Equals, "www") 90 | 91 | // // The header name should just have a dot-prefix 92 | // c.Check(tz.Labels[""].Records[dns.TypeNS][0].RR.(*dns.NS).Hdr.Name, Equals, "test.example.com.") 93 | 94 | } 95 | 96 | func TestRemoveConfig(t *testing.T) { 97 | dir, err := os.MkdirTemp("", "geodns-test.") 98 | if err != nil { 99 | t.Fail() 100 | } 101 | defer os.RemoveAll(dir) 102 | 103 | muxm, err := NewMuxManager(dir, &NilReg{}) 104 | if err != nil { 105 | t.Logf("loading zones: %s", err) 106 | t.Fail() 107 | } 108 | 109 | muxm.reload() 110 | 111 | _, err = CopyFile("../dns/test.example.org.json", dir+"/test.example.org.json") 112 | if err != nil { 113 | t.Log(err) 114 | t.Fail() 115 | } 116 | _, err = CopyFile("../dns/test.example.org.json", dir+"/test2.example.org.json") 117 | if err != nil { 118 | t.Log(err) 119 | t.Fail() 120 | } 121 | 122 | err = os.WriteFile(dir+"/invalid.example.org.json", []byte("not-json"), 0644) 123 | if err != nil { 124 | t.Log(err) 125 | t.Fail() 126 | } 127 | 128 | muxm.reload() 129 | if muxm.zonelist["test.example.org"].Origin != "test.example.org" { 130 | t.Errorf("test.example.org has unexpected Origin: '%s'", muxm.zonelist["test.example.org"].Origin) 131 | } 132 | if muxm.zonelist["test2.example.org"].Origin != "test2.example.org" { 133 | t.Errorf("test2.example.org has unexpected Origin: '%s'", muxm.zonelist["test2.example.org"].Origin) 134 | } 135 | 136 | os.Remove(dir + "/test2.example.org.json") 137 | os.Remove(dir + "/invalid.example.org.json") 138 | 139 | muxm.reload() 140 | 141 | if muxm.zonelist["test.example.org"].Origin != "test.example.org" { 142 | t.Errorf("test.example.org has unexpected Origin: '%s'", muxm.zonelist["test.example.org"].Origin) 143 | } 144 | _, ok := muxm.zonelist["test2.example.org"] 145 | if ok != false { 146 | t.Log("test2.example.org is still loaded") 147 | t.Fail() 148 | } 149 | } 150 | 151 | func CopyFile(src, dst string) (int64, error) { 152 | sf, err := os.Open(src) 153 | if err != nil { 154 | return 0, fmt.Errorf("Could not copy '%s' to '%s': %s", src, dst, err) 155 | } 156 | defer sf.Close() 157 | df, err := os.Create(dst) 158 | if err != nil { 159 | return 0, fmt.Errorf("Could not copy '%s' to '%s': %s", src, dst, err) 160 | } 161 | defer df.Close() 162 | return io.Copy(df, sf) 163 | } 164 | -------------------------------------------------------------------------------- /zones/zone_health_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "math/rand" 5 | "testing" 6 | 7 | "github.com/abh/geodns/v3/health" 8 | "github.com/miekg/dns" 9 | ) 10 | 11 | type HealthStatus struct { 12 | t *testing.T 13 | status health.StatusType 14 | odds float64 15 | } 16 | 17 | func (hs *HealthStatus) GetStatus(name string) health.StatusType { 18 | hs.t.Logf("GetStatus(%s)", name) 19 | // hs.t.Fatalf("in get status") 20 | 21 | if hs.odds >= 0 { 22 | switch rand.Float64() < hs.odds { 23 | case true: 24 | return health.StatusHealthy 25 | case false: 26 | return health.StatusUnhealthy 27 | } 28 | } 29 | 30 | return hs.status 31 | } 32 | 33 | func (hs *HealthStatus) Close() error { 34 | return nil 35 | } 36 | 37 | func (hs *HealthStatus) Reload() error { 38 | return nil 39 | } 40 | 41 | func TestHealth(t *testing.T) { 42 | muxm := loadZones(t) 43 | t.Log("setting up health status") 44 | 45 | hs := &HealthStatus{t: t, odds: -1, status: health.StatusUnhealthy} 46 | 47 | tz := muxm.zonelist["hc.example.com"] 48 | tz.HealthStatus = hs 49 | // t.Logf("hs: '%+v'", tz.HealthStatus) 50 | // t.Logf("hc zone: '%+v'", tz) 51 | 52 | matches := tz.FindLabels("tucs", []string{"@"}, []uint16{dns.TypeA}) 53 | // t.Logf("qt: %d, label: '%+v'", qt, label) 54 | records := tz.Picker(matches[0].Label, matches[0].Type, 2, nil) 55 | if len(records) > 0 { 56 | t.Errorf("got %d records when expecting 0", len(records)) 57 | } 58 | 59 | // t.Logf("label.Test: '%+v'", label.Test) 60 | 61 | if len(records) == 0 { 62 | t.Log("didn't get any records") 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /zones/zone_stats.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | ) 7 | 8 | type zoneLabelStats struct { 9 | pos int 10 | rotated bool 11 | log []string 12 | mu sync.Mutex 13 | } 14 | 15 | type labelStats []labelStat 16 | 17 | func (s labelStats) Len() int { return len(s) } 18 | func (s labelStats) Swap(i, j int) { s[i], s[j] = s[j], s[i] } 19 | 20 | type labelStatsByCount struct{ labelStats } 21 | 22 | func (s labelStatsByCount) Less(i, j int) bool { return s.labelStats[i].Count > s.labelStats[j].Count } 23 | 24 | type labelStat struct { 25 | Label string 26 | Count int 27 | } 28 | 29 | func NewZoneLabelStats(size int) *zoneLabelStats { 30 | zs := &zoneLabelStats{ 31 | log: make([]string, size), 32 | } 33 | return zs 34 | } 35 | 36 | func (zs *zoneLabelStats) Close() { 37 | zs.log = []string{} 38 | } 39 | 40 | func (zs *zoneLabelStats) Reset() { 41 | zs.mu.Lock() 42 | defer zs.mu.Unlock() 43 | zs.pos = 0 44 | zs.log = make([]string, len(zs.log)) 45 | zs.rotated = false 46 | } 47 | 48 | func (zs *zoneLabelStats) Add(l string) { 49 | zs.add(l) 50 | } 51 | 52 | func (zs *zoneLabelStats) add(l string) { 53 | zs.mu.Lock() 54 | defer zs.mu.Unlock() 55 | 56 | zs.log[zs.pos] = l 57 | zs.pos++ 58 | if zs.pos+1 > len(zs.log) { 59 | zs.rotated = true 60 | zs.pos = 0 61 | } 62 | } 63 | 64 | func (zs *zoneLabelStats) TopCounts(n int) labelStats { 65 | cm := zs.Counts() 66 | top := make(labelStats, len(cm)) 67 | i := 0 68 | for l, c := range cm { 69 | top[i] = labelStat{l, c} 70 | i++ 71 | } 72 | sort.Sort(labelStatsByCount{top}) 73 | if len(top) > n { 74 | others := 0 75 | for _, t := range top[n:] { 76 | others += t.Count 77 | } 78 | top = append(top[:n], labelStat{"Others", others}) 79 | } 80 | return top 81 | } 82 | 83 | func (zs *zoneLabelStats) Counts() map[string]int { 84 | zs.mu.Lock() 85 | defer zs.mu.Unlock() 86 | 87 | counts := make(map[string]int) 88 | for i, l := range zs.log { 89 | if !zs.rotated && i >= zs.pos { 90 | break 91 | } 92 | counts[l]++ 93 | } 94 | return counts 95 | } 96 | -------------------------------------------------------------------------------- /zones/zone_stats_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "github.com/stretchr/testify/assert" 5 | 6 | "testing" 7 | ) 8 | 9 | func TestZoneStats(t *testing.T) { 10 | zs := NewZoneLabelStats(4) 11 | if zs == nil { 12 | t.Fatalf("NewZoneLabelStats returned nil") 13 | } 14 | t.Log("adding 4 entries") 15 | zs.Add("abc") 16 | zs.Add("foo") 17 | zs.Add("def") 18 | zs.Add("abc") 19 | t.Log("getting counts") 20 | co := zs.Counts() 21 | assert.Equal(t, co["abc"], 2) 22 | assert.Equal(t, co["foo"], 1) 23 | 24 | zs.Add("foo") 25 | 26 | co = zs.Counts() 27 | assert.Equal(t, co["abc"], 1) // the first abc rolled off 28 | assert.Equal(t, co["foo"], 2) 29 | 30 | zs.Close() 31 | 32 | zs = NewZoneLabelStats(10) 33 | zs.Add("a") 34 | zs.Add("a") 35 | zs.Add("a") 36 | zs.Add("b") 37 | zs.Add("c") 38 | zs.Add("c") 39 | zs.Add("d") 40 | zs.Add("d") 41 | zs.Add("e") 42 | zs.Add("f") 43 | 44 | top := zs.TopCounts(2) 45 | assert.Len(t, top, 3, "TopCounts(2) returned 3 elements") 46 | assert.Equal(t, top[0].Label, "a") 47 | 48 | zs.Reset() 49 | assert.Len(t, zs.Counts(), 0, "Counts() is empty after reset") 50 | 51 | zs.Close() 52 | 53 | } 54 | -------------------------------------------------------------------------------- /zones/zone_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "regexp" 5 | "testing" 6 | 7 | "github.com/miekg/dns" 8 | ) 9 | 10 | func TestExampleComZone(t *testing.T) { 11 | t.Log("example com") 12 | mm, err := NewMuxManager("../dns", &NilReg{}) 13 | if err != nil { 14 | t.Fatalf("Loading test zones: %s", err) 15 | } 16 | 17 | ex, ok := mm.zonelist["test.example.com"] 18 | if !ok || ex == nil || ex.Labels == nil { 19 | t.Fatalf("Did not load 'test.example.com' test zone") 20 | } 21 | 22 | if mh := ex.Labels["weight"].MaxHosts; mh != 1 { 23 | t.Logf("Invalid MaxHosts, expected one got '%d'", mh) 24 | t.Fail() 25 | } 26 | 27 | // Make sure that the empty "no.bar" zone gets skipped and "bar" is used 28 | m := ex.findFirstLabel("bar", []string{"no", "europe", "@"}, []uint16{dns.TypeA}) 29 | if l := len(m.Label.Records[dns.TypeA]); l != 1 { 30 | t.Logf("Unexpected number of A records: '%d'", l) 31 | t.Fail() 32 | } 33 | if m.Type != dns.TypeA { 34 | t.Fatalf("Expected qtype = A record (type %d), got type %d", dns.TypeA, m.Type) 35 | } 36 | if str := m.Label.Records[m.Type][0].RR.(*dns.A).A.String(); str != "192.168.1.2" { 37 | t.Errorf("Got A '%s', expected '%s'", str, "192.168.1.2") 38 | } 39 | 40 | m = ex.findFirstLabel("", []string{"@"}, []uint16{dns.TypeMX}) 41 | 42 | Mx := m.Label.Records[dns.TypeMX] 43 | if len(Mx) != 2 { 44 | t.Errorf("Expected 2 MX records but got %d", len(Mx)) 45 | } 46 | if Mx[0].RR.(*dns.MX).Mx != "mx.example.net." { 47 | t.Errorf("First MX should have been mx.example.net, but was %s", Mx[0].RR.(*dns.MX).Mx) 48 | } 49 | 50 | m = ex.findFirstLabel("", []string{"dk", "europe", "@"}, []uint16{dns.TypeMX}) 51 | Mx = m.Label.Records[dns.TypeMX] 52 | if len(Mx) != 1 { 53 | t.Errorf("Got %d MX record for dk,europe,@ - expected %d", len(Mx), 1) 54 | } 55 | if Mx[0].RR.(*dns.MX).Mx != "mx-eu.example.net." { 56 | t.Errorf("First MX should have been mx-eu.example.net, but was %s", Mx[0].RR.(*dns.MX).Mx) 57 | } 58 | 59 | // // look for multiple record types 60 | m = ex.findFirstLabel("www", []string{"@"}, []uint16{dns.TypeCNAME, dns.TypeA}) 61 | if m.Type != dns.TypeCNAME { 62 | t.Errorf("www should have been a CNAME, but was a %s", dns.TypeToString[m.Type]) 63 | } 64 | 65 | m = ex.findFirstLabel("", []string{"@"}, []uint16{dns.TypeNS}) 66 | Ns := m.Label.Records[dns.TypeNS] 67 | if len(Ns) != 2 { 68 | t.Errorf("root should have returned 2 NS records but got %d", len(Ns)) 69 | } 70 | 71 | // Test that we get the expected NS records (in any order because 72 | // of the configuration format used for this zone) 73 | re := regexp.MustCompile(`^ns[12]\.example\.net.$`) 74 | for i := 0; i < 2; i++ { 75 | if matched := re.MatchString(Ns[i].RR.(*dns.NS).Ns); !matched { 76 | if err != nil { 77 | t.Fatal(err) 78 | } 79 | t.Errorf("Unexpected NS record data '%s'", Ns[i].RR.(*dns.NS).Ns) 80 | } 81 | } 82 | 83 | m = ex.findFirstLabel("", []string{"@"}, []uint16{dns.TypeSPF}) 84 | Spf := m.Label.Records[dns.TypeSPF] 85 | if txt := Spf[0].RR.(*dns.SPF).Txt[0]; txt != "v=spf1 ~all" { 86 | t.Errorf("Wrong SPF data '%s'", txt) 87 | } 88 | 89 | m = ex.findFirstLabel("foo", []string{"@"}, []uint16{dns.TypeTXT}) 90 | Txt := m.Label.Records[dns.TypeTXT] 91 | if txt := Txt[0].RR.(*dns.TXT).Txt[0]; txt != "this is foo" { 92 | t.Errorf("Wrong TXT data '%s'", txt) 93 | } 94 | 95 | m = ex.findFirstLabel("weight", []string{"@"}, []uint16{dns.TypeTXT}) 96 | Txt = m.Label.Records[dns.TypeTXT] 97 | 98 | txts := []string{"w10000", "w1"} 99 | for i, r := range Txt { 100 | if txt := r.RR.(*dns.TXT).Txt[0]; txt != txts[i] { 101 | t.Errorf("txt record %d was '%s', expected '%s'", i, txt, txts[i]) 102 | } 103 | } 104 | 105 | // verify empty labels are created 106 | m = ex.findFirstLabel("a.b.c", []string{"@"}, []uint16{dns.TypeA}) 107 | if a := m.Label.Records[dns.TypeA][0].RR.(*dns.A); a.A.String() != "192.168.1.7" { 108 | t.Errorf("unexpected IP for a.b.c '%s'", a) 109 | } 110 | 111 | emptyLabels := []string{"b.c", "c"} 112 | for _, el := range emptyLabels { 113 | m = ex.findFirstLabel(el, []string{"@"}, []uint16{dns.TypeA}) 114 | if len(m.Label.Records[dns.TypeA]) > 0 { 115 | t.Errorf("Unexpected A record for '%s'", el) 116 | } 117 | if m.Label.Label != el { 118 | t.Errorf("'%s' label is '%s'", el, m.Label.Label) 119 | } 120 | } 121 | 122 | //verify label is created 123 | m = ex.findFirstLabel("three.two.one", []string{"@"}, []uint16{dns.TypeA}) 124 | if l := len(m.Label.Records[dns.TypeA]); l != 1 { 125 | t.Errorf("Unexpected A record count for 'three.two.one' %d, expected 1", l) 126 | } 127 | if a := m.Label.Records[dns.TypeA][0].RR.(*dns.A); a.A.String() != "192.168.1.5" { 128 | t.Errorf("unexpected IP for three.two.one '%s'", a) 129 | } 130 | 131 | el := "two.one" 132 | m = ex.findFirstLabel(el, []string{"@"}, []uint16{dns.TypeA}) 133 | if len(m.Label.Records[dns.TypeA]) > 0 { 134 | t.Errorf("Unexpected A record for '%s'", el) 135 | } 136 | if m.Label.Label != el { 137 | t.Errorf("'%s' label is '%s'", el, m.Label.Label) 138 | } 139 | 140 | //verify label isn't overwritten 141 | m = ex.findFirstLabel("one", []string{"@"}, []uint16{dns.TypeA}) 142 | if l := len(m.Label.Records[dns.TypeA]); l != 1 { 143 | t.Errorf("Unexpected A record count for 'one' %d, expected 1", l) 144 | } 145 | if a := m.Label.Records[dns.TypeA][0].RR.(*dns.A); a.A.String() != "192.168.1.6" { 146 | t.Errorf("unexpected IP for one '%s'", a) 147 | } 148 | } 149 | 150 | func TestExampleOrgZone(t *testing.T) { 151 | mm, err := NewMuxManager("../dns", &NilReg{}) 152 | if err != nil { 153 | t.Fatalf("Loading test zones: %s", err) 154 | } 155 | 156 | ex, ok := mm.zonelist["test.example.org"] 157 | if !ok || ex == nil || ex.Labels == nil { 158 | t.Fatalf("Did not load 'test.example.org' test zone") 159 | } 160 | 161 | matches := ex.FindLabels("sub", []string{"@"}, []uint16{dns.TypeNS}) 162 | if matches[0].Type != dns.TypeNS { 163 | t.Fatalf("Expected qtype = NS record (type %d), got type %d", dns.TypeNS, matches[0].Type) 164 | } 165 | 166 | Ns := matches[0].Label.Records[matches[0].Type] 167 | if l := len(Ns); l != 2 { 168 | t.Fatalf("Expected 2 NS records, got '%d'", l) 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /zones/zones_closest_test.go: -------------------------------------------------------------------------------- 1 | package zones 2 | 3 | import ( 4 | "net" 5 | "reflect" 6 | "sort" 7 | "testing" 8 | 9 | "github.com/miekg/dns" 10 | ) 11 | 12 | func TestClosest(t *testing.T) { 13 | muxm := loadZones(t) 14 | t.Log("test closests") 15 | 16 | tests := []struct { 17 | Label string 18 | ClientIP string 19 | ExpectedA []string 20 | QType uint16 21 | MaxHosts int 22 | }{ 23 | {"closest", "212.237.144.84", []string{"194.106.223.155"}, dns.TypeA, 1}, 24 | {"closest", "208.113.157.108", []string{"207.171.7.49", "207.171.7.59"}, dns.TypeA, 2}, 25 | {"closest", "2620:0:872::1", []string{"2607:f238:3::1:45"}, dns.TypeAAAA, 1}, 26 | // {"closest", "208.113.157.108", []string{"207.171.7.59"}, 1}, 27 | } 28 | 29 | for _, x := range tests { 30 | 31 | ip := net.ParseIP(x.ClientIP) 32 | if ip == nil { 33 | t.Fatalf("Invalid ClientIP: %s", x.ClientIP) 34 | } 35 | 36 | tz := muxm.zonelist["test.example.com"] 37 | targets, netmask, location := tz.Options.Targeting.GetTargets(ip, true) 38 | 39 | t.Logf("targets: %q, netmask: %d, location: %+v", targets, netmask, location) 40 | 41 | // This is a weird API, but it's what serve() uses now. Fixing it 42 | // isn't super straight forward. Moving some of the exceptions from serve() 43 | // into configuration and making the "find the best answer" code have 44 | // a better API should be possible though. Some day. 45 | labelMatches := tz.FindLabels( 46 | x.Label, 47 | targets, 48 | []uint16{dns.TypeMF, dns.TypeCNAME, x.QType}, 49 | ) 50 | 51 | if len(labelMatches) == 0 { 52 | t.Fatalf("no labelmatches") 53 | } 54 | 55 | for _, match := range labelMatches { 56 | label := match.Label 57 | labelQtype := match.Type 58 | 59 | records := tz.Picker(label, labelQtype, x.MaxHosts, location) 60 | if records == nil { 61 | t.Fatalf("didn't get closest records") 62 | } 63 | 64 | if len(x.ExpectedA) == 0 { 65 | if len(records) > 0 { 66 | t.Logf("Expected 0 records but got %d", len(records)) 67 | t.Fail() 68 | } 69 | } 70 | 71 | if len(x.ExpectedA) != len(records) { 72 | t.Logf("Expected %d records, got %d", len(x.ExpectedA), len(records)) 73 | t.Fail() 74 | } 75 | 76 | ips := []string{} 77 | 78 | for _, r := range records { 79 | 80 | switch rr := r.RR.(type) { 81 | case *dns.A: 82 | ips = append(ips, rr.A.String()) 83 | case *dns.AAAA: 84 | ips = append(ips, rr.AAAA.String()) 85 | default: 86 | t.Fatalf("unexpected RR type: %s", rr.Header().String()) 87 | } 88 | } 89 | sort.Strings(ips) 90 | sort.Strings(x.ExpectedA) 91 | 92 | if !reflect.DeepEqual(ips, x.ExpectedA) { 93 | t.Logf("Got '%+v', expected '%+v'", ips, x.ExpectedA) 94 | t.Fail() 95 | } 96 | 97 | } 98 | } 99 | } 100 | --------------------------------------------------------------------------------