Compare commits

...
140 Commits
Author SHA1 Message Date
Joona Hoikkala 22c41911ce HTTP timeouts to API, and self-validation mutex to nameserver ops 2026-02-05 14:48:25 +02:00
Joona Hoikkala b7d9f44765 Refine concurrency configuration for GitHub actions 2026-02-04 00:42:14 +02:00
Joona Hoikkala c6826aad31 Update github actions 2026-02-04 00:13:34 +02:00
Joona Hoikkala f448431675 Fix linter and umask setting 2026-02-03 23:59:39 +02:00
Joona Hoikkala 0d1ca5cad7 Add a few tests 2026-02-03 23:41:05 +02:00
Joona Hoikkala 48bfe812c0 Minor refactoring, error returns and e2e testing suite 2026-02-03 23:27:11 +02:00
Joona Hoikkala dc1a8f54b1 Merge remote-tracking branch 'origin/master' into refactoring 2025-05-06 23:00:09 +03:00
e0f9745182 Refactoring - improving coverage (#371)
* Increase code coverage in acmedns

* More testing of ReadConfig() and its fallback mechanism

* Found that if someone put a '"' double quote into the filename that we configure zap to log to, it would cause the the JSON created to be invalid. I have replaced the JSON string with proper config

* Better handling of config options for api.TLS - we now error on an invalid value instead of silently failing.

added a basic test for api.setupTLS() (to increase test coverage)

* testing nameserver isOwnChallenge and isAuthoritative methods

* add a unit test for nameserver answerOwnChallenge

* fix linting errors

* bump go and golangci-lint versions in github actions

* Update golangci-lint.yml

Bumping github-actions workflow versions to accommodate some changes in upstream golanci-lint

* Bump Golang version to 1.23 (currently the oldest supported version)

Bump golanglint-ci to 2.0.2 and migrate the config file.

This should resolve the math/rand/v2 issue

* bump golanglint-ci action version

* Fixing up new golanglint-ci warnings and errors

---------

Co-authored-by: Joona Hoikkala <5235109+joohoi@users.noreply.github.com>
2025-05-06 22:20:59 +03:00
Joona HoikkalaandGitHub b7a0a8a7bc Add timeout to golangci job (#369) 2024-12-15 13:19:23 +02:00
Joona HoikkalaandGitHub 139a0dd03b Update deps to support go 1.23 (#368)
* Update deps to support go 1.23

* Test updated golangci config

* Fix deprecated ioutil references
2024-12-15 13:00:37 +02:00
Joona Hoikkala d20fae37c9 Merge remote-tracking branch 'origin/master' into refactoring 2024-04-10 23:01:26 +03:00
Joona Hoikkala 0450268bec Updated dependencies, wrote changelog entry and fixed namespace for release 2024-04-10 22:51:51 +03:00
Florian RitterhoffandGitHub 34344b1b35 Refactoring (#327)
* chore: enable more linters and fix linter issues

* ci: enable linter checks on all branches and disable recurring checks

recurring linter checks don't make that much sense. The code & linter checks should not change on their own over night ;)

* chore: update packages

* Revert "chore: update packages"

This reverts commit 30250bf28c4b39e9e5b3af012a4e28ab036bf9af.

* chore: manually upgrade some packages
2023-03-07 01:49:06 +02:00
Joona HoikkalaandGitHub 27e8251d11 Bump dependencies (#334) 2023-03-07 01:45:39 +02:00
Joona Hoikkala 1c4e1e55e4 Remove the last dangling reference to old logging package 2023-01-02 18:40:06 +02:00
Joona Hoikkala 3e0ee478b7 Add tests for acmetxt cidrslice and util funcs 2022-12-26 15:56:19 +02:00
Joona Hoikkala f90ef442a3 Small fixes 2022-12-25 13:30:38 +02:00
Joona Hoikkala 157241994f Re-added tests 2022-12-25 12:52:49 +02:00
Joona Hoikkala 1405e6ab47 Refactor core 2022-12-22 12:52:38 +02:00
Joona HoikkalaandGitHub 6ba9360156 Update goreleaser config to work with latest version (#312) 2022-08-10 15:28:16 +03:00
Joona HoikkalaandGitHub 0c3d538de8 Update golint, dependencies and get to sane state (#313)
* Update golangci-lint config

* Fix mutex embedding lint issue

* Change go version

* Update dependencies

* Bump golangci-lint version
2022-08-10 15:17:11 +03:00
Joona HoikkalaandGitHub a33c09accf Deps update (#289)
* Dependency version bumps and according fixes & touchups

* Change to async to avoid interactive dialogs

* Move the Let's Encrypt environment selection to proper place
2022-01-26 00:26:45 +02:00
Joona HoikkalaandGitHub 68bb6ab654 Add acme-dns-client to the documentation (#253) 2021-01-11 18:07:22 +02:00
Joona HoikkalaandGitHub 835fbb9ef6 Migrate to GitHub actions for coverage & unit test automation (#251) 2021-01-11 17:31:09 +02:00
Jonathan VanascoandGitHub 9c6ca258e1 relax subdomain validation from UUID to actual subdomain (#243) 2021-01-11 14:55:31 +02:00
Peter LebbingandGitHub d573f771a8 README: Use https URLs for API testing (#230) 2021-01-11 14:50:48 +02:00
Brett KellerandGitHub 035a219f9f Add support for LE expiration notification e-mails on API cert (#227) 2021-01-11 14:35:54 +02:00
Wouter TinusandGitHub 19069f50ec Update README.md (#210)
Add new website for win-acme
2020-02-10 19:27:16 +02:00
Joona HoikkalaandGitHub 5de21916a3 Handle go modules properly in Dockerfile with Go 1.13 (#207) 2020-01-10 12:32:11 +02:00
Joona HoikkalaandGitHub 3d5a512d1e Revert "Update modules (#203)" (#206)
This reverts commit 1681de11d2.
2020-01-09 08:39:04 +02:00
Joona HoikkalaandGitHub 9f7a158367 Update readme (#204) 2020-01-08 20:40:37 +02:00
Joona HoikkalaandGitHub 1681de11d2 Update modules (#203) 2020-01-08 07:53:11 +02:00
Marvin BlumandJoona Hoikkala 88d3be685e Removed example for health check endpoint because it is misleading. (#181) 2019-10-21 16:13:22 +03:00
Joona HoikkalaandGitHub aa3e7e1b75 Migrate over to certmagic from using autocert (#190)
* Use certmagic for challenge validation

* WIP

* Get the correct key

* Override preflight check logic

* Fix logging for imported packages and tidy config.cfg

* Fix test and add docstrings

* Update README
2019-10-20 22:52:19 +03:00
Joona HoikkalaandGitHub af542b44a9 Proper EDNS0 (non)support (#188)
* Proper EDNS0 (non)support

* Add changelog entry

* Add EDNS0 tests
2019-10-18 22:24:56 +03:00
Joona HoikkalaandGitHub 5b1e51534f Migrate to Go 1.13 and gomodules (#187) 2019-10-17 14:21:04 +03:00
lateagainandJoona Hoikkala 637c82f407 Update acme-dns.service (#183)
Set working directory to service users home.
2019-09-18 16:05:32 +03:00
Daniel McCarneyandJoona Hoikkala e1f1d6af34 README: Add warning/advice about HTTPS API. (#169)
This commit updates the README to add a brief description of the `tls`
configuration options. In particular using `tls = "letsencrypt"` is
recommended and a warning is added about using `tls = "cert"` and
allowing the certificate to expire.
2019-06-12 15:42:23 +03:00
znerolandJoona Hoikkala c13035a344 Refactor: Use more specific type in argument of DB.Update (#162)
The DB.Update function takes a type of ACMETxt. However, the function
only requires the Value and Subdomain fields.

Refactor the function such that it takes ACMETxtPost instead of the full
ACMETxt record. This will simplify extraction of txt-record related
logic from the db code.
2019-06-12 15:41:02 +03:00
Joona HoikkalaandGitHub af5d2561d2 Fail closed with malformed allowfrom data in register endpoint (#148)
* Prepare readme for release

* Fail closed with malformed allowfrom data in register endpoint
2019-02-22 16:53:11 +02:00
Daniel Lo NigroandJoona Hoikkala 395cb7a62c Add Windows ACME Simple (win-acme) to clients list (#134)
* Add win-acme to clients list

* Use its official name ("Windows ACME Simple")

* Remove version number from WACS
2019-02-22 16:42:52 +02:00
Joona HoikkalaandGitHub 5d74ff1142 Prepare README for point release v0.7.2 (#153) 2019-02-07 09:58:07 +02:00
Ward VandewegeandJoona Hoikkala 37db83e5b7 Respond case insensitively to A and SOA requests (#152)
* When appending the SOA for authoritative NXDOMAIN responses, it needs to go in
the Authoritative section, not the Answer section.

This fixes the acme-dns validation for the lego Let's Encrypt client.

* Respond case-insensitively to A and SOA requests. Add corresponding tests.

This fixes the autocert feature with Let's Encrypt, because Let's Encrypt does
a lookup for the A record with a deliberately mangled case.
2019-02-07 09:16:33 +02:00
Ward VandewegeandJoona Hoikkala 41a1cff0ae When appending the SOA for authoritative NXDOMAIN responses, it needs to go in (#151)
the Authoritative section, not the Answer section.

This fixes the acme-dns validation for the lego Let's Encrypt client.
2019-02-07 09:13:47 +02:00
Joona HoikkalaandGitHub 4f5fad0d32 Prepare for point release (#150)
* Prepare for point release
2019-02-06 12:13:00 +02:00
Ward VandewegeandJoona Hoikkala 5651772837 Make sure to initialize the SOA record for the tcp server, too. (#149) 2019-02-06 12:09:01 +02:00
Joona HoikkalaandGitHub 09dc25d336 Update vendored dependencies (#147)
* Prepare readme for release

* Update vendored packages

* New version of dns dependency handles rejecting UPDATEs under the hood

* Go 1.11 required
2019-02-04 00:21:42 +02:00
Joona HoikkalaandGitHub a88ee29755 Prepare readme for release (#146) 2019-02-03 20:32:02 +02:00
Joona HoikkalaandGitHub de7fe3cb1d Fix responses to be more standards compliant (#145)
* Handle OPT questions (EDNS)

* Handle authoritative bit, and append SOA for authoritative NXDOMAIN responses

* Changelog entry

* Fix linter errors
2019-02-03 20:19:15 +02:00
Joona HoikkalaandGitHub 7a2f9f06b1 Refactoring DNS server part for safer paraller execution (#144)
* Refactoring DNS server part for safer paraller execution and better data structures

* Fix linter issues
2019-02-03 17:23:04 +02:00
Joona HoikkalaandGitHub d695f72963 Fix the default configuration SQLite db path (#143) 2019-02-01 09:55:26 +02:00
Marvin BlumandJoona Hoikkala aff13a02fb Added http health check endpoint. (#137)
* Added http health check endpoint.

* Fixed performing POST on GET endpoint.

* Explicitly return http status 200 in health check endpoint.

* Updated changelog.
2019-01-25 19:22:53 +02:00
Joona HoikkalaandGitHub 7fbb5261c8 Fix test not to assume preset UseHeaders value (#139) 2019-01-25 18:36:29 +02:00
Philip VieiraandJoona Hoikkala 2da94f1462 Clarify how to test that the DNS Lookup is working (#133)
Reasoning: Unless you specify that you're looking for a TXT record, you will not get the answer from the DNS server
2019-01-20 18:23:07 +02:00
Christopher CookandJoona Hoikkala ea4d125663 Add Certify The Web to clients list (#131)
Certify now supports acme-dns in the latest beta.
https://github.com/webprofusion/certify/issues/391
2018-12-18 11:05:01 +02:00
Joona HoikkalaandGitHub f64de0353d Fix Docker instructions and add option to bind both UDP and TCP DNS listeners (#130) 2018-12-13 12:19:10 +02:00
Joona HoikkalaandGitHub 20411b650f Update changelog and prepare for release v0.6 (#125) 2018-10-31 15:13:29 +02:00
Joona HoikkalaandGitHub 3bb130d055 Refuse dynamic updates properly (#124) 2018-10-31 15:01:42 +02:00
Joona HoikkalaandGitHub 1d85bf75b0 Added Traefik and cert-manager to client list (#123) 2018-10-31 01:13:10 +02:00
Joona HoikkalaandGitHub c2c5c5cd70 Better error handling in goroutines (#122)
* More robust goroutine error handling using channels

* Fix tests and make startup log msg saner

* Clarification to README and config file
2018-10-31 00:54:51 +02:00
golint fixerandJoona Hoikkala a09073da12 Fix golint import path (#121) 2018-10-29 10:29:34 +02:00
Julien SchmidtandJoona Hoikkala 4bb5e265ab README: use absolute path for sqlite DB in example config (#116) 2018-09-28 11:40:50 +03:00
Julien SchmidtandJoona Hoikkala b452d504c8 README: fix NS address in test command (#115) 2018-09-28 11:14:31 +03:00
Yannik SembritzkiandJoona Hoikkala f76790426d fix inconsistency between README and actual config (#114)
* fix inconsistency between README and actual config

* clarify example config in sync w/ config.cfg
2018-09-28 00:46:07 +03:00
Yannik SembritzkiandJoona Hoikkala f650e47fe5 fix README inconsistencies (#113) 2018-09-28 00:33:51 +03:00
Yannik SembritzkiandJoona Hoikkala dc0dd43017 simplify example dns config (#106) 2018-09-27 23:58:37 +03:00
Julien SchmidtandJoona Hoikkala c827ee4801 README: fix section of HTTP header vars in example config (#112) 2018-09-27 23:51:31 +03:00
Stefan BethkeandJoona Hoikkala db2a6bc288 Cmd line flag -c for config location (#108)
Add command line parsing and a flag `-c` to specify where the config
file should be loaded from.
2018-09-21 13:38:23 +03:00
Joona HoikkalaandGitHub d66ccffaf6 Sign releases (#105) 2018-08-12 21:04:07 +03:00
Joona HoikkalaandGitHub eeff02e63b Changelog entries for v0.5 (#104) 2018-08-12 20:51:04 +03:00
Joona HoikkalaandGitHub 90ae6ee268 Default value for added configuration option to keep backwards compatibility with old config (#103) 2018-08-12 20:19:58 +03:00
Joona HoikkalaandGitHub 0fc5a8e848 Use umask 0077 across the process in order to have the created files readable only by the acme-dns user (#102) 2018-08-12 20:06:54 +03:00
Joona HoikkalaandGitHub ec013c0f25 Better error messages for missing DB config values (#101)
* Better error messages for missing DB config values

* Make linter happy
2018-08-12 19:48:39 +03:00
Joona HoikkalaandGitHub 856cc05881 Added supplementary error checking (#99)
* Added supplementary errorichecking

* After running util.go through gofmt

* Updated main and util

* Minor updates to main and util

* Slight refactoring

* Add tests
2018-08-12 18:49:17 +03:00
Joona HoikkalaandGitHub 75d4a30c1f Update dependencies and replace uuid library (#100) 2018-08-10 16:51:32 +03:00
Daniel McCarneyandJoona Hoikkala 8aa869b2f8 README: Add Lego to supported clients (#94)
* README: Add Lego to supported clients

* README: List clients in alpha sort
2018-07-10 00:28:53 +03:00
Daniel McCarneyandJoona Hoikkala 52e977ce1d README: Add goacmedns, a Go acme-dns client library to README. (#92) 2018-07-01 22:54:37 +03:00
Josh SorefandJoona Hoikkala 0bbbf5ed39 Minor (#90)
* add periods to sentences

* markdown

* reword

* reword: the item _acme-challenge should be a cname, not a cname to _acme-challenge
2018-06-05 11:50:42 +03:00
Josh SorefandJoona Hoikkala fbd5c65a6a Spelling (#89)
* spelling: the challenging

* spelling: effects

* spelling: received

* spelling: vagrantfile
2018-06-05 11:42:16 +03:00
Gabe CookandJoona Hoikkala 4646e7f7e5 Add systemd service information (#83)
* Add systemd service information

* Requested changes for systemd service information
2018-05-27 00:08:55 +03:00
Andrew MeyerandJoona Hoikkala 3006cb712b Make ACME cache directory location configurable (#81)
* Remove trailing whitespace from README and config

* Make ACME cache directory location configurable
2018-05-14 13:42:39 +03:00
Ryan BolgerandJoona Hoikkala fde566fe67 Added Posh-ACME to client list (#78)
* Added Posh-ACME to client list

* alphabetical order as requested
2018-05-02 23:15:50 +03:00
Joona HoikkalaandGitHub a17792d33c Merge pull request #75 from joohoi/golintfix-revert
Revert "Disable golint until golang/go#25048 is resolved."
2018-04-25 11:50:32 +03:00
Joona Hoikkala d1af1d029f Revert "Disable golint until golang/go#25048 is resolved."
This reverts commit 1c918fcaa4.
2018-04-25 11:44:34 +03:00
Joona HoikkalaandGitHub a5b417901c Merge pull request #73 from joohoi/add-sewer
README: Add Sewer client
2018-04-24 15:00:30 +03:00
Joona Hoikkala 065083781e Merge remote-tracking branch 'origin/master' into add-sewer 2018-04-24 14:55:32 +03:00
Joona HoikkalaandGitHub 0af5dc2cd9 Merge pull request #74 from joohoi/golintfix
Fix golint dependency issue
2018-04-24 14:55:05 +03:00
Joona Hoikkala 1c918fcaa4 Disable golint until golang/go#25048 is resolved. 2018-04-24 14:49:20 +03:00
Joona Hoikkala 299c520c2b Add Sewer client 2018-04-24 14:17:59 +03:00
Joona HoikkalaandGitHub 3343d943d6 Merge pull request #68 from joohoi/update-clients
README: add acme.sh and change Certbot hook URL
2018-04-08 12:55:59 +03:00
Joona Hoikkala 7744357b61 README: add acme.sh and change Certbot hook URL 2018-04-08 12:51:06 +03:00
Joona HoikkalaandGitHub 14f552e970 Merge pull request #66 from cpu/cpu-typo-fix
db_test: fix 'recieve' typo
2018-04-08 12:41:20 +03:00
Joona HoikkalaandGitHub f2d1fc692b Merge branch 'master' into cpu-typo-fix 2018-04-08 12:37:14 +03:00
Joona HoikkalaandGitHub dd12fa7c9b Merge pull request #64 from Daniel15/patch-2
Make installation instructions more comprehensive
2018-04-08 12:34:00 +03:00
Joona HoikkalaandGitHub 0991b3e3c9 Merge branch 'master' into patch-2 2018-04-08 12:24:24 +03:00
Daniel d18cd65374 db_test: fix 'recieve' typo 2018-03-26 17:22:23 -04:00
Daniel Lo NigroandGitHub 8eed75b495 Explain CNAMEs in DNS records section 2018-03-22 20:37:07 -07:00
Daniel Lo NigroandGitHub 46b9c45e1b Remove unintended newline 2018-03-22 20:33:30 -07:00
Daniel Lo NigroandGitHub ed82e15349 Refactor readme changes
- Move DNS config to separate "DNS Records" section
- Move testing to separate "Testing It Out" section
2018-03-22 20:32:56 -07:00
Joona HoikkalaandGitHub 7b2203ccca Merge pull request #65 from joohoi/clients
README: Add link to a client hook written in Go
2018-03-20 15:01:51 +02:00
Joona Hoikkala 945013579f README: Add link to a client hook written in Go 2018-03-20 14:56:35 +02:00
Daniel Lo NigroandGitHub 3ee50d0c20 Make installation instructions more comprehensive 2018-03-18 23:47:45 -07:00
Joona HoikkalaandGitHub f2fe21934c Merge pull request #60 from joohoi/link_clientlib
README: Add link to the client library
2018-03-18 17:13:23 +02:00
Joona Hoikkala 07aa5e0043 README: Add link to the client library 2018-03-18 16:02:25 +02:00
Joona HoikkalaandGitHub 01f010e35e README: link to client implementation (#55) 2018-03-15 13:40:45 +02:00
Joona HoikkalaandGitHub cd3a587e43 Release v0.4 (#53) 2018-03-15 00:47:36 +02:00
Joona HoikkalaandGitHub 439da9c09f Properly parse r.RemoteAddr (#50)
* Properly parse r.RemoteAddr

* Add tests, and fix net.ParseCIDR issues with IPv6 addresses enclosed in brackets
2018-03-15 00:23:55 +02:00
Joona HoikkalaandGitHub 5c2e60a828 Add configuration option to disable registration endpoint (#51) 2018-03-14 23:35:39 +02:00
Joona HoikkalaandGitHub d542ee03b5 Merge pull request #42 from cpu/cpu-differentiate-errors
API: Differentiate bad TXT update error.
2018-03-13 11:32:40 +02:00
Joona HoikkalaandGitHub 92f8cc2802 Merge branch 'master' into cpu-differentiate-errors 2018-03-13 10:55:02 +02:00
Joona HoikkalaandGitHub 978ac5d62b Merge pull request #49 from Yannik/patch-1
return cname if requested RR was not found and cname exists (fixes #48)
2018-03-13 10:42:44 +02:00
Yannik Sembritzki 4d214d7f52 return cname if requested RR was not found and cname exists 2018-03-03 11:24:32 +01:00
Daniel McCarneyandGitHub f463d07d0b Merge branch 'master' into cpu-differentiate-errors 2018-03-01 09:54:41 -05:00
Joona HoikkalaandGitHub 9a908d7d6b Log IP address that we're matching against allowFrom values stored in the DB (#46)
* Add logging for IP matching

* Fix typo
2018-03-01 16:53:38 +02:00
Daniel McCarneyandJoona Hoikkala 11c852ee91 README: simplify installation instructions with go get. (#41)
* README: simplify installation instructions with `go get`.

Prior to this commit the installation instructions in the README had you
use `git clone` and then `go build`. This can be collapsed into one step
using a more idiomatic `go get` command. This commit updates the README
accordingly.

* Also mention supported config file locations

* Fix go get URL
2018-02-28 20:38:44 +02:00
Joona HoikkalaandGitHub 32608e9f47 Merge pull request #44 from cpu/cpu-add-api-ip-example-config
README: Add example 'ip' in example API config.
2018-02-28 20:24:08 +02:00
Daniel 77b5fda6fb README: Add example 'ip' in example API config.
`acme-dns` supports binding the API to a specific interface instead of
all interfaces by providing an `ip` address in the `[api]` configuration
section. Prior to this commit the `ip` field wasn't shown in the example
configuration in the README. This commit adds an example showing how to
configure `acme-dns` to listen only on `127.0.0.1` to the example config
and describes what the default value (`""`) does.
2018-02-27 21:45:13 -05:00
Daniel efdd560ee4 API: Differentiate bad TXT update error.
Previous to this commit, if the update message had a valid subdomain but
an invalid TXT the error returned was for a bad subdomain. This can
confuse developers who were POSTing junk TXT records to test acme-dns
:-)

This commit adjusts the `webUpdatePost` error handling such that
`!validSubdomain(input)` and `!validTXT(input)` give distinct errors.

The `!validSubdomain` case should never happen in `webUpdatePost`
because `auth.go`'s `Auth` function already vets the post data
subdomain but I retained the error handling code just in case.

Unit tests for an update with an invalid subdomain and an update with an
invalid TXT are included.
2018-02-27 19:26:15 -05:00
Joona HoikkalaandGitHub 830cceb62c Release v0.3.2 (#40) 2018-02-01 16:10:24 +02:00
Leon KyneurandJoona Hoikkala 9f896759f0 reinstall ca-certificates (#39) 2018-02-01 16:02:06 +02:00
Joona HoikkalaandGitHub 476f6fc555 Only autobuild for linux, as sqlite needs cgo (#38) 2018-02-01 15:33:57 +02:00
Joona HoikkalaandGitHub e2e84ede6b Goreleaser config (#37) 2018-02-01 14:20:40 +02:00
Joona HoikkalaandGitHub 562d7cbad4 Make autocert use HTTP-01 challenge instead of TLS-SNI (#36) 2018-02-01 10:53:34 +02:00
Joona HoikkalaandGitHub 5470ba7a41 Remove unused variable and prepare for v0.2 (#22)
* Remove unused variable and prepare for v0.2

* Added new changes to changelog and feature list

* Modified changelog
2018-01-22 12:47:26 +02:00
Joona HoikkalaandGitHub 665455d319 Docker instructions and configuration (#33)
* Added dockerfile

* Docker configuration

* Added Docker images, composer configuration and documentation
2018-01-22 12:35:07 +02:00
Joona HoikkalaandGitHub c5337fc841 Update dependencies (#32) 2018-01-22 11:19:33 +02:00
Joona HoikkalaandGitHub 733245fb3d Support for multiple TXT records per subdomain (#29)
* Support for multiple TXT records per subdomain and database upgrade functionality

* Linter fixes

* Make sure the database upgrade routine works for PostgreSQL

* Move subdomain query outside of the upgrade transaction
2018-01-22 09:53:07 +02:00
Joona HoikkalaandGitHub ba695134ce Make cors messages respect the debug flag (#25) 2017-11-15 21:35:35 +02:00
Joona HoikkalaandGitHub b0cd264c71 Fail on malformed JSON payloads in register endpoint (#24) 2017-11-15 13:52:27 +02:00
Joona HoikkalaandGitHub 02d42bff30 Removed unnecessary body check (#21) 2017-11-15 01:39:00 +02:00
Joona HoikkalaandGitHub fd9ce4606d Get rid of Iris and use julienschmidt/httprouter instead (#20)
* Replace iris with httprouter

* Linter fixes

* Finalize iris removal

* Vendor dependencies for reproducable builds

* Api tests are back
2017-11-14 23:54:29 +02:00
Koen VlaswinkelandJoona Hoikkala 93871a7cec Fix config not being used (#19) 2017-11-14 13:01:46 +02:00
Joona HoikkalaandGitHub 9c54da3ee6 Try to read config from under /etc (#18) 2017-11-14 00:42:30 +02:00
Joona HoikkalaandGitHub 9c639223ce Added dep vendoring (#17) 2017-11-12 23:57:45 +02:00
Joona HoikkalaandGitHub c70a6cffb0 Go 1.9 and Iris v8, added possibility to bind to IP (#15) 2017-11-12 23:40:15 +02:00
Daniel McCarneyandJoona Hoikkala 0ec12dbc5f Update README json examples. (#12)
This commit fixes the JSON examples in the README. There was a missing
`]` in the example input, a missing IP range in the reflected output,
and a few trailing `,` that shouldn't have been there.
2017-09-01 09:37:22 +03:00
Joona HoikkalaandGitHub 41b2ff5940 Use iris v6 and Go 1.8+ (#10)
* Quick fixes to bring framework up to date

* Script for test running, api tests need complete rewrite

* Removed govendor from tests

* Fix for AutoTLS
2017-08-02 19:25:27 +03:00
Joona HoikkalaandGitHub 2bfeedda4c Fixed iris version to v5 api (#9) 2017-01-30 12:19:22 +02:00
Joona Hoikkala 872e2b7c6f Service offline 2017-01-30 09:32:01 +02:00
Joona HoikkalaandGitHub 220ef6d3c0 Merge pull request #7 from joohoi/release-0.1
Release 0.1
2016-12-05 00:43:10 +02:00
69 changed files with 4373 additions and 2154 deletions
+25
View File
@@ -0,0 +1,25 @@
name: E2E Tests
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref_name != 'master' }}
jobs:
e2e-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Run E2E Tests
run: |
cd test/e2e
docker compose up --build --abort-on-container-exit --exit-code-from tester
+40
View File
@@ -0,0 +1,40 @@
name: Go Coverage
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron: '15 */12 * * *'
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref_name != 'master' }}
jobs:
test:
name: Build and Test
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: true
- name: Build
run: go build -v ./...
- name: Test
run: go test -v -race -covermode=atomic -coverprofile=coverage.out ./...
- name: Send coverage
uses: coverallsapp/github-action@v2
with:
file: coverage.out
format: golang
+29
View File
@@ -0,0 +1,29 @@
name: golangci-lint
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.ref_name != 'master' }}
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: 1.23
- name: Check out code
uses: actions/checkout@v4
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v7
with:
version: v2.0.2
+2 -2
View File
@@ -3,5 +3,5 @@ acme-dns.db
acme-dns.log
.vagrant
coverage.out
vendor/*/
/vendor/**/.git
.idea/
dist/
+30
View File
@@ -0,0 +1,30 @@
version: "2"
linters:
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
issues:
max-issues-per-linter: 0
max-same-issues: 0
formatters:
enable:
- gofmt
- goimports
settings:
goimports:
local-prefixes:
- github.com/acme-dns/acme-dns
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$
+21
View File
@@ -0,0 +1,21 @@
builds:
- binary: acme-dns
env:
- CGO_ENABLED=1
goos:
- linux
goarch:
- amd64
archives:
- id: tgz
format: tar.gz
files:
- LICENSE
- README.md
- Dockerfile
- config.cfg
- acme-dns.service
signs:
- artifacts: checksum
-15
View File
@@ -1,15 +0,0 @@
language: go
go:
- 1.7
env:
- "PATH=/home/travis/gopath/bin:$PATH"
before_install:
- go get -u github.com/kardianos/govendor
- go get github.com/golang/lint/golint
- go get github.com/mattn/goveralls
- govendor sync
script:
- go vet
- golint -set_exit_status
- go test -race -v
- $HOME/gopath/bin/goveralls -ignore main.go -v -service=travis-ci
+5
View File
@@ -0,0 +1,5 @@
{
"gopls": {
"formatting.local": "github.com/acme-dns/acme-dns"
}
}
+23
View File
@@ -0,0 +1,23 @@
FROM golang:alpine AS builder
LABEL maintainer="joona@kuori.org"
RUN apk add --update gcc musl-dev git
ENV GOPATH /tmp/buildcache
RUN git clone https://github.com/joohoi/acme-dns /tmp/acme-dns
WORKDIR /tmp/acme-dns
RUN CGO_ENABLED=1 go build
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /tmp/acme-dns .
RUN mkdir -p /etc/acme-dns
RUN mkdir -p /var/lib/acme-dns
RUN rm -rf ./config.cfg
RUN apk --no-cache add ca-certificates && update-ca-certificates
VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"]
ENTRYPOINT ["./acme-dns"]
EXPOSE 53 80 443
EXPOSE 53/udp
+250 -54
View File
@@ -8,9 +8,11 @@ A simplified DNS server with a RESTful HTTP API to provide a simple way to autom
Many DNS servers do not provide an API to enable automation for the ACME DNS challenges. Those which do, give the keys way too much power.
Leaving the keys laying around your random boxes is too often a requirement to have a meaningful process automation.
Acme-dns provides a simple API exclusively for TXT record updates and should be used with ACME magic "\_acme-challenge" - subdomain CNAME records. This way, in the unfortunate exposure of API keys, the effetcs are limited to the subdomain TXT record in question.
Acme-dns provides a simple API exclusively for TXT record updates and should be used with ACME magic "\_acme-challenge" - subdomain CNAME records. This way, in the unfortunate exposure of API keys, the effects are limited to the subdomain TXT record in question.
So basically it boils down to **accessibility** and **security**
So basically it boils down to **accessibility** and **security**.
For longer explanation of the underlying issue and other proposed solutions, see a blog post on the topic from EFF deeplinks blog: https://www.eff.org/deeplinks/2018/02/technical-deep-dive-securing-automation-acme-dns-challenge-validation
## Features
- Simplified DNS server, serving your ACME DNS challenges (TXT)
@@ -18,16 +20,20 @@ So basically it boils down to **accessibility** and **security**
- HTTP API automatically acquires and uses Let's Encrypt TLS certificate
- Limit /update API endpoint access to specific CIDR mask(s), defined in the /register request
- Supports SQLite & PostgreSQL as DB backends
- Rolling update of two TXT records to be able to answer to challenges for certificates that have both names: `yourdomain.tld` and `*.yourdomain.tld`, as both of the challenges point to the same subdomain.
- Simple deployment (it's Go after all)
## Usage
A client application for acme-dns with support for Certbot authentication hooks is available at: [https://github.com/acme-dns/acme-dns-client](https://github.com/acme-dns/acme-dns-client).
[![asciicast](https://asciinema.org/a/94903.png)](https://asciinema.org/a/94903)
Using acme-dns is a three-step process (provided you already have the self-hosted server set up, or are using a service like acme-dns.io):
Using acme-dns is a three-step process (provided you already have the self-hosted server set up):
- Get credentials and unique subdomain (simple POST request to eg. https://auth.acme-dns.io/register)
- Create a (ACME magic) CNAME record to your existing zone, pointing to the subdomain you got from the registration. (eg. `_acme-challenge.domainiwantcertfor.tld. CNAME a097455b-52cc-4569-90c8-7a4b97c6eba8.auth.example.org` )
- Use your credentials to POST a new DNS challenge values to an acme-dns server for the CA to validate them off of.
- Use your credentials to POST new DNS challenge values to an acme-dns server for the CA to validate from.
- Crontab and forget.
## API
@@ -36,9 +42,9 @@ Using acme-dns is a three-step process (provided you already have the self-hoste
The method returns a new unique subdomain and credentials needed to update your record.
Fulldomain is where you can point your own `_acme-challenge` subdomain CNAME record to.
With the credentials, you can update the TXT response in the service to match the challenge token, later referred as \_\_\_validation\_token\_recieved\_from\_the\_ca\_\_\_, given out by the Certificate Authority.
With the credentials, you can update the TXT response in the service to match the challenge token, later referred as \_\_\_validation\_token\_received\_from\_the\_ca\_\_\_, given out by the Certificate Authority.
**Optional:**: You can POST JSON data to limit the /update requests to predefined source networks using CIDR notation.
**Optional:**: You can POST JSON data to limit the `/update` requests to predefined source networks using CIDR notation.
```POST /register```
@@ -48,7 +54,8 @@ With the credentials, you can update the TXT response in the service to match th
"allowfrom": [
"192.168.100.1/24",
"1.2.3.4/32",
"2002:c0a8:2a00::0/40",
"2002:c0a8:2a00::0/40"
]
}
```
@@ -58,7 +65,8 @@ With the credentials, you can update the TXT response in the service to match th
{
"allowfrom": [
"192.168.100.1/24",
"1.2.3.4/32"
"1.2.3.4/32",
"2002:c0a8:2a00::0/40"
],
"fulldomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a.auth.acme-dns.io",
"password": "htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z",
@@ -76,14 +84,14 @@ The method allows you to update the TXT answer contents of your unique subdomain
#### Required headers
| Header name | Description | Example |
| ------------- |--------------------------------------------|-------------------------------------------------------|
| X-Api-User | UUIDv4 username recieved from registration | `X-Api-User: c36f50e8-4632-44f0-83fe-e070fef28a10` |
| X-Api-Key | Password recieved from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` |
| X-Api-User | UUIDv4 username received from registration | `X-Api-User: c36f50e8-4632-44f0-83fe-e070fef28a10` |
| X-Api-Key | Password received from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` |
#### Example input
```json
{
"subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a",
"txt": "___validation_token_recieved_from_the_ca___",
"txt": "___validation_token_received_from_the_ca___"
}
```
@@ -92,63 +100,154 @@ The method allows you to update the TXT answer contents of your unique subdomain
```Status: 200 OK```
```json
{
"txt": "___validation_token_recieved_from_the_ca___",
"txt": "___validation_token_received_from_the_ca___"
}
```
### Health check endpoint
The method can be used to check readiness and/or liveness of the server. It will return status code 200 on success or won't be reachable.
```GET /health```
## Self-hosted
You are encouraged to run your own acme-dns instance, because you are effectively authorizing the acme-dns server to act on your behalf in providing the answer to challengeing CA, making the instance able to request (and get issued) a TLS certificate for the domain that has CNAME pointing to it.
You are encouraged to run your own acme-dns instance, because you are effectively authorizing the acme-dns server to act on your behalf in providing the answer to the challenging CA, making the instance able to request (and get issued) a TLS certificate for the domain that has CNAME pointing to it.
Check out how in the INSTALL section.
## As a service
Acme-dns instance is running as a service for everyone wanting to get on in fast. You can find it at `auth.acme-dns.io`, so to get started, try:
```curl -X POST https://auth.acme-dns.io/register```
See the INSTALL section for information on how to do this.
## Installation
1) Install [Go](https://golang.org/doc/install)
1) Install [Go 1.13 or newer](https://golang.org/doc/install).
2) Clone this repo: `git clone https://github.com/joohoi/acme-dns $GOPATH/src/acme-dns`
2) Build acme-dns:
```
git clone https://github.com/joohoi/acme-dns
cd acme-dns
export GOPATH=/tmp/acme-dns
go build
```
3) Install govendor. ‘go get -u github.com/kardianos/govendor’ . This is used for dependency handling.
3) Move the built acme-dns binary to a directory in your $PATH, for example:
`sudo mv acme-dns /usr/local/bin`
4) Get dependencies: `cd $GOPATH/src/acme-dns` and `govendor sync`
4) Edit config.cfg to suit your needs (see [configuration](#configuration)). `acme-dns` will read the configuration file from `/etc/acme-dns/config.cfg` or `./config.cfg`, or a location specified with the `-c` flag.
5) Build ACME-DNS: `go build`
5) If your system has systemd, you can optionally install acme-dns as a service so that it will start on boot and be tracked by systemd. This also allows us to add the `CAP_NET_BIND_SERVICE` capability so that acme-dns can be run by a user other than root.
6) Edit config.cfg to suit your needs (see [configuration](#configuration))
1) Make sure that you have moved the configuration file to `/etc/acme-dns/config.cfg` so that acme-dns can access it globally.
7) Run acme-dns. Please note that acme-dns needs to open a privileged port (53, domain), so it needs to be run with elevated privileges.
2) Move the acme-dns executable from `~/go/bin/acme-dns` to `/usr/local/bin/acme-dns` (Any location will work, just be sure to change `acme-dns.service` to match).
3) Create a minimal acme-dns user: `sudo adduser --system --gecos "acme-dns Service" --disabled-password --group --home /var/lib/acme-dns acme-dns`.
4) Move the systemd service unit from `acme-dns.service` to `/etc/systemd/system/acme-dns.service`.
5) Reload systemd units: `sudo systemctl daemon-reload`.
6) Enable acme-dns on boot: `sudo systemctl enable acme-dns.service`.
7) Run acme-dns: `sudo systemctl start acme-dns.service`.
6) If you did not install the systemd service, run `acme-dns`. Please note that acme-dns needs to open a privileged port (53, domain), so it needs to be run with elevated privileges.
### Using Docker
1) Pull the latest acme-dns Docker image: `docker pull joohoi/acme-dns`.
2) Create directories: `config` for the configuration file, and `data` for the sqlite3 database.
3) Copy [configuration template](https://raw.githubusercontent.com/joohoi/acme-dns/master/config.cfg) to `config/config.cfg`.
4) Modify the `config.cfg` to suit your needs.
5) Run Docker, this example expects that you have `port = "80"` in your `config.cfg`:
```
docker run --rm --name acmedns \
-p 53:53 \
-p 53:53/udp \
-p 80:80 \
-v /path/to/your/config:/etc/acme-dns:ro \
-v /path/to/your/data:/var/lib/acme-dns \
-d joohoi/acme-dns
```
### Docker Compose
1) Create directories: `config` for the configuration file, and `data` for the sqlite3 database.
2) Copy [configuration template](https://raw.githubusercontent.com/joohoi/acme-dns/master/config.cfg) to `config/config.cfg`.
3) Copy [docker-compose.yml from the project](https://raw.githubusercontent.com/joohoi/acme-dns/master/docker-compose.yml), or create your own.
4) Edit the `config/config.cfg` and `docker-compose.yml` to suit your needs, and run `docker-compose up -d`.
## DNS Records
Note: In this documentation:
- `auth.example.org` is the hostname of the acme-dns server
- acme-dns will serve `*.auth.example.org` records
- `198.51.100.1` is the **public** IP address of the system running acme-dns
These values should be changed based on your environment.
You will need to add some DNS records on your domain's regular DNS server:
- `NS` record for `auth.example.org` pointing to `auth.example.org` (this means, that `auth.example.org` is responsible for any `*.auth.example.org` records)
- `A` record for `auth.example.org` pointing to `198.51.100.1`
- If using IPv6, an `AAAA` record pointing to the IPv6 address.
- Each domain you will be authenticating will need a `_acme-challenge` `CNAME` subdomain added. The [client](README.md#clients) you use will explain how to do this.
## Testing It Out
You may want to test that acme-dns is working before using it for real queries.
1) Confirm that DNS lookups for the acme-dns subdomain works as expected: `dig auth.example.org`.
2) Call the `/register` API endpoint to register a test domain:
```
$ curl -X POST https://auth.example.org/register
{"username":"eabcdb41-d89f-4580-826f-3e62e9755ef2","password":"pbAXVjlIOE01xbut7YnAbkhMQIkcwoHO0ek2j4Q0","fulldomain":"d420c923-bbd7-4056-ab64-c3ca54c9b3cf.auth.example.org","subdomain":"d420c923-bbd7-4056-ab64-c3ca54c9b3cf","allowfrom":[]}
```
3) Call the `/update` API endpoint to set a test TXT record. Pass the `username`, `password` and `subdomain` received from the `register` call performed above:
```
$ curl -X POST \
-H "X-Api-User: eabcdb41-d89f-4580-826f-3e62e9755ef2" \
-H "X-Api-Key: pbAXVjlIOE01xbut7YnAbkhMQIkcwoHO0ek2j4Q0" \
-d '{"subdomain": "d420c923-bbd7-4056-ab64-c3ca54c9b3cf", "txt": "___validation_token_received_from_the_ca___"}' \
https://auth.example.org/update
```
Note: The `txt` field must be exactly 43 characters long, otherwise acme-dns will reject it
4) Perform a DNS lookup to the test subdomain to confirm the updated TXT record is being served:
```
$ dig -t txt @auth.example.org d420c923-bbd7-4056-ab64-c3ca54c9b3cf.auth.example.org
```
## Configuration
```bash
[general]
# dns interface
listen = ":53"
# protocol, "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "udp"
# domain name to serve the requests off of
# DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
# In this case acme-dns will error out and you will need to define the listening interface
# for example: listen = "127.0.0.1:53"
listen = "127.0.0.1:53"
# protocol, "both", "both4", "both6", "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "both"
# domain name to serve the requests off of
domain = "auth.example.org"
# zone name server
nsname = "ns1.auth.example.org"
# zone name server
nsname = "auth.example.org"
# admin email address, where @ is substituted with .
nsadmin = "admin.example.org"
# predefined records served in addition to the TXT
records = [
# default A
"auth.example.org. A 192.168.1.100",
# A
"ns1.auth.example.org. A 192.168.1.100",
"ns2.auth.example.org. A 192.168.1.100",
# NS
"auth.example.org. NS ns1.auth.example.org.",
"auth.example.org. NS ns2.auth.example.org.",
# domain pointing to the public IP of your acme-dns server
"auth.example.org. A 198.51.100.1",
# specify that auth.example.org will resolve any *.auth.example.org records
"auth.example.org. NS auth.example.org.",
]
# debug messages from CORS etc
debug = false
@@ -157,23 +256,34 @@ debug = false
# Database engine to use, sqlite3 or postgres
engine = "sqlite3"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres
connection = "acme-dns.db"
# Please note that the default Docker image uses path /var/lib/acme-dns/acme-dns.db for sqlite3
connection = "/var/lib/acme-dns/acme-dns.db"
# connection = "postgres://user:password@localhost/acmedns_db"
[api]
# domain name to listen requests for, mandatory if using tls = "letsencrypt"
api_domain = ""
# listen ip eg. 127.0.0.1
ip = "0.0.0.0"
# disable registration endpoint
disable_registration = false
# listen port, eg. 443 for default HTTPS
port = "8080"
# possible values: "letsencrypt", "cert", "none"
tls = "none"
port = "443"
# possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "letsencryptstaging"
# only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
# only used if tls = "letsencrypt"
acme_cache_dir = "api-certs"
# optional e-mail address to which Let's Encrypt will send expiration notices for the API's cert
notification_email = ""
# CORS AllowOrigins, wildcards can be used
corsorigins = [
"*"
]
# use HTTP header to get the client ip
use_header = false
# header name to pull the ip address / list of ip addresses from
header_name = "X-Forwarded-For"
[logconfig]
# logging level: "error", "warning", "info" or "debug"
@@ -182,26 +292,112 @@ loglevel = "debug"
logtype = "stdout"
# file path for logfile TODO
# logfile = "./acme-dns.log"
# format, either "json" or "text"
# format, either "json" or "text"
logformat = "text"
# use HTTP header to get the client ip
use_header = false
# header name to pull the ip address / list of ip addresses from
header_name = "X-Forwarded-For"
```
## HTTPS API
The RESTful acme-dns API can be exposed over HTTPS in two ways:
1. Using `tls = "letsencrypt"` and letting acme-dns issue its own certificate
automatically with Let's Encrypt.
1. Using `tls = "cert"` and providing your own HTTPS certificate chain and
private key with `tls_cert_fullchain` and `tls_cert_privkey`.
Where possible the first option is recommended. This is the easiest and safest
way to have acme-dns expose its API over HTTPS.
**Warning**: If you choose to use `tls = "cert"` you must take care that the
certificate *does not expire*! If it does and the ACME client you use to issue the
certificate depends on the ACME DNS API to update TXT records you will be stuck
in a position where the API certificate has expired but it can't be renewed
because the ACME client will refuse to connect to the ACME DNS API it needs to
use for the renewal.
## Clients
- acme.sh: [https://github.com/Neilpang/acme.sh](https://github.com/Neilpang/acme.sh)
- Certify The Web: [https://github.com/webprofusion/certify](https://github.com/webprofusion/certify)
- cert-manager: [https://github.com/jetstack/cert-manager](https://github.com/jetstack/cert-manager)
- Lego: [https://github.com/xenolf/lego](https://github.com/xenolf/lego)
- Posh-ACME: [https://github.com/rmbolger/Posh-ACME](https://github.com/rmbolger/Posh-ACME)
- Sewer: [https://github.com/komuw/sewer](https://github.com/komuw/sewer)
- Traefik: [https://github.com/containous/traefik](https://github.com/containous/traefik)
- Windows ACME Simple (WACS): [https://www.win-acme.com](https://www.win-acme.com)
### Authentication hooks
- acme-dns-client with Certbot authentication hook: [https://github.com/acme-dns/acme-dns-client](https://github.com/acme-dns/acme-dns-client)
- Certbot authentication hook in Python: [https://github.com/joohoi/acme-dns-certbot-joohoi](https://github.com/joohoi/acme-dns-certbot-joohoi)
- Certbot authentication hook in Go: [https://github.com/koesie10/acme-dns-certbot-hook](https://github.com/koesie10/acme-dns-certbot-hook)
### Libraries
- Generic client library in Python ([PyPI](https://pypi.python.org/pypi/pyacmedns/)): [https://github.com/joohoi/pyacmedns](https://github.com/joohoi/pyacmedns)
- Generic client library in Go: [https://github.com/cpu/goacmedns](https://github.com/cpu/goacmedns)
## Changelog
- v1.0
- New
- Refactoring of the codebase to something more robust
- Changed
- Updated dependencies
- v0.8
- NOTE: configuration option: "api_domain" deprecated!
- New
- Automatic HTTP API certificate provisioning using DNS challenges making acme-dns able to acquire certificates even with HTTP api not being accessible from public internet.
- Configuration value for "tls": "letsencryptstaging". Setting it will help you to debug possible issues with HTTP API certificate acquiring process. This is the new default value.
- Changed
- Fixed: EDNS0 support
- Migrated from autocert to [certmagic](https://github.com/mholt/certmagic) for HTTP API certificate handling
- v0.7.2
- Changed
- Fixed: Regression error of not being able to answer to incoming random-case requests.
- Fixed: SOA record added to a correct header field in NXDOMAIN responses.
- v0.7.1
- Changed
- Fixed: SOA record correctly added to the TCP DNS server when using both, UDP and TCP servers.
- v0.7
- New
- Added an endpoint to perform health checks
- Changed
- A new protocol selection for DNS server "both", that binds both - UDP and TCP ports.
- Refactored DNS server internals.
- Handle some aspects of DNS spec better.
- v0.6
- New
- Command line flag `-c` to specify location of config file.
- Proper refusal of dynamic update requests.
- Release signing
- Changed
- Better error messages for goroutines
- v0.5
- New
- Configurable certificate cache directory
- Changed
- Process wide umask to ensure created files are only readable by the user running acme-dns
- Replaced package that handles UUIDs because of a flaw in the original package
- Updated dependencies
- Better error messages
- v0.4 Clear error messages for bad TXT record content, proper handling of static CNAME records, fixed IP address parsing from the request, added option to disable registration endpoint in the configuration.
- v0.3.2 Dockerfile was fixed for users using autocert feature
- v0.3.1 Added goreleaser for distributing binary builds of the releases
- v0.3 Changed autocert to use HTTP-01 challenges, as TLS-SNI is disabled by Let's Encrypt
- v0.2 Now powered by httprouter, support wildcard certificates, Docker images
- v0.1 Initial release
## TODO
- Logging to a file
- DNSSEC
- Want to see something implemented, make a feature request!
## Contributing
acme-dns is open for contributions.
If you have an improvement, please open a Pull Request.
acme-dns is open for contributions.
If you have an idea for improvement, please open an new issue or feel free to write a PR!
## License
Vendored
+1 -1
View File
@@ -1,7 +1,7 @@
# -*- mode: ruby -*-
# vi: set ft=ruby :
# Vagratnfile for running integration tests with PostgreSQL
# Vagrantfile for running integration tests with PostgreSQL
VAGRANTFILE_API_VERSION = "2"
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=Limited DNS server with RESTful HTTP API to handle ACME DNS challenges easily and securely
After=network.target
[Service]
User=acme-dns
Group=acme-dns
AmbientCapabilities=CAP_NET_BIND_SERVICE
WorkingDirectory=~
ExecStart=/usr/local/bin/acme-dns
Restart=on-failure
[Install]
WantedBy=multi-user.target
-78
View File
@@ -1,78 +0,0 @@
package main
import (
"encoding/json"
"net"
"github.com/satori/go.uuid"
)
// ACMETxt is the default structure for the user controlled record
type ACMETxt struct {
Username uuid.UUID
Password string
ACMETxtPost
LastActive int64
AllowFrom cidrslice
}
// ACMETxtPost holds the DNS part of the ACMETxt struct
type ACMETxtPost struct {
Subdomain string `json:"subdomain"`
Value string `json:"txt"`
}
// cidrslice is a list of allowed cidr ranges
type cidrslice []string
func (c *cidrslice) JSON() string {
ret, _ := json.Marshal(c.ValidEntries())
return string(ret)
}
func (c *cidrslice) ValidEntries() []string {
valid := []string{}
for _, v := range *c {
_, _, err := net.ParseCIDR(v)
if err == nil {
valid = append(valid, v)
}
}
return valid
}
// Check if IP belongs to an allowed net
func (a ACMETxt) allowedFrom(ip string) bool {
remoteIP := net.ParseIP(ip)
// Range not limited
if len(a.AllowFrom.ValidEntries()) == 0 {
return true
}
for _, v := range a.AllowFrom.ValidEntries() {
_, vnet, _ := net.ParseCIDR(v)
if vnet.Contains(remoteIP) {
return true
}
}
return false
}
// Go through list (most likely from headers) to check for the IP.
// Reason for this is that some setups use reverse proxy in front of acme-dns
func (a ACMETxt) allowedFromList(ips []string) bool {
for _, v := range ips {
if a.allowedFrom(v) {
return true
}
}
return false
}
func newACMETxt() ACMETxt {
var a = ACMETxt{}
password := generatePassword(40)
a.Username = uuid.NewV4()
a.Password = password
a.Subdomain = uuid.NewV4().String()
return a
}
-108
View File
@@ -1,108 +0,0 @@
package main
import (
"errors"
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/kataras/iris"
)
// Serve is an authentication middlware function used to authenticate update requests
func (a authMiddleware) Serve(ctx *iris.Context) {
allowUpdate := false
usernameStr := ctx.RequestHeader("X-Api-User")
password := ctx.RequestHeader("X-Api-Key")
postData := ACMETxt{}
username, err := getValidUsername(usernameStr)
if err == nil && validKey(password) {
au, err := DB.GetByUsername(username)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Error while trying to get user")
// To protect against timed side channel (never gonna give you up)
correctPassword(password, "$2a$10$8JEFVNYYhLoBysjAxe2yBuXrkDojBQBkVpXEQgyQyjn43SvJ4vL36")
} else {
if correctPassword(password, au.Password) {
// Password ok
// Now test for the possibly limited ranges
if DNSConf.API.UseHeader {
ips := getIPListFromHeader(ctx.RequestHeader(DNSConf.API.HeaderName))
allowUpdate = au.allowedFromList(ips)
} else {
allowUpdate = au.allowedFrom(ctx.RequestIP())
}
if allowUpdate {
// Update is allowed from remote addr
if err := ctx.ReadJSON(&postData); err == nil {
if au.Subdomain == postData.Subdomain {
ctx.Next()
return
}
} else {
// JSON error
ctx.JSON(iris.StatusBadRequest, iris.Map{"error": "bad data"})
return
}
}
} else {
// Wrong password
log.WithFields(log.Fields{"username": username}).Warning("Failed password check")
}
}
}
ctx.JSON(iris.StatusUnauthorized, iris.Map{"error": "unauthorized"})
}
func webRegisterPost(ctx *iris.Context) {
var regJSON iris.Map
var regStatus int
aTXT := ACMETxt{}
_ = ctx.ReadJSON(&aTXT)
// Create new user
nu, err := DB.Register(aTXT.AllowFrom)
if err != nil {
errstr := fmt.Sprintf("%v", err)
regJSON = iris.Map{"error": errstr}
regStatus = iris.StatusInternalServerError
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error in registration")
} else {
regJSON = iris.Map{"username": nu.Username, "password": nu.Password, "fulldomain": nu.Subdomain + "." + DNSConf.General.Domain, "subdomain": nu.Subdomain, "allowfrom": nu.AllowFrom.ValidEntries()}
regStatus = iris.StatusCreated
log.WithFields(log.Fields{"user": nu.Username.String()}).Debug("Created new user")
}
ctx.JSON(regStatus, regJSON)
}
func webUpdatePost(ctx *iris.Context) {
// User auth done in middleware
a := ACMETxt{}
userStr := ctx.RequestHeader("X-API-User")
// Already checked in auth middlware
username, _ := getValidUsername(userStr)
// Already checked in auth middleware
_ = ctx.ReadJSON(&a)
a.Username = username
// Do update
if validSubdomain(a.Subdomain) && validTXT(a.Value) {
err := DB.Update(a)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error while trying to update record")
webUpdatePostError(ctx, errors.New("internal error"), iris.StatusInternalServerError)
return
}
ctx.JSON(iris.StatusOK, iris.Map{"txt": a.Value})
} else {
log.WithFields(log.Fields{"subdomain": a.Subdomain, "txt": a.Value}).Debug("Bad data for subdomain")
webUpdatePostError(ctx, errors.New("bad data"), iris.StatusBadRequest)
return
}
}
func webUpdatePostError(ctx *iris.Context, err error, status int) {
errStr := fmt.Sprintf("%v", err)
updJSON := iris.Map{"error": errStr}
ctx.JSON(status, updJSON)
}
-262
View File
@@ -1,262 +0,0 @@
package main
import (
"errors"
"github.com/gavv/httpexpect"
"github.com/kataras/iris"
"github.com/kataras/iris/httptest"
"gopkg.in/DATA-DOG/go-sqlmock.v1"
"testing"
)
func setupIris(t *testing.T, debug bool, noauth bool) *httpexpect.Expect {
iris.ResetDefault()
var dbcfg = dbsettings{
Engine: "sqlite3",
Connection: ":memory:"}
var httpapicfg = httpapi{
Domain: "",
Port: "8080",
TLS: "none",
CorsOrigins: []string{"*"},
UseHeader: false,
HeaderName: "X-Forwarded-For",
}
var dnscfg = DNSConfig{
API: httpapicfg,
Database: dbcfg,
}
DNSConf = dnscfg
var ForceAuth = authMiddleware{}
iris.Post("/register", webRegisterPost)
if noauth {
iris.Post("/update", webUpdatePost)
} else {
iris.Post("/update", ForceAuth.Serve, webUpdatePost)
}
httptestcfg := httptest.DefaultConfiguration()
httptestcfg.Debug = debug
return httptest.New(iris.Default, t, httptestcfg)
}
func TestApiRegister(t *testing.T) {
e := setupIris(t, false, false)
e.POST("/register").Expect().
Status(iris.StatusCreated).
JSON().Object().
ContainsKey("fulldomain").
ContainsKey("subdomain").
ContainsKey("username").
ContainsKey("password").
NotContainsKey("error")
allowfrom := map[string][]interface{}{
"allowfrom": []interface{}{"123.123.123.123/32",
"1010.10.10.10/24",
"invalid"},
}
response := e.POST("/register").
WithJSON(allowfrom).
Expect().
Status(iris.StatusCreated).
JSON().Object().
ContainsKey("fulldomain").
ContainsKey("subdomain").
ContainsKey("username").
ContainsKey("password").
ContainsKey("allowfrom").
NotContainsKey("error")
response.Value("allowfrom").Array().Elements("123.123.123.123/32")
}
func TestApiRegisterWithMockDB(t *testing.T) {
e := setupIris(t, false, false)
oldDb := DB.GetBackend()
db, mock, _ := sqlmock.New()
DB.SetBackend(db)
defer db.Close()
mock.ExpectBegin()
mock.ExpectPrepare("INSERT INTO records").WillReturnError(errors.New("error"))
e.POST("/register").Expect().
Status(iris.StatusInternalServerError).
JSON().Object().
ContainsKey("error")
DB.SetBackend(oldDb)
}
func TestApiUpdateWithoutCredentials(t *testing.T) {
e := setupIris(t, false, false)
e.POST("/update").Expect().
Status(iris.StatusUnauthorized).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt")
}
func TestApiUpdateWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
e := setupIris(t, false, false)
newUser, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// Valid data
updateJSON["subdomain"] = newUser.Subdomain
updateJSON["txt"] = validTxtData
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(iris.StatusOK).
JSON().Object().
ContainsKey("txt").
NotContainsKey("error").
ValueEqual("txt", validTxtData)
}
func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
// Valid data
updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8"
updateJSON["txt"] = validTxtData
e := setupIris(t, false, true)
oldDb := DB.GetBackend()
db, mock, _ := sqlmock.New()
DB.SetBackend(db)
defer db.Close()
mock.ExpectBegin()
mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error"))
e.POST("/update").
WithJSON(updateJSON).
Expect().
Status(iris.StatusInternalServerError).
JSON().Object().
ContainsKey("error")
DB.SetBackend(oldDb)
}
func TestApiManyUpdateWithCredentials(t *testing.T) {
// TODO: transfer to using httpexpect builder
// If test fails and more debug info is needed, use setupIris(t, true, false)
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
e := setupIris(t, false, false)
// User without defined CIDR masks
newUser, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// User with defined allow from - CIDR masks, all invalid
// (httpexpect doesn't provide a way to mock remote ip)
newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.1/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
}
// Another user with valid CIDR mask to match the httpexpect default
newUserWithValidCIDR, err := DB.Register(cidrslice{"0.0.0.0/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err)
}
for _, test := range []struct {
user string
pass string
subdomain string
txt interface{}
status int
}{
{"non-uuid-user", "tooshortpass", "non-uuid-subdomain", validTxtData, 401},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "tooshortpass", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "LongEnoughPassButNoUserExists___________", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{newUser.Username.String(), newUser.Password, "a097455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{newUser.Username.String(), newUser.Password, newUser.Subdomain, "tooshortfortxt", 400},
{newUser.Username.String(), newUser.Password, newUser.Subdomain, 1234567890, 400},
{newUser.Username.String(), newUser.Password, newUser.Subdomain, validTxtData, 200},
{newUserWithCIDR.Username.String(), newUserWithCIDR.Password, newUserWithCIDR.Subdomain, validTxtData, 401},
{newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200},
{newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401},
} {
updateJSON = map[string]interface{}{
"subdomain": test.subdomain,
"txt": test.txt}
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", test.user).
WithHeader("X-Api-Key", test.pass).
Expect().
Status(test.status)
}
}
func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
e := setupIris(t, false, false)
// Use header checks from default header (X-Forwarded-For)
DNSConf.API.UseHeader = true
// User without defined CIDR masks
newUser, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.2/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
}
newUserWithIP6CIDR, err := DB.Register(cidrslice{"2002:c0a8::0/32"})
if err != nil {
t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err)
}
for _, test := range []struct {
user ACMETxt
headerValue string
status int
}{
{newUser, "whatever goes", 200},
{newUser, "10.0.0.1, 1.2.3.4 ,3.4.5.6", 200},
{newUserWithCIDR, "127.0.0.1", 401},
{newUserWithCIDR, "10.0.0.1, 10.0.0.2, 192.168.1.3", 401},
{newUserWithCIDR, "10.1.1.1 ,192.168.1.2, 8.8.8.8", 200},
{newUserWithIP6CIDR, "2002:c0a8:b4dc:0d3::0", 200},
{newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401},
{newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200},
} {
updateJSON = map[string]interface{}{
"subdomain": test.user.Subdomain,
"txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", test.user.Username.String()).
WithHeader("X-Api-Key", test.user.Password).
WithHeader("X-Forwarded-For", test.headerValue).
Expect().
Status(test.status)
}
DNSConf.API.UseHeader = false
}
+33 -28
View File
@@ -1,45 +1,50 @@
[general]
# dns interface
listen = ":53"
# protocol, "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "udp"
# domain name to serve the requests off of
# DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
# In this case acme-dns will error out and you will need to define the listening interface
# for example: listen = "127.0.0.1:53"
listen = "127.0.0.1:53"
# protocol, "both", "both4", "both6", "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6"
protocol = "both"
# domain name to serve the requests off of
domain = "auth.example.org"
# zone name server
nsname = "ns1.auth.example.org"
# admin email address, where @ is substituted with .
# zone name server
nsname = "auth.example.org"
# admin email address, where @ is substituted with .
nsadmin = "admin.example.org"
# predefined records served in addition to the TXT
records = [
# default A
"auth.example.org. A 192.168.1.100",
# A
"ns1.auth.example.org. A 192.168.1.100",
"ns2.auth.example.org. A 192.168.1.100",
# NS
"auth.example.org. NS ns1.auth.example.org.",
"auth.example.org. NS ns2.auth.example.org.",
# domain pointing to the public IP of your acme-dns server
"auth.example.org. A 198.51.100.1",
# specify that auth.example.org will resolve any *.auth.example.org records
"auth.example.org. NS auth.example.org.",
]
# debug messages from CORS etc
debug = false
[database]
# Database engine to use, sqlite3 or postgres
engine = "sqlite3"
# Database engine to use, sqlite or postgres
engine = "sqlite"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres
# Please note that the default Docker image uses path /var/lib/acme-dns/acme-dns.db for sqlite3
connection = "acme-dns.db"
# connection = "postgres://user:password@localhost/acmedns_db"
[api]
# domain name to listen requests for, mandatory if using tls = "letsencrypt"
api_domain = ""
# listen ip eg. 127.0.0.1
ip = "0.0.0.0"
# disable registration endpoint
disable_registration = false
# listen port, eg. 443 for default HTTPS
port = "8080"
# possible values: "letsencrypt", "cert", "none"
port = "443"
# possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "none"
# only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
# only used if tls = "letsencrypt"
acme_cache_dir = "api-certs"
# optional e-mail address to which Let's Encrypt will send expiration notices for the API's cert
notification_email = ""
# CORS AllowOrigins, wildcards can be used
corsorigins = [
"*"
@@ -51,10 +56,10 @@ header_name = "X-Forwarded-For"
[logconfig]
# logging level: "error", "warning", "info" or "debug"
loglevel = "debug"
# possible values: stdout, TODO file & integrations
loglevel = "info"
# possible values: stdout, file
logtype = "stdout"
# file path for logfile TODO
# logfile = "./acme-dns.log"
# format, either "json" or "text"
logformat = "text"
# file path for logfile
logfile = "./acme-dns.log"
# format, either "json" or "text"
logformat = "json"
-212
View File
@@ -1,212 +0,0 @@
package main
import (
"database/sql"
"encoding/json"
"errors"
"regexp"
"time"
log "github.com/Sirupsen/logrus"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/satori/go.uuid"
"golang.org/x/crypto/bcrypt"
)
var recordsTable = `
CREATE TABLE IF NOT EXISTS records(
Username TEXT UNIQUE NOT NULL PRIMARY KEY,
Password TEXT UNIQUE NOT NULL,
Subdomain TEXT UNIQUE NOT NULL,
Value TEXT,
LastActive INT,
AllowFrom TEXT
);`
// getSQLiteStmt replaces all PostgreSQL prepared statement placeholders (eg. $1, $2) with SQLite variant "?"
func getSQLiteStmt(s string) string {
re, _ := regexp.Compile("\\$[0-9]")
return re.ReplaceAllString(s, "?")
}
func (d *acmedb) Init(engine string, connection string) error {
d.Lock()
defer d.Unlock()
db, err := sql.Open(engine, connection)
if err != nil {
return err
}
d.DB = db
//d.DB.SetMaxOpenConns(1)
_, err = d.DB.Exec(recordsTable)
if err != nil {
return err
}
return nil
}
func (d *acmedb) Register(afrom cidrslice) (ACMETxt, error) {
d.Lock()
defer d.Unlock()
a := newACMETxt()
a.AllowFrom = cidrslice(afrom.ValidEntries())
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.Password), 10)
timenow := time.Now().Unix()
regSQL := `
INSERT INTO records(
Username,
Password,
Subdomain,
Value,
LastActive,
AllowFrom)
values($1, $2, $3, '', $4, $5)`
if DNSConf.Database.Engine == "sqlite3" {
regSQL = getSQLiteStmt(regSQL)
}
sm, err := d.DB.Prepare(regSQL)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Database error in prepare")
return a, errors.New("SQL error")
}
defer sm.Close()
_, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, timenow, a.AllowFrom.JSON())
if err != nil {
return a, err
}
return a, nil
}
func (d *acmedb) GetByUsername(u uuid.UUID) (ACMETxt, error) {
d.Lock()
defer d.Unlock()
var results []ACMETxt
getSQL := `
SELECT Username, Password, Subdomain, Value, LastActive, AllowFrom
FROM records
WHERE Username=$1 LIMIT 1
`
if DNSConf.Database.Engine == "sqlite3" {
getSQL = getSQLiteStmt(getSQL)
}
sm, err := d.DB.Prepare(getSQL)
if err != nil {
return ACMETxt{}, err
}
defer sm.Close()
rows, err := sm.Query(u.String())
if err != nil {
return ACMETxt{}, err
}
defer rows.Close()
// It will only be one row though
for rows.Next() {
txt, err := getModelFromRow(rows)
if err != nil {
return ACMETxt{}, err
}
results = append(results, txt)
}
if len(results) > 0 {
return results[0], nil
}
return ACMETxt{}, errors.New("no user")
}
func (d *acmedb) GetByDomain(domain string) ([]ACMETxt, error) {
d.Lock()
defer d.Unlock()
domain = sanitizeString(domain)
var a []ACMETxt
getSQL := `
SELECT Username, Password, Subdomain, Value, LastActive, AllowFrom
FROM records
WHERE Subdomain=$1 LIMIT 1
`
if DNSConf.Database.Engine == "sqlite3" {
getSQL = getSQLiteStmt(getSQL)
}
sm, err := d.DB.Prepare(getSQL)
if err != nil {
return a, err
}
defer sm.Close()
rows, err := sm.Query(domain)
if err != nil {
return a, err
}
defer rows.Close()
for rows.Next() {
txt, err := getModelFromRow(rows)
if err != nil {
return a, err
}
a = append(a, txt)
}
return a, nil
}
func (d *acmedb) Update(a ACMETxt) error {
d.Lock()
defer d.Unlock()
// Data in a is already sanitized
timenow := time.Now().Unix()
updSQL := `
UPDATE records SET Value=$1, LastActive=$2
WHERE Username=$3 AND Subdomain=$4
`
if DNSConf.Database.Engine == "sqlite3" {
updSQL = getSQLiteStmt(updSQL)
}
sm, err := d.DB.Prepare(updSQL)
if err != nil {
return err
}
defer sm.Close()
_, err = sm.Exec(a.Value, timenow, a.Username, a.Subdomain)
if err != nil {
return err
}
return nil
}
func getModelFromRow(r *sql.Rows) (ACMETxt, error) {
txt := ACMETxt{}
afrom := ""
err := r.Scan(
&txt.Username,
&txt.Password,
&txt.Subdomain,
&txt.Value,
&txt.LastActive,
&afrom)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Row scan error")
}
cslice := cidrslice{}
err = json.Unmarshal([]byte(afrom), &cslice)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("JSON unmarshall error")
}
txt.AllowFrom = cslice
return txt, err
}
func (d *acmedb) Close() {
d.DB.Close()
}
func (d *acmedb) GetBackend() *sql.DB {
return d.DB
}
func (d *acmedb) SetBackend(backend *sql.DB) {
d.DB = backend
}
-106
View File
@@ -1,106 +0,0 @@
package main
import (
"fmt"
log "github.com/Sirupsen/logrus"
"github.com/miekg/dns"
"strings"
"time"
)
func readQuery(m *dns.Msg) {
for _, que := range m.Question {
if rr, rc, err := answer(que); err == nil {
m.MsgHdr.Rcode = rc
for _, r := range rr {
m.Answer = append(m.Answer, r)
}
}
}
}
func answerTXT(q dns.Question) ([]dns.RR, int, error) {
var ra []dns.RR
rcode := dns.RcodeNameError
subdomain := sanitizeDomainQuestion(q.Name)
atxt, err := DB.GetByDomain(subdomain)
if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error while trying to get record")
return ra, dns.RcodeNameError, err
}
for _, v := range atxt {
if len(v.Value) > 0 {
r := new(dns.TXT)
r.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}
r.Txt = append(r.Txt, v.Value)
ra = append(ra, r)
rcode = dns.RcodeSuccess
}
}
log.WithFields(log.Fields{"domain": q.Name}).Info("Answering TXT question for domain")
return ra, rcode, nil
}
func answer(q dns.Question) ([]dns.RR, int, error) {
if q.Qtype == dns.TypeTXT {
return answerTXT(q)
}
var r []dns.RR
var rcode = dns.RcodeSuccess
var domain = strings.ToLower(q.Name)
var rtype = q.Qtype
r, ok := RR.Records[rtype][domain]
if !ok {
rcode = dns.RcodeNameError
}
log.WithFields(log.Fields{"qtype": dns.TypeToString[rtype], "domain": domain, "rcode": dns.RcodeToString[rcode]}).Debug("Answering question for domain")
return r, rcode, nil
}
func handleRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
if r.Opcode == dns.OpcodeQuery {
readQuery(m)
}
w.WriteMsg(m)
}
// Parse config records
func (r *Records) Parse(config general) {
rrmap := make(map[uint16]map[string][]dns.RR)
for _, v := range config.StaticRecords {
rr, err := dns.NewRR(strings.ToLower(v))
if err != nil {
log.WithFields(log.Fields{"error": err.Error(), "rr": v}).Warning("Could not parse RR from config")
continue
}
// Add parsed RR to the list
rrmap = appendRR(rrmap, rr)
}
// Create serial
serial := time.Now().Format("2006010215")
// Add SOA
SOAstring := fmt.Sprintf("%s. SOA %s. %s. %s 28800 7200 604800 86400", strings.ToLower(config.Domain), strings.ToLower(config.Nsname), strings.ToLower(config.Nsadmin), serial)
soarr, err := dns.NewRR(SOAstring)
if err != nil {
log.WithFields(log.Fields{"error": err.Error(), "soa": SOAstring}).Error("Error while adding SOA record")
} else {
rrmap = appendRR(rrmap, soarr)
}
r.Records = rrmap
}
func appendRR(rrmap map[uint16]map[string][]dns.RR, rr dns.RR) map[uint16]map[string][]dns.RR {
_, ok := rrmap[rr.Header().Rrtype]
if !ok {
newrr := make(map[string][]dns.RR)
rrmap[rr.Header().Rrtype] = newrr
}
rrmap[rr.Header().Rrtype][rr.Header().Name] = append(rrmap[rr.Header().Rrtype][rr.Header().Name], rr)
log.WithFields(log.Fields{"recordtype": dns.TypeToString[rr.Header().Rrtype], "domain": rr.Header().Name}).Debug("Adding new record type to domain")
return rrmap
}
-195
View File
@@ -1,195 +0,0 @@
package main
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"github.com/erikstmartin/go-testdb"
"github.com/miekg/dns"
"strings"
"testing"
)
var resolv resolver
var server *dns.Server
type resolver struct {
server string
}
func (r *resolver) lookup(host string, qtype uint16) ([]dns.RR, error) {
msg := new(dns.Msg)
msg.Id = dns.Id()
msg.Question = make([]dns.Question, 1)
msg.Question[0] = dns.Question{Name: dns.Fqdn(host), Qtype: qtype, Qclass: dns.ClassINET}
in, err := dns.Exchange(msg, r.server)
if err != nil {
return []dns.RR{}, fmt.Errorf("Error querying the server [%v]", err)
}
if in != nil && in.Rcode != dns.RcodeSuccess {
return []dns.RR{}, fmt.Errorf("Received error from the server [%s]", dns.RcodeToString[in.Rcode])
}
return in.Answer, nil
}
func hasExpectedTXTAnswer(answer []dns.RR, cmpTXT string) error {
for _, record := range answer {
// We expect only one answer, so no need to loop through the answer slice
if rec, ok := record.(*dns.TXT); ok {
for _, txtValue := range rec.Txt {
if txtValue == cmpTXT {
return nil
}
}
} else {
errmsg := fmt.Sprintf("Got answer of unexpected type [%q]", answer[0])
return errors.New(errmsg)
}
}
return errors.New("Expected answer not found")
}
func findRecordFromMemory(rrstr string, host string, qtype uint16) error {
var errmsg = "No record found"
arr, _ := dns.NewRR(strings.ToLower(rrstr))
if arrQt, ok := RR.Records[qtype]; ok {
if arrHst, ok := arrQt[host]; ok {
for _, v := range arrHst {
if arr.String() == v.String() {
return nil
}
}
} else {
errmsg = "No records for domain"
}
} else {
errmsg = "No records for this type in DB"
}
return errors.New(errmsg)
}
func TestQuestionDBError(t *testing.T) {
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"}
return testdb.RowsFromSlice(columns, [][]driver.Value{}), errors.New("Prepared query error")
})
defer testdb.Reset()
tdb, err := sql.Open("testdb", "")
if err != nil {
t.Errorf("Got error: %v", err)
}
oldDb := DB.GetBackend()
DB.SetBackend(tdb)
defer DB.SetBackend(oldDb)
q := dns.Question{Name: dns.Fqdn("whatever.tld"), Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
_, rcode, err := answerTXT(q)
if err == nil {
t.Errorf("Expected error but got none")
}
if rcode != dns.RcodeNameError {
t.Errorf("Expected [%s] rcode, but got [%s]", dns.RcodeToString[dns.RcodeNameError], dns.RcodeToString[rcode])
}
}
func TestParse(t *testing.T) {
var testcfg = general{
Domain: ")",
Nsname: "ns1.auth.example.org",
Nsadmin: "admin.example.org",
StaticRecords: []string{},
Debug: false,
}
var testRR Records
testRR.Parse(testcfg)
if !loggerHasEntryWithMessage("Error while adding SOA record") {
t.Errorf("Expected SOA parsing to return error, but did not find one")
}
}
func TestResolveA(t *testing.T) {
resolv := resolver{server: "0.0.0.0:15353"}
answer, err := resolv.lookup("auth.example.org", dns.TypeA)
if err != nil {
t.Errorf("%v", err)
}
if len(answer) > 0 {
err = findRecordFromMemory(answer[0].String(), "auth.example.org.", dns.TypeA)
if err != nil {
t.Errorf("Answer [%s] did not match the expected, got error: [%s], debug: [%q]", answer[0].String(), err, RR.Records)
}
} else {
t.Error("No answer for DNS query")
}
_, err = resolv.lookup("nonexistent.domain.tld", dns.TypeA)
if err == nil {
t.Errorf("Was expecting error because of NXDOMAIN but got none")
}
}
func TestResolveTXT(t *testing.T) {
resolv := resolver{server: "0.0.0.0:15353"}
validTXT := "______________valid_response_______________"
atxt, err := DB.Register(cidrslice{})
if err != nil {
t.Errorf("Could not initiate db record: [%v]", err)
return
}
atxt.Value = validTXT
err = DB.Update(atxt)
if err != nil {
t.Errorf("Could not update db record: [%v]", err)
return
}
for i, test := range []struct {
subDomain string
expTXT string
getAnswer bool
validAnswer bool
}{
{atxt.Subdomain, validTXT, true, true},
{atxt.Subdomain, "invalid", true, false},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", validTXT, false, false},
} {
answer, err := resolv.lookup(test.subDomain+".auth.example.org", dns.TypeTXT)
if err != nil {
if test.getAnswer {
t.Fatalf("Test %d: Expected answer but got: %v", i, err)
}
} else {
if !test.getAnswer {
t.Errorf("Test %d: Expected no answer, but got one.", i)
}
}
if len(answer) > 0 {
if !test.getAnswer {
t.Errorf("Test %d: Expected no answer, but got: [%q]", i, answer)
}
err = hasExpectedTXTAnswer(answer, test.expTXT)
if err != nil {
if test.validAnswer {
t.Errorf("Test %d: %v", i, err)
}
} else {
if !test.validAnswer {
t.Errorf("Test %d: Answer was not expected to be valid, answer [%q], compared to [%s]", i, answer, test.expTXT)
}
}
} else {
if test.getAnswer {
t.Errorf("Test %d: Expected answer, but didn't get one", i)
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
version: '2'
services:
acmedns:
build:
context: .
dockerfile: Dockerfile
image: joohoi/acme-dns:latest
ports:
- "443:443"
- "53:53"
- "53:53/udp"
- "80:80"
volumes:
- ./config:/etc/acme-dns:ro
- ./data:/var/lib/acme-dns
+73
View File
@@ -0,0 +1,73 @@
module github.com/joohoi/acme-dns
go 1.23.0
toolchain go1.23.5
require (
github.com/BurntSushi/toml v1.4.0
github.com/DATA-DOG/go-sqlmock v1.5.0
github.com/caddyserver/certmagic v0.23.0
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5
github.com/gavv/httpexpect v2.0.0+incompatible
github.com/glebarez/go-sqlite v1.20.0
github.com/google/uuid v1.3.0
github.com/julienschmidt/httprouter v1.3.0
github.com/lib/pq v1.10.7
github.com/mholt/acmez/v3 v3.1.2
github.com/miekg/dns v1.1.65
github.com/rs/cors v1.8.3
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.38.0
)
require (
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
golang.org/x/sync v0.14.0 // indirect
)
require (
github.com/ajg/form v1.5.1 // indirect
github.com/andybalholm/brotli v1.0.4 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/gorilla/websocket v1.5.0 // indirect
github.com/imkira/go-interpol v1.1.0 // indirect
github.com/klauspost/compress v1.15.13 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/libdns/libdns v1.0.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.17 // indirect
github.com/moul/http2curl v1.0.0 // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/onsi/gomega v1.24.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/smartystreets/goconvey v1.7.2 // indirect
github.com/stretchr/testify v1.8.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.43.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 // indirect
github.com/yudai/gojsondiff v1.0.0 // indirect
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect
github.com/yudai/pp v2.0.1+incompatible // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.33.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.21.5 // indirect
modernc.org/mathutil v1.5.0 // indirect
modernc.org/memory v1.4.0 // indirect
modernc.org/sqlite v1.20.0 // indirect
)
+271
View File
@@ -0,0 +1,271 @@
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/ajg/form v1.5.1 h1:t9c7v8JUKu/XxOGBU0yjNpaMloxGEJhUkqFRq0ibGeU=
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
github.com/andybalholm/brotli v1.0.4 h1:V7DdXeJtZscaqfNuAdSRuRFzuiKlHSC/Zh3zl9qY3JY=
github.com/andybalholm/brotli v1.0.4/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/caddyserver/certmagic v0.23.0 h1:CfpZ/50jMfG4+1J/u2LV6piJq4HOfO6ppOnOf7DkFEU=
github.com/caddyserver/certmagic v0.23.0/go.mod h1:9mEZIWqqWoI+Gf+4Trh04MOVPD0tGSxtqsxg87hAIH4=
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y=
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072 h1:DddqAaWDpywytcG8w/qoQ5sAN8X12d3Z3koB0C3Rxsc=
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gavv/httpexpect v2.0.0+incompatible h1:1X9kcRshkSKEjNJJxX9Y9mQ5BRfbxU5kORdjhlA1yX8=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/glebarez/go-sqlite v1.20.0 h1:6D9uRXq3Kd+W7At+hOU2eIAeahv6qcYfO8jzmvb4Dr8=
github.com/glebarez/go-sqlite v1.20.0/go.mod h1:uTnJoqtwMQjlULmljLT73Cg7HB+2X6evsBHODyyq1ak=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/imkira/go-interpol v1.1.0 h1:KIiKr0VSG2CUW1hl1jpiyuzuJeKUUpC8iM1AIE7N1Vk=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
github.com/klauspost/compress v1.15.9/go.mod h1:PhcZ0MbTNciWF3rruxRgKxI5NkcHHrHUDtV4Yw2GlzU=
github.com/klauspost/compress v1.15.13 h1:NFn1Wr8cfnenSJSA46lLq4wHCcBzKTSjnBIexDMMOV0=
github.com/klauspost/compress v1.15.13/go.mod h1:QPwzmACJjUTFsnSHH934V6woptycfrDDJnH7hvFVbGM=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw=
github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/libdns/libdns v1.0.0 h1:IvYaz07JNz6jUQ4h/fv2R4sVnRnm77J/aOuC9B+TQTA=
github.com/libdns/libdns v1.0.0/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng=
github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mholt/acmez/v3 v3.1.2 h1:auob8J/0FhmdClQicvJvuDavgd5ezwLBfKuYmynhYzc=
github.com/mholt/acmez/v3 v3.1.2/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
github.com/miekg/dns v1.1.65 h1:0+tIPHzUW0GCge7IiK3guGP57VAw7hoPDfApjkMD1Fc=
github.com/miekg/dns v1.1.65/go.mod h1:Dzw9769uoKVaLuODMDZz9M6ynFU6Em65csPuoi8G0ck=
github.com/moul/http2curl v1.0.0 h1:dRMWoAtb+ePxMlLkrCbAqh4TlPHXvoGUSQ323/9Zahs=
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.24.2 h1:J/tulyYK6JwBldPViHJReihxxZ+22FHs0piGjQAvoUE=
github.com/onsi/gomega v1.24.2/go.mod h1:gs3J10IS7Z7r7eXRoNJIrNqU4ToQukCJhFtKrWgHWnk=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/cors v1.8.3 h1:O+qNyWn7Z+F9M0ILBHgMVPuB1xTOucVd5gtaYyXBpRo=
github.com/rs/cors v1.8.3/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ=
github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
github.com/smartystreets/assertions v1.2.0 h1:42S6lae5dvLc7BrLu/0ugRtcFVjoJNMC/N3yZFZkDFs=
github.com/smartystreets/assertions v1.2.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.7.2 h1:9RBaZCeXEQ3UselpuwUQHltGVXvdwm6cv1hgR6gDIPg=
github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3Pg9vgXWeJpQFMM=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.43.0 h1:Gy4sb32C98fbzVWZlTM1oTMdLWGyvxR03VhM6cBIU4g=
github.com/valyala/fasthttp v1.43.0/go.mod h1:f6VbjjoI3z1NDOZOv17o6RvtRSWxC77seBFc2uWtgiY=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
github.com/yudai/gojsondiff v1.0.0 h1:27cbfqXLVEJ1o8I6v3y9lg8Ydm53EKqHXAOMxEGlCOA=
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M=
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
github.com/yudai/pp v2.0.1+incompatible h1:Q4//iY4pNF6yPLZIigmvcl7k/bPgrcTPIFIcmawg5bI=
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220906165146-f3363e06e74c/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
lukechampine.com/uint128 v1.2.0/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
modernc.org/cc/v3 v3.40.0/go.mod h1:/bTg4dnWkSXowUO6ssQKnOV0yMVxDYNIsIrzqTFDGH0=
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
modernc.org/ccgo/v3 v3.16.13-0.20221017192402-261537637ce8/go.mod h1:fUB3Vn0nVPReA+7IG7yZDfjv1TMWjhQP8gCxrFAtL5g=
modernc.org/ccgo/v3 v3.16.13/go.mod h1:2Quk+5YgpImhPjv2Qsob1DnZ/4som1lJTodubIcoUkY=
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
modernc.org/libc v1.21.4/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/libc v1.21.5 h1:xBkU9fnHV+hvZuPSRszN0AXDG4M7nwPLwTWwkYcvLCI=
modernc.org/libc v1.21.5/go.mod h1:przBsL5RDOZajTVslkugzLBj1evTue36jEomFQOoYuI=
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sqlite v1.20.0 h1:80zmD3BGkm8BZ5fUi/4lwJQHiO3GXgIUvZRXpoIfROY=
modernc.org/sqlite v1.20.0/go.mod h1:EsYz8rfOvLCiYTy5ZFsOYzoCcRMu98YYkwAcCw5YIYw=
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
modernc.org/tcl v1.15.0/go.mod h1:xRoGotBZ6dU+Zo2tca+2EqVEeMmOUBzHnhIwq4YrVnE=
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
modernc.org/z v1.7.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
+39 -52
View File
@@ -1,68 +1,55 @@
//+build !test
package main
import (
log "github.com/Sirupsen/logrus"
"github.com/iris-contrib/middleware/cors"
"github.com/kataras/iris"
"flag"
"fmt"
"os"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/joohoi/acme-dns/pkg/api"
"github.com/joohoi/acme-dns/pkg/database"
"github.com/joohoi/acme-dns/pkg/nameserver"
"go.uber.org/zap"
)
func main() {
setUmask()
configPtr := flag.String("c", "/etc/acme-dns/config.cfg", "config file location")
flag.Parse()
// Read global config
configTmp := readConfig("config.cfg")
DNSConf = configTmp
setupLogging(DNSConf.Logconfig.Format, DNSConf.Logconfig.Level)
// Read the default records in
RR.Parse(DNSConf.General)
// Open database
newDB := new(acmedb)
err := newDB.Init(DNSConf.Database.Engine, DNSConf.Database.Connection)
var err error
var logger *zap.Logger
config, usedConfigFile, err := acmedns.ReadConfig(*configPtr, "./config.cfg")
if err != nil {
log.Errorf("Could not open database [%v]", err)
fmt.Printf("Error: %s\n", err)
os.Exit(1)
}
DB = newDB
defer DB.Close()
logger, err = acmedns.SetupLogging(config)
if err != nil {
fmt.Printf("Could not set up logging: %s\n", err)
os.Exit(1)
}
// Make sure to flush the zap logger buffer before exiting
defer logger.Sync() //nolint:all
sugar := logger.Sugar()
// DNS server
startDNS(DNSConf.General.Listen, DNSConf.General.Proto)
// HTTP API
startHTTPAPI()
log.Debugf("Shutting down...")
}
func startHTTPAPI() {
api := iris.New()
api.Config.DisableBanner = true
crs := cors.New(cors.Options{
AllowedOrigins: DNSConf.API.CorsOrigins,
AllowedMethods: []string{"GET", "POST"},
OptionsPassthrough: false,
Debug: DNSConf.General.Debug,
})
api.Use(crs)
var ForceAuth = authMiddleware{}
api.Post("/register", webRegisterPost)
api.Post("/update", ForceAuth.Serve, webUpdatePost)
switch DNSConf.API.TLS {
case "letsencrypt":
listener, err := iris.LETSENCRYPTPROD(DNSConf.API.Domain)
err = api.Serve(listener)
sugar.Infow("Using config file",
"file", usedConfigFile)
sugar.Info("Starting up")
db, err := database.Init(&config, sugar)
// Error channel for servers
errChan := make(chan error, 1)
api := api.Init(&config, db, sugar, errChan)
dnsservers := nameserver.InitAndStart(&config, db, sugar, errChan)
go api.Start(dnsservers)
if err != nil {
sugar.Error(err)
}
for {
err = <-errChan
if err != nil {
log.Errorf("Error in HTTP server [%v]", err)
sugar.Fatal(err)
}
case "cert":
host := DNSConf.API.Domain + ":" + DNSConf.API.Port
api.ListenTLS(host, DNSConf.API.TLSCertFullchain, DNSConf.API.TLSCertPrivkey)
default:
host := DNSConf.API.Domain + ":" + DNSConf.API.Port
api.Listen(host)
}
}
-96
View File
@@ -1,96 +0,0 @@
package main
import (
"flag"
"fmt"
log "github.com/Sirupsen/logrus"
logrustest "github.com/Sirupsen/logrus/hooks/test"
"io/ioutil"
"os"
"testing"
)
var loghook = new(logrustest.Hook)
var (
postgres = flag.Bool("postgres", false, "run integration tests against PostgreSQL")
)
var records = []string{
"auth.example.org. A 192.168.1.100",
"ns1.auth.example.org. A 192.168.1.101",
"!''b', unparseable ",
"ns2.auth.example.org. A 192.168.1.102",
}
func TestMain(m *testing.M) {
setupTestLogger()
setupConfig()
RR.Parse(DNSConf.General)
flag.Parse()
newDb := new(acmedb)
if *postgres {
DNSConf.Database.Engine = "postgres"
err := newDb.Init("postgres", "postgres://acmedns:acmedns@localhost/acmedns")
if err != nil {
fmt.Println("PostgreSQL integration tests expect database \"acmedns\" running in localhost, with username and password set to \"acmedns\"")
os.Exit(1)
}
} else {
DNSConf.Database.Engine = "sqlite3"
_ = newDb.Init("sqlite3", ":memory:")
}
DB = newDb
server := startDNS("0.0.0.0:15353", "udp")
exitval := m.Run()
server.Shutdown()
DB.Close()
os.Exit(exitval)
}
func setupConfig() {
var dbcfg = dbsettings{
Engine: "sqlite3",
Connection: ":memory:",
}
var generalcfg = general{
Domain: "auth.example.org",
Nsname: "ns1.auth.example.org",
Nsadmin: "admin.example.org",
StaticRecords: records,
Debug: false,
}
var httpapicfg = httpapi{
Domain: "",
Port: "8080",
TLS: "none",
CorsOrigins: []string{"*"},
UseHeader: false,
HeaderName: "X-Forwarded-For",
}
var dnscfg = DNSConfig{
Database: dbcfg,
General: generalcfg,
API: httpapicfg,
}
DNSConf = dnscfg
}
func setupTestLogger() {
log.SetOutput(ioutil.Discard)
log.AddHook(loghook)
}
func loggerHasEntryWithMessage(message string) bool {
for _, v := range loghook.Entries {
if v.Message == message {
return true
}
}
return false
}
+47
View File
@@ -0,0 +1,47 @@
package acmedns
import (
"net"
"github.com/google/uuid"
)
// AllowedFrom Check if IP belongs to an allowed net
func (a ACMETxt) AllowedFrom(ip string) bool {
remoteIP := net.ParseIP(ip)
// Range not limited
if len(a.AllowFrom.ValidEntries()) == 0 {
return true
}
for _, v := range a.AllowFrom.ValidEntries() {
_, vnet, _ := net.ParseCIDR(v)
if vnet.Contains(remoteIP) {
return true
}
}
return false
}
// AllowedFromList Go through list (most likely from headers) to check for the IP.
// Reason for this is that some setups use reverse proxy in front of acme-dns
func (a ACMETxt) AllowedFromList(ips []string) bool {
if len(ips) == 0 {
// If no IP provided, check if no whitelist present (everyone has access)
return a.AllowedFrom("")
}
for _, v := range ips {
if a.AllowedFrom(v) {
return true
}
}
return false
}
func NewACMETxt() ACMETxt {
var a = ACMETxt{}
password := generatePassword(40)
a.Username = uuid.New()
a.Password = password
a.Subdomain = uuid.New().String()
return a
}
+38
View File
@@ -0,0 +1,38 @@
package acmedns
import "testing"
func TestAllowedFrom(t *testing.T) {
testslice := NewACMETxt()
testslice.AllowFrom = []string{"192.168.1.0/24", "2001:db8::/32"}
for _, test := range []struct {
input string
expected bool
}{
{"192.168.1.42", true},
{"192.168.2.42", false},
{"2001:db8:aaaa::", true},
{"2001:db9:aaaa::", false},
} {
if testslice.AllowedFrom(test.input) != test.expected {
t.Errorf("Was expecting AllowedFrom to return %t for %s but got %t instead.", test.expected, test.input, !test.expected)
}
}
}
func TestAllowedFromList(t *testing.T) {
testslice := ACMETxt{AllowFrom: []string{"192.168.1.0/24", "2001:db8::/32"}}
if testslice.AllowedFromList([]string{"192.168.2.2", "1.1.1.1"}) != false {
t.Errorf("Was expecting AllowedFromList to return false")
}
if testslice.AllowedFromList([]string{"192.168.1.2", "1.1.1.1"}) != true {
t.Errorf("Was expecting AllowedFromList to return true")
}
allowfromall := ACMETxt{AllowFrom: []string{}}
if allowfromall.AllowedFromList([]string{"192.168.1.2", "1.1.1.1"}) != true {
t.Errorf("Expected non-restricted AlloFrom to be allowed")
}
if allowfromall.AllowedFromList([]string{}) != true {
t.Errorf("Expected non-restricted AlloFrom to be allowed for empty list")
}
}
+35
View File
@@ -0,0 +1,35 @@
package acmedns
import (
"encoding/json"
"net"
)
// cidrslice is a list of allowed cidr ranges
type Cidrslice []string
func (c *Cidrslice) JSON() string {
ret, _ := json.Marshal(c.ValidEntries())
return string(ret)
}
func (c *Cidrslice) IsValid() error {
for _, v := range *c {
_, _, err := net.ParseCIDR(sanitizeIPv6addr(v))
if err != nil {
return err
}
}
return nil
}
func (c *Cidrslice) ValidEntries() []string {
valid := []string{}
for _, v := range *c {
_, _, err := net.ParseCIDR(sanitizeIPv6addr(v))
if err == nil {
valid = append(valid, sanitizeIPv6addr(v))
}
}
return valid
}
+35
View File
@@ -0,0 +1,35 @@
package acmedns
import (
"encoding/json"
"testing"
)
func TestCidrSlice(t *testing.T) {
for i, test := range []struct {
input Cidrslice
expectedErr bool
expectedLen int
}{
{[]string{"192.168.1.0/24"}, false, 1},
{[]string{"shoulderror"}, true, 0},
{[]string{"2001:db8:aaaaa::"}, true, 0},
{[]string{"192.168.1.0/24", "2001:db8::/32"}, false, 2},
} {
err := test.input.IsValid()
if test.expectedErr && err == nil {
t.Errorf("Expected test %d to generate IsValid() error but it didn't", i)
}
if !test.expectedErr && err != nil {
t.Errorf("Expected test %d to pass IsValid() but it generated an error %s", i, err)
}
outSlice := []string{}
err = json.Unmarshal([]byte(test.input.JSON()), &outSlice)
if err != nil {
t.Errorf("Unexpected error when unmarshaling Cidrslice JSON: %s", err)
}
if len(outSlice) != test.expectedLen {
t.Errorf("Expected cidrslice JSON to be of length %d, but got %d instead for test %d", test.expectedLen, len(outSlice), i)
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package acmedns
import (
"errors"
"fmt"
"os"
"github.com/BurntSushi/toml"
)
const (
ApiTlsProviderNone = "none"
ApiTlsProviderLetsEncrypt = "letsencrypt"
ApiTlsProviderLetsEncryptStaging = "letsencryptstaging"
ApiTlsProviderCert = "cert"
)
func FileIsAccessible(fname string) bool {
_, err := os.Stat(fname)
if err != nil {
return false
}
f, err := os.Open(fname)
if err != nil {
return false
}
f.Close()
return true
}
func readTomlConfig(fname string) (AcmeDnsConfig, error) {
var conf AcmeDnsConfig
_, err := toml.DecodeFile(fname, &conf)
if err != nil {
// Return with config file parsing errors from toml package
return conf, err
}
return prepareConfig(conf)
}
// prepareConfig checks that mandatory values exist, and can be used to set default values in the future
func prepareConfig(conf AcmeDnsConfig) (AcmeDnsConfig, error) {
if conf.Database.Engine == "" {
return conf, errors.New("missing database configuration option \"engine\"")
}
if conf.Database.Connection == "" {
return conf, errors.New("missing database configuration option \"connection\"")
}
// Default values for options added to config to keep backwards compatibility with old config
if conf.API.ACMECacheDir == "" {
conf.API.ACMECacheDir = "api-certs"
}
switch conf.API.TLS {
case ApiTlsProviderCert, ApiTlsProviderLetsEncrypt, ApiTlsProviderLetsEncryptStaging, ApiTlsProviderNone:
// we have a good value
default:
return conf, fmt.Errorf("invalid value for api.tls, expected one of [%s, %s, %s, %s]", ApiTlsProviderCert, ApiTlsProviderLetsEncrypt, ApiTlsProviderLetsEncryptStaging, ApiTlsProviderNone)
}
return conf, nil
}
func ReadConfig(configFile, fallback string) (AcmeDnsConfig, string, error) {
var usedConfigFile string
var config AcmeDnsConfig
var err error
if FileIsAccessible(configFile) {
usedConfigFile = configFile
config, err = readTomlConfig(configFile)
} else if FileIsAccessible(fallback) {
usedConfigFile = fallback
config, err = readTomlConfig(fallback)
} else {
err = fmt.Errorf("configuration file not found")
}
if err != nil {
err = fmt.Errorf("encountered an error while trying to read configuration file: %w", err)
}
return config, usedConfigFile, err
}
+24
View File
@@ -0,0 +1,24 @@
package acmedns
import (
"database/sql"
"github.com/google/uuid"
)
type AcmednsDB interface {
Register(cidrslice Cidrslice) (ACMETxt, error)
GetByUsername(uuid.UUID) (ACMETxt, error)
GetTXTForDomain(string) ([]string, error)
Update(ACMETxtPost) error
GetBackend() *sql.DB
SetBackend(*sql.DB)
Close()
}
type AcmednsNS interface {
Start(errorChannel chan error)
SetOwnAuthKey(key string)
SetNotifyStartedFunc(func())
ParseRecords()
}
+46
View File
@@ -0,0 +1,46 @@
package acmedns
import (
"go.uber.org/zap/zapcore"
"go.uber.org/zap"
)
func SetupLogging(config AcmeDnsConfig) (*zap.Logger, error) {
var (
logger *zap.Logger
zapCfg zap.Config
err error
)
logformat := "console"
if config.Logconfig.Format == "json" {
logformat = "json"
}
outputPath := "stdout"
if config.Logconfig.Logtype == "file" {
outputPath = config.Logconfig.File
}
errorPath := "stderr"
if config.Logconfig.Logtype == "file" {
errorPath = config.Logconfig.File
}
zapCfg.Level, err = zap.ParseAtomicLevel(config.Logconfig.Level)
if err != nil {
return logger, err
}
zapCfg.Encoding = logformat
zapCfg.OutputPaths = []string{outputPath}
zapCfg.ErrorOutputPaths = []string{errorPath}
zapCfg.EncoderConfig = zapcore.EncoderConfig{
TimeKey: "time",
MessageKey: "msg",
LevelKey: "level",
EncodeLevel: zapcore.LowercaseLevelEncoder,
EncodeTime: zapcore.ISO8601TimeEncoder,
}
logger, err = zapCfg.Build()
return logger, err
}
+36
View File
@@ -0,0 +1,36 @@
[general]
listen = "127.0.0.1:53"
protocol = "both"
domain = "test.example.org"
nsname = "test.example.org"
nsadmin = "test.example.org"
records = [
"test.example.org. A 127.0.0.1",
"test.example.org. NS test.example.org.",
]
debug = true
[database]
engine = "dinosaur"
connection = "roar"
[api]
ip = "0.0.0.0"
disable_registration = false
port = "443"
tls = "none"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.pem"
acme_cache_dir = "api-certs"
notification_email = ""
corsorigins = [
"*"
]
use_header = true
header_name = "X-is-gonna-give-it-to-ya"
[logconfig]
loglevel = "info"
logtype = "stdout"
logfile = "./acme-dns.log"
logformat = "json"
+72
View File
@@ -0,0 +1,72 @@
package acmedns
import "github.com/google/uuid"
type Account struct {
Username string
Password string
Subdomain string
}
// AcmeDnsConfig holds the config structure
type AcmeDnsConfig struct {
General general
Database dbsettings
API httpapi
Logconfig logconfig
}
// Config file general section
type general struct {
Listen string
Proto string `toml:"protocol"`
Domain string
Nsname string
Nsadmin string
Debug bool
StaticRecords []string `toml:"records"`
}
type dbsettings struct {
Engine string
Connection string
}
// API config
type httpapi struct {
Domain string `toml:"api_domain"`
IP string
DisableRegistration bool `toml:"disable_registration"`
AutocertPort string `toml:"autocert_port"`
Port string `toml:"port"`
TLS string
TLSCertPrivkey string `toml:"tls_cert_privkey"`
TLSCertFullchain string `toml:"tls_cert_fullchain"`
ACMECacheDir string `toml:"acme_cache_dir"`
NotificationEmail string `toml:"notification_email"`
CorsOrigins []string
UseHeader bool `toml:"use_header"`
HeaderName string `toml:"header_name"`
}
// Logging config
type logconfig struct {
Level string `toml:"loglevel"`
Logtype string `toml:"logtype"`
File string `toml:"logfile"`
Format string `toml:"logformat"`
}
// ACMETxt is the default structure for the user controlled record
type ACMETxt struct {
Username uuid.UUID
Password string
ACMETxtPost
AllowFrom Cidrslice
}
// ACMETxtPost holds the DNS part of the ACMETxt struct
type ACMETxtPost struct {
Subdomain string `json:"subdomain"`
Value string `json:"txt"`
}
+40
View File
@@ -0,0 +1,40 @@
package acmedns
import (
"crypto/rand"
"math/big"
"regexp"
"golang.org/x/crypto/bcrypt"
)
func sanitizeIPv6addr(s string) string {
// Remove brackets from IPv6 addresses, net.ParseCIDR needs this
re, _ := regexp.Compile(`[\[\]]+`)
return re.ReplaceAllString(s, "")
}
func SanitizeString(s string) string {
// URL safe base64 alphabet without padding as defined in ACME
re, _ := regexp.Compile(`[^A-Za-z\-\_0-9]+`)
return re.ReplaceAllString(s, "")
}
func generatePassword(length int) string {
ret := make([]byte, length)
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890-_"
alphalen := big.NewInt(int64(len(alphabet)))
for i := 0; i < length; i++ {
c, _ := rand.Int(rand.Reader, alphalen)
r := int(c.Int64())
ret[i] = alphabet[r]
}
return string(ret)
}
func CorrectPassword(pw string, hash string) bool {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)); err == nil {
return true
}
return false
}
+326
View File
@@ -0,0 +1,326 @@
package acmedns
import (
"fmt"
"math/rand/v2"
"os"
"reflect"
"syscall"
"testing"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"golang.org/x/crypto/bcrypt"
)
func fakeConfig() AcmeDnsConfig {
conf := AcmeDnsConfig{}
conf.Logconfig.Logtype = "stdout"
return conf
}
func TestSetupLogging(t *testing.T) {
conf := fakeConfig()
for i, test := range []struct {
format string
level string
expected zapcore.Level
}{
{"text", "warn", zap.WarnLevel},
{"json", "debug", zap.DebugLevel},
{"text", "info", zap.InfoLevel},
{"json", "error", zap.ErrorLevel},
} {
conf.Logconfig.Format = test.format
conf.Logconfig.Level = test.level
logger, err := SetupLogging(conf)
if err != nil {
t.Errorf("Got unexpected error: %s", err)
} else {
if logger.Sugar().Level() != test.expected {
t.Errorf("Test %d: Expected loglevel %s but got %s", i, test.expected, logger.Sugar().Level())
}
}
}
}
func TestSetupLoggingError(t *testing.T) {
conf := fakeConfig()
for _, test := range []struct {
format string
level string
file string
errexpected bool
}{
{"text", "warn", "", false},
{"json", "debug", "", false},
{"text", "info", "", false},
{"json", "error", "", false},
{"text", "something", "", true},
{"text", "info", "a path with\" in its name.txt", false},
} {
conf.Logconfig.Format = test.format
conf.Logconfig.Level = test.level
if test.file != "" {
conf.Logconfig.File = test.file
conf.Logconfig.Logtype = "file"
}
_, err := SetupLogging(conf)
if test.errexpected && err == nil {
t.Errorf("Expected error but did not get one for loglevel: %s", err)
} else if !test.errexpected && err != nil {
t.Errorf("Unexpected error: %s", err)
}
// clean up the file zap creates
if test.file != "" {
_ = os.Remove(test.file)
}
}
}
func TestReadConfig(t *testing.T) {
for i, test := range []struct {
inFile []byte
output AcmeDnsConfig
}{
{
[]byte("[general]\nlisten = \":53\"\ndebug = true\n[api]\napi_domain = \"something.strange\""),
AcmeDnsConfig{
General: general{
Listen: ":53",
Debug: true,
},
API: httpapi{
Domain: "something.strange",
},
},
},
{
[]byte("[\x00[[[[[[[[[de\nlisten =]"),
AcmeDnsConfig{},
},
} {
tmpfile, err := os.CreateTemp("", "acmedns")
if err != nil {
t.Fatalf("Could not create temporary file: %s", err)
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.Write(test.inFile); err != nil {
t.Error("Could not write to temporary file")
}
if err := tmpfile.Close(); err != nil {
t.Error("Could not close temporary file")
}
ret, _, _ := ReadConfig(tmpfile.Name(), "")
if ret.General.Listen != test.output.General.Listen {
t.Errorf("Test %d: Expected listen value %s, but got %s", i, test.output.General.Listen, ret.General.Listen)
}
if ret.API.Domain != test.output.API.Domain {
t.Errorf("Test %d: Expected HTTP API domain %s, but got %s", i, test.output.API.Domain, ret.API.Domain)
}
}
}
func TestReadConfigFallback(t *testing.T) {
var (
path string
err error
)
testPath := "testdata/test_read_fallback_config.toml"
path, err = getNonExistentPath()
if err != nil {
t.Errorf("failed getting non existant path: %s", err)
}
cfg, used, err := ReadConfig(path, testPath)
if err != nil {
t.Fatalf("failed to read a config file when we should have: %s", err)
}
if used != testPath {
t.Fatalf("we read from the wrong file. got: %s, want: %s", used, testPath)
}
expected := AcmeDnsConfig{
General: general{
Listen: "127.0.0.1:53",
Proto: "both",
Domain: "test.example.org",
Nsname: "test.example.org",
Nsadmin: "test.example.org",
Debug: true,
StaticRecords: []string{
"test.example.org. A 127.0.0.1",
"test.example.org. NS test.example.org.",
},
},
Database: dbsettings{
Engine: "dinosaur",
Connection: "roar",
},
API: httpapi{
Domain: "",
IP: "0.0.0.0",
DisableRegistration: false,
AutocertPort: "",
Port: "443",
TLS: "none",
TLSCertPrivkey: "/etc/tls/example.org/privkey.pem",
TLSCertFullchain: "/etc/tls/example.org/fullchain.pem",
ACMECacheDir: "api-certs",
NotificationEmail: "",
CorsOrigins: []string{"*"},
UseHeader: true,
HeaderName: "X-is-gonna-give-it-to-ya",
},
Logconfig: logconfig{
Level: "info",
Logtype: "stdout",
File: "./acme-dns.log",
Format: "json",
},
}
if !reflect.DeepEqual(cfg, expected) {
t.Errorf("Did not read the config correctly: got %+v, want: %+v", cfg, expected)
}
}
func getNonExistentPath() (string, error) {
path := fmt.Sprintf("/some/path/that/should/not/exist/on/any/filesystem/%10d.cfg", rand.Int())
if _, err := os.Stat(path); os.IsNotExist(err) {
return path, nil
}
return "", fmt.Errorf("attempted non existant file exists!?: %s", path)
}
// TestReadConfigFallbackError makes sure we error when we do not have a fallback config file
func TestReadConfigFallbackError(t *testing.T) {
var (
badPaths []string
i int
)
for len(badPaths) < 2 && i < 10 {
i++
if path, err := getNonExistentPath(); err == nil {
badPaths = append(badPaths, path)
}
}
if len(badPaths) != 2 {
t.Fatalf("did not create exactly 2 bad paths")
}
_, _, err := ReadConfig(badPaths[0], badPaths[1])
if err == nil {
t.Errorf("Should have failed reading non existant file: %s", err)
}
}
func TestFileCheckPermissionDenied(t *testing.T) {
tmpfile, err := os.CreateTemp("", "acmedns")
if err != nil {
t.Fatalf("Could not create temporary file: %s", err)
}
defer os.Remove(tmpfile.Name())
_ = syscall.Chmod(tmpfile.Name(), 0000)
if FileIsAccessible(tmpfile.Name()) {
t.Errorf("File should not be accessible")
}
_ = syscall.Chmod(tmpfile.Name(), 0644)
}
func TestFileCheckNotExists(t *testing.T) {
if FileIsAccessible("/path/that/does/not/exist") {
t.Errorf("File should not be accessible")
}
}
func TestFileCheckOK(t *testing.T) {
tmpfile, err := os.CreateTemp("", "acmedns")
if err != nil {
t.Fatalf("Could not create temporary file: %s", err)
}
defer os.Remove(tmpfile.Name())
if !FileIsAccessible(tmpfile.Name()) {
t.Errorf("File should be accessible")
}
}
func TestPrepareConfig(t *testing.T) {
for i, test := range []struct {
input AcmeDnsConfig
shoulderror bool
}{
{AcmeDnsConfig{
Database: dbsettings{Engine: "whatever", Connection: "whatever_too"},
API: httpapi{TLS: ApiTlsProviderNone},
}, false},
{AcmeDnsConfig{Database: dbsettings{Engine: "", Connection: "whatever_too"},
API: httpapi{TLS: ApiTlsProviderNone},
}, true},
{AcmeDnsConfig{Database: dbsettings{Engine: "whatever", Connection: ""},
API: httpapi{TLS: ApiTlsProviderNone},
}, true},
{AcmeDnsConfig{
Database: dbsettings{Engine: "whatever", Connection: "whatever_too"},
API: httpapi{TLS: "whatever"},
}, true},
} {
_, err := prepareConfig(test.input)
if test.shoulderror {
if err == nil {
t.Errorf("Test %d: Expected error with prepareConfig input data [%v]", i, test.input)
}
} else {
if err != nil {
t.Errorf("Test %d: Expected no error with prepareConfig input data [%v]", i, test.input)
}
}
}
}
func TestSanitizeString(t *testing.T) {
for i, test := range []struct {
input string
expected string
}{
{"abcd!abcd", "abcdabcd"},
{"ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz0123456789", "ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz0123456789"},
{"ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopq=@rstuvwxyz0123456789", "ABCDEFGHIJKLMNOPQRSTUVXYZabcdefghijklmnopqrstuvwxyz0123456789"},
} {
if SanitizeString(test.input) != test.expected {
t.Errorf("Expected SanitizeString to return %s for test %d, but got %s instead", test.expected, i, SanitizeString(test.input))
}
}
}
func TestCorrectPassword(t *testing.T) {
testPass, _ := bcrypt.GenerateFromPassword([]byte("nevergonnagiveyouup"), 10)
for i, test := range []struct {
input string
expected bool
}{
{"abcd", false},
{"nevergonnagiveyouup", true},
{"@rstuvwxyz0123456789", false},
} {
if test.expected && !CorrectPassword(test.input, string(testPass)) {
t.Errorf("Expected CorrectPassword to return %t for test %d", test.expected, i)
}
if !test.expected && CorrectPassword(test.input, string(testPass)) {
t.Errorf("Expected CorrectPassword to return %t for test %d", test.expected, i)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"context"
"crypto/tls"
"net/http"
"time"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/caddyserver/certmagic"
"github.com/julienschmidt/httprouter"
"github.com/rs/cors"
"go.uber.org/zap"
)
type AcmednsAPI struct {
Config *acmedns.AcmeDnsConfig
DB acmedns.AcmednsDB
Logger *zap.SugaredLogger
errChan chan error
}
func Init(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.SugaredLogger, errChan chan error) AcmednsAPI {
a := AcmednsAPI{Config: config, DB: db, Logger: logger, errChan: errChan}
return a
}
func (a *AcmednsAPI) Start(dnsservers []acmedns.AcmednsNS) {
var err error
//TODO: do we want to debug log the HTTP server?
stderrorlog, err := zap.NewStdLogAt(a.Logger.Desugar(), zap.ErrorLevel)
if err != nil {
a.errChan <- err
return
}
api := httprouter.New()
c := cors.New(cors.Options{
AllowedOrigins: a.Config.API.CorsOrigins,
AllowedMethods: []string{"GET", "POST"},
OptionsPassthrough: false,
Debug: a.Config.General.Debug,
})
if a.Config.General.Debug {
// Logwriter for saner log output
c.Log = stderrorlog
}
if !a.Config.API.DisableRegistration {
api.POST("/register", a.webRegisterPost)
}
api.POST("/update", a.Auth(a.webUpdatePost))
api.GET("/health", a.healthCheck)
host := a.Config.API.IP + ":" + a.Config.API.Port
// TLS specific general settings
cfg := &tls.Config{
MinVersion: tls.VersionTLS12,
}
switch a.Config.API.TLS {
case acmedns.ApiTlsProviderLetsEncrypt, acmedns.ApiTlsProviderLetsEncryptStaging:
magic := a.setupTLS(dnsservers)
err = magic.ManageAsync(context.Background(), []string{a.Config.General.Domain})
if err != nil {
a.errChan <- err
return
}
cfg.GetCertificate = magic.GetCertificate
srv := &http.Server{
Addr: host,
Handler: c.Handler(api),
TLSConfig: cfg,
ErrorLog: stderrorlog,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
a.Logger.Infow("Listening HTTPS",
"host", host,
"domain", a.Config.General.Domain)
err = srv.ListenAndServeTLS("", "")
case acmedns.ApiTlsProviderCert:
srv := &http.Server{
Addr: host,
Handler: c.Handler(api),
TLSConfig: cfg,
ErrorLog: stderrorlog,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
}
a.Logger.Infow("Listening HTTPS",
"host", host,
"domain", a.Config.General.Domain)
err = srv.ListenAndServeTLS(a.Config.API.TLSCertFullchain, a.Config.API.TLSCertPrivkey)
default:
a.Logger.Infow("Listening HTTP",
"host", host)
err = http.ListenAndServe(host, c.Handler(api))
}
if err != nil {
a.errChan <- err
}
}
func (a *AcmednsAPI) setupTLS(dnsservers []acmedns.AcmednsNS) *certmagic.Config {
provider := NewChallengeProvider(dnsservers)
certmagic.Default.Logger = a.Logger.Desugar()
storage := certmagic.FileStorage{Path: a.Config.API.ACMECacheDir}
// Set up certmagic for getting certificate for acme-dns api
certmagic.DefaultACME.DNS01Solver = &provider
certmagic.DefaultACME.Agreed = true
certmagic.DefaultACME.Logger = a.Logger.Desugar()
if a.Config.API.TLS == acmedns.ApiTlsProviderLetsEncrypt {
certmagic.DefaultACME.CA = certmagic.LetsEncryptProductionCA
} else {
certmagic.DefaultACME.CA = certmagic.LetsEncryptStagingCA
}
certmagic.DefaultACME.Email = a.Config.API.NotificationEmail
magicConf := certmagic.Default
magicConf.Logger = a.Logger.Desugar()
magicConf.Storage = &storage
magicConf.DefaultServerName = a.Config.General.Domain
magicCache := certmagic.NewCache(certmagic.CacheOptions{
GetConfigForCert: func(cert certmagic.Certificate) (*certmagic.Config, error) {
return &magicConf, nil
},
Logger: a.Logger.Desugar(),
})
magic := certmagic.New(magicCache, magicConf)
return magic
}
+533
View File
@@ -0,0 +1,533 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/joohoi/acme-dns/pkg/database"
"github.com/joohoi/acme-dns/pkg/nameserver"
"github.com/DATA-DOG/go-sqlmock"
"github.com/caddyserver/certmagic"
"github.com/gavv/httpexpect"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter"
"github.com/rs/cors"
"go.uber.org/zap"
)
func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger) {
c := acmedns.AcmeDnsConfig{}
c.Database.Engine = "sqlite"
c.Database.Connection = ":memory:"
l := zap.NewNop().Sugar()
return c, l
}
// noAuth function to write ACMETxt model to context while not preforming any validation
func noAuth(update httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
postData := acmedns.ACMETxt{}
uname := r.Header.Get("X-Api-User")
passwd := r.Header.Get("X-Api-Key")
dec := json.NewDecoder(r.Body)
_ = dec.Decode(&postData)
// Set user info to the decoded ACMETxt object
postData.Username, _ = uuid.Parse(uname)
postData.Password = passwd
// Set the ACMETxt struct to context to pull in from update function
ctx := r.Context()
ctx = context.WithValue(ctx, ACMETxtKey, postData)
r = r.WithContext(ctx)
update(w, r, p)
}
}
func getExpect(t *testing.T, server *httptest.Server) *httpexpect.Expect {
return httpexpect.WithConfig(httpexpect.Config{
BaseURL: server.URL,
Reporter: httpexpect.NewAssertReporter(t),
Printers: []httpexpect.Printer{
httpexpect.NewCurlPrinter(t),
httpexpect.NewDebugPrinter(t, true),
},
})
}
func setupRouter(debug bool, noauth bool) (http.Handler, AcmednsAPI, acmedns.AcmednsDB) {
api := httprouter.New()
config, logger := fakeConfigAndLogger()
config.API.Domain = ""
config.API.Port = "8080"
config.API.TLS = acmedns.ApiTlsProviderNone
config.API.CorsOrigins = []string{"*"}
config.API.UseHeader = true
config.API.HeaderName = "X-Forwarded-For"
db, _ := database.Init(&config, logger)
errChan := make(chan error, 1)
adnsapi := Init(&config, db, logger, errChan)
c := cors.New(cors.Options{
AllowedOrigins: config.API.CorsOrigins,
AllowedMethods: []string{"GET", "POST"},
OptionsPassthrough: false,
Debug: config.General.Debug,
})
api.POST("/register", adnsapi.webRegisterPost)
api.GET("/health", adnsapi.healthCheck)
if noauth {
api.POST("/update", noAuth(adnsapi.webUpdatePost))
} else {
api.POST("/update", adnsapi.Auth(adnsapi.webUpdatePost))
}
return c.Handler(api), adnsapi, db
}
func TestApiRegister(t *testing.T) {
router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
e.POST("/register").Expect().
Status(http.StatusCreated).
JSON().Object().
ContainsKey("fulldomain").
ContainsKey("subdomain").
ContainsKey("username").
ContainsKey("password").
NotContainsKey("error")
allowfrom := map[string][]interface{}{
"allowfrom": {"123.123.123.123/32",
"2001:db8:a0b:12f0::1/32",
"[::1]/64",
},
}
response := e.POST("/register").
WithJSON(allowfrom).
Expect().
Status(http.StatusCreated).
JSON().Object().
ContainsKey("fulldomain").
ContainsKey("subdomain").
ContainsKey("username").
ContainsKey("password").
ContainsKey("allowfrom").
NotContainsKey("error")
response.Value("allowfrom").Array().Elements("123.123.123.123/32", "2001:db8:a0b:12f0::1/32", "::1/64")
}
func TestApiRegisterBadAllowFrom(t *testing.T) {
router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
invalidVals := []string{
"invalid",
"1.2.3.4/33",
"1.2/24",
"1.2.3.4",
"12345:db8:a0b:12f0::1/32",
"1234::123::123::1/32",
}
for _, v := range invalidVals {
allowfrom := map[string][]interface{}{
"allowfrom": {v}}
response := e.POST("/register").
WithJSON(allowfrom).
Expect().
Status(http.StatusBadRequest).
JSON().Object().
ContainsKey("error")
response.Value("error").Equal("invalid_allowfrom_cidr")
}
}
func TestApiRegisterMalformedJSON(t *testing.T) {
router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
malPayloads := []string{
"{\"allowfrom': '1.1.1.1/32'}",
"\"allowfrom\": \"1.1.1.1/32\"",
"{\"allowfrom\": \"[1.1.1.1/32]\"",
"\"allowfrom\": \"1.1.1.1/32\"}",
"{allowfrom: \"1.2.3.4\"}",
"{allowfrom: [1.2.3.4]}",
"whatever that's not a json payload",
}
for _, test := range malPayloads {
e.POST("/register").
WithBytes([]byte(test)).
Expect().
Status(http.StatusBadRequest).
JSON().Object().
ContainsKey("error").
NotContainsKey("subdomain").
NotContainsKey("username")
}
}
func TestApiRegisterWithMockDB(t *testing.T) {
router, _, db := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
oldDb := db.GetBackend()
mdb, mock, _ := sqlmock.New()
db.SetBackend(mdb)
defer db.Close()
mock.ExpectBegin()
mock.ExpectPrepare("INSERT INTO records").WillReturnError(errors.New("error"))
e.POST("/register").Expect().
Status(http.StatusInternalServerError).
JSON().Object().
ContainsKey("error")
db.SetBackend(oldDb)
}
func TestApiUpdateWithInvalidSubdomain(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
router, _, db := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// Invalid subdomain data
updateJSON["subdomain"] = "example.com"
updateJSON["txt"] = validTxtData
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(http.StatusUnauthorized).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt").
ValueEqual("error", "forbidden")
}
func TestApiUpdateWithInvalidTxt(t *testing.T) {
invalidTXTData := "idk m8 bbl lmao"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
router, _, db := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
updateJSON["subdomain"] = newUser.Subdomain
// Invalid txt data
updateJSON["txt"] = invalidTXTData
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(http.StatusBadRequest).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt").
ValueEqual("error", "bad_txt")
}
func TestApiUpdateWithoutCredentials(t *testing.T) {
router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
e.POST("/update").Expect().
Status(http.StatusUnauthorized).
JSON().Object().
ContainsKey("error").
NotContainsKey("txt")
}
func TestApiUpdateWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
router, _, db := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// Valid data
updateJSON["subdomain"] = newUser.Subdomain
updateJSON["txt"] = validTxtData
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", newUser.Username.String()).
WithHeader("X-Api-Key", newUser.Password).
Expect().
Status(http.StatusOK).
JSON().Object().
ContainsKey("txt").
NotContainsKey("error").
ValueEqual("txt", validTxtData)
}
func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{
"subdomain": "",
"txt": ""}
// Valid data
updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8"
updateJSON["txt"] = validTxtData
router, _, db := setupRouter(false, true)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
oldDb := db.GetBackend()
mdb, mock, _ := sqlmock.New()
db.SetBackend(mdb)
defer db.Close()
mock.ExpectBegin()
mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error"))
e.POST("/update").
WithJSON(updateJSON).
Expect().
Status(http.StatusInternalServerError).
JSON().Object().
ContainsKey("error")
db.SetBackend(oldDb)
}
func TestApiManyUpdateWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
router, _, db := setupRouter(true, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
// User without defined CIDR masks
newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
// User with defined allow from - CIDR masks, all invalid
// (httpexpect doesn't provide a way to mock remote ip)
newUserWithCIDR, err := db.Register(acmedns.Cidrslice{"192.168.1.1/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
}
// Another user with valid CIDR mask to match the httpexpect default
newUserWithValidCIDR, err := db.Register(acmedns.Cidrslice{"10.1.2.3/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err)
}
for _, test := range []struct {
user string
pass string
subdomain string
txt interface{}
status int
}{
{"non-uuid-user", "tooshortpass", "non-uuid-subdomain", validTxtData, 401},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "tooshortpass", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "LongEnoughPassButNoUserExists___________", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{newUser.Username.String(), newUser.Password, "a097455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
{newUser.Username.String(), newUser.Password, newUser.Subdomain, "tooshortfortxt", 400},
{newUser.Username.String(), newUser.Password, newUser.Subdomain, 1234567890, 400},
{newUser.Username.String(), newUser.Password, newUser.Subdomain, validTxtData, 200},
{newUserWithCIDR.Username.String(), newUserWithCIDR.Password, newUserWithCIDR.Subdomain, validTxtData, 401},
{newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200},
{newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401},
} {
updateJSON := map[string]interface{}{
"subdomain": test.subdomain,
"txt": test.txt}
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", test.user).
WithHeader("X-Api-Key", test.pass).
WithHeader("X-Forwarded-For", "10.1.2.3").
Expect().
Status(test.status)
}
}
func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
router, adnsapi, db := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
// Use header checks from default header (X-Forwarded-For)
adnsapi.Config.API.UseHeader = true
// User without defined CIDR masks
newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Could not create new user, got error [%v]", err)
}
newUserWithCIDR, err := db.Register(acmedns.Cidrslice{"192.168.1.2/32", "invalid"})
if err != nil {
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
}
newUserWithIP6CIDR, err := db.Register(acmedns.Cidrslice{"2002:c0a8::0/32"})
if err != nil {
t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err)
}
for _, test := range []struct {
user acmedns.ACMETxt
headerValue string
status int
}{
{newUser, "whatever goes", 200},
{newUser, "10.0.0.1, 1.2.3.4 ,3.4.5.6", 200},
{newUserWithCIDR, "127.0.0.1", 401},
{newUserWithCIDR, "10.0.0.1, 10.0.0.2, 192.168.1.3", 401},
{newUserWithCIDR, "10.1.1.1 ,192.168.1.2, 8.8.8.8", 200},
{newUserWithIP6CIDR, "2002:c0a8:b4dc:0d3::0", 200},
{newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401},
{newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200},
} {
updateJSON := map[string]interface{}{
"subdomain": test.user.Subdomain,
"txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
e.POST("/update").
WithJSON(updateJSON).
WithHeader("X-Api-User", test.user.Username.String()).
WithHeader("X-Api-Key", test.user.Password).
WithHeader("X-Forwarded-For", test.headerValue).
Expect().
Status(test.status)
}
adnsapi.Config.API.UseHeader = false
}
func TestApiHealthCheck(t *testing.T) {
router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router)
defer server.Close()
e := getExpect(t, server)
e.GET("/health").Expect().Status(http.StatusOK)
}
func TestGetIPListFromHeader(t *testing.T) {
for i, test := range []struct {
input string
output []string
}{
{"1.1.1.1, 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
{" 1.1.1.1 , 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
{",1.1.1.1 ,2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
} {
res := getIPListFromHeader(test.input)
if len(res) != len(test.output) {
t.Errorf("Test %d: Expected [%d] items in return list, but got [%d]", i, len(test.output), len(res))
} else {
for j, vv := range test.output {
if res[j] != vv {
t.Errorf("Test %d: Expected return value [%v] but got [%v]", j, test.output, res)
}
}
}
}
}
func TestUpdateAllowedFromIP(t *testing.T) {
_, adnsapi, _ := setupRouter(false, false)
adnsapi.Config.API.UseHeader = false
userWithAllow := acmedns.NewACMETxt()
userWithAllow.AllowFrom = acmedns.Cidrslice{"192.168.1.2/32", "[::1]/128"}
userWithoutAllow := acmedns.NewACMETxt()
for i, test := range []struct {
remoteaddr string
expected bool
}{
{"192.168.1.2:1234", true},
{"192.168.1.1:1234", false},
{"invalid", false},
{"[::1]:4567", true},
} {
newreq, _ := http.NewRequest("GET", "/whatever", nil)
newreq.RemoteAddr = test.remoteaddr
ret := adnsapi.updateAllowedFromIP(newreq, userWithAllow)
if test.expected != ret {
t.Errorf("Test %d: Unexpected result for user with allowForm set", i)
}
if !adnsapi.updateAllowedFromIP(newreq, userWithoutAllow) {
t.Errorf("Test %d: Unexpected result for user without allowForm set", i)
}
}
}
func TestSetupTLS(t *testing.T) {
_, svr, _ := setupRouter(false, false)
for _, test := range []struct {
apiTls string
expectedCA string
}{
{
apiTls: acmedns.ApiTlsProviderLetsEncrypt,
expectedCA: certmagic.LetsEncryptProductionCA,
},
{
apiTls: acmedns.ApiTlsProviderLetsEncryptStaging,
expectedCA: certmagic.LetsEncryptStagingCA,
},
} {
svr.Config.API.TLS = test.apiTls
ns := &nameserver.Nameserver{}
magic := svr.setupTLS([]acmedns.AcmednsNS{ns})
if test.expectedCA != certmagic.DefaultACME.CA {
t.Errorf("failed to configure default ACME CA. got %s, want %s", certmagic.DefaultACME.CA, test.expectedCA)
}
if magic.DefaultServerName != svr.Config.General.Domain {
t.Errorf("failed to set the correct doman. got: %s, want %s", magic.DefaultServerName, svr.Config.General.Domain)
}
}
}
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"context"
"encoding/json"
"fmt"
"net"
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
type key int
// ACMETxtKey is a context key for ACMETxt struct
const ACMETxtKey key = 0
// Auth middleware for update request
func (a *AcmednsAPI) Auth(update httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
postData := acmedns.ACMETxt{}
userOK := false
user, err := a.getUserFromRequest(r)
if err == nil {
if a.updateAllowedFromIP(r, user) {
dec := json.NewDecoder(r.Body)
err = dec.Decode(&postData)
if err != nil {
a.Logger.Errorw("Decoding error",
"error", "json_error")
}
if user.Subdomain == postData.Subdomain {
userOK = true
} else {
a.Logger.Errorw("Subdomain mismatch",
"error", "subdomain_mismatch",
"name", postData.Subdomain,
"expected", user.Subdomain)
}
} else {
a.Logger.Errorw("Update not allowed from IP",
"error", "ip_unauthorized")
}
} else {
a.Logger.Errorw("Error while trying to get user",
"error", err.Error())
}
if userOK {
// Set user info to the decoded ACMETxt object
postData.Username = user.Username
postData.Password = user.Password
// Set the ACMETxt struct to context to pull in from update function
ctx := context.WithValue(r.Context(), ACMETxtKey, postData)
update(w, r.WithContext(ctx), p)
} else {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
_, _ = w.Write(jsonError("forbidden"))
}
}
}
func (a *AcmednsAPI) getUserFromRequest(r *http.Request) (acmedns.ACMETxt, error) {
uname := r.Header.Get("X-Api-User")
passwd := r.Header.Get("X-Api-Key")
username, err := getValidUsername(uname)
if err != nil {
return acmedns.ACMETxt{}, fmt.Errorf("invalid username: %s: %w", uname, err)
}
if validKey(passwd) {
dbuser, err := a.DB.GetByUsername(username)
if err != nil {
a.Logger.Errorw("Error while trying to get user",
"error", err.Error())
// To protect against timed side channel (never gonna give you up)
acmedns.CorrectPassword(passwd, "$2a$10$8JEFVNYYhLoBysjAxe2yBuXrkDojBQBkVpXEQgyQyjn43SvJ4vL36")
return acmedns.ACMETxt{}, fmt.Errorf("invalid username: %s", uname)
}
if acmedns.CorrectPassword(passwd, dbuser.Password) {
return dbuser, nil
}
return acmedns.ACMETxt{}, fmt.Errorf("invalid password for user %s", uname)
}
return acmedns.ACMETxt{}, fmt.Errorf("invalid key for user %s", uname)
}
func (a *AcmednsAPI) updateAllowedFromIP(r *http.Request, user acmedns.ACMETxt) bool {
if a.Config.API.UseHeader {
ips := getIPListFromHeader(r.Header.Get(a.Config.API.HeaderName))
return user.AllowedFromList(ips)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
a.Logger.Errorw("Error while parsing remote address",
"error", err.Error(),
"remoteaddr", r.RemoteAddr)
host = ""
}
return user.AllowedFrom(host)
}
+40
View File
@@ -0,0 +1,40 @@
package api
import (
"context"
"github.com/mholt/acmez/v3/acme"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
// ChallengeProvider implements go-acme/lego Provider interface which is used for ACME DNS challenge handling
type ChallengeProvider struct {
servers []acmedns.AcmednsNS
}
// NewChallengeProvider creates a new instance of ChallengeProvider
func NewChallengeProvider(servers []acmedns.AcmednsNS) ChallengeProvider {
return ChallengeProvider{servers: servers}
}
// Present is used for making the ACME DNS challenge token available for DNS
func (c *ChallengeProvider) Present(ctx context.Context, challenge acme.Challenge) error {
for _, s := range c.servers {
s.SetOwnAuthKey(challenge.DNS01KeyAuthorization())
}
return nil
}
// CleanUp is called after the run to remove the ACME DNS challenge tokens from DNS records
func (c *ChallengeProvider) CleanUp(ctx context.Context, _ acme.Challenge) error {
for _, s := range c.servers {
s.SetOwnAuthKey("")
}
return nil
}
// Wait is a dummy function as we are just going to be ready to answer the challenge from the get-go
func (c *ChallengeProvider) Wait(_ context.Context, _ acme.Challenge) error {
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package api
import (
"context"
"testing"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/mholt/acmez/v3/acme"
)
type mockNameserver struct {
acmedns.AcmednsNS
authKey string
}
func (m *mockNameserver) SetOwnAuthKey(key string) {
m.authKey = key
}
func TestChallengeProvider(t *testing.T) {
mock := &mockNameserver{}
servers := []acmedns.AcmednsNS{mock}
cp := NewChallengeProvider(servers)
ctx := context.Background()
challenge := acme.Challenge{
Type: "dns-01",
Token: "test-token",
KeyAuthorization: "test-key-auth",
}
// Test Present
err := cp.Present(ctx, challenge)
if err != nil {
t.Errorf("Present failed: %v", err)
}
expectedKey := challenge.DNS01KeyAuthorization()
if mock.authKey != expectedKey {
t.Errorf("Expected auth key %s, got %s", expectedKey, mock.authKey)
}
// Test CleanUp
err = cp.CleanUp(ctx, challenge)
if err != nil {
t.Errorf("CleanUp failed: %v", err)
}
if mock.authKey != "" {
t.Errorf("Expected empty auth key after CleanUp, got %s", mock.authKey)
}
// Test Wait
err = cp.Wait(ctx, challenge)
if err != nil {
t.Errorf("Wait failed: %v", err)
}
}
+12
View File
@@ -0,0 +1,12 @@
package api
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
// Endpoint used to check the readiness and/or liveness (health) of the server.
func (a *AcmednsAPI) healthCheck(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
w.WriteHeader(http.StatusOK)
}
+76
View File
@@ -0,0 +1,76 @@
package api
import (
"encoding/json"
"fmt"
"io"
"net/http"
"github.com/julienschmidt/httprouter"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
// RegResponse is a struct for registration response JSON
type RegResponse struct {
Username string `json:"username"`
Password string `json:"password"`
Fulldomain string `json:"fulldomain"`
Subdomain string `json:"subdomain"`
Allowfrom []string `json:"allowfrom"`
}
func (a *AcmednsAPI) webRegisterPost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var regStatus int
var reg []byte
var err error
aTXT := acmedns.ACMETxt{}
bdata, _ := io.ReadAll(r.Body)
if len(bdata) > 0 {
err = json.Unmarshal(bdata, &aTXT)
if err != nil {
regStatus = http.StatusBadRequest
reg = jsonError("malformed_json_payload")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(regStatus)
_, _ = w.Write(reg)
return
}
}
// Fail with malformed CIDR mask in allowfrom
err = aTXT.AllowFrom.IsValid()
if err != nil {
regStatus = http.StatusBadRequest
reg = jsonError("invalid_allowfrom_cidr")
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(regStatus)
_, _ = w.Write(reg)
return
}
// Create new user
nu, err := a.DB.Register(aTXT.AllowFrom)
if err != nil {
errstr := fmt.Sprintf("%v", err)
reg = jsonError(errstr)
regStatus = http.StatusInternalServerError
a.Logger.Errorw("Error in registration",
"error", err.Error())
} else {
a.Logger.Debugw("Created new user",
"user", nu.Username.String())
regStruct := RegResponse{nu.Username.String(), nu.Password, nu.Subdomain + "." + a.Config.General.Domain, nu.Subdomain, nu.AllowFrom.ValidEntries()}
regStatus = http.StatusCreated
reg, err = json.Marshal(regStruct)
if err != nil {
regStatus = http.StatusInternalServerError
reg = jsonError("json_error")
a.Logger.Errorw("Could not marshal JSON",
"error", "json")
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(regStatus)
_, _ = w.Write(reg)
}
+55
View File
@@ -0,0 +1,55 @@
package api
import (
"net/http"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/julienschmidt/httprouter"
)
func (a *AcmednsAPI) webUpdatePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var updStatus int
var upd []byte
// Get user
atxt, ok := r.Context().Value(ACMETxtKey).(acmedns.ACMETxt)
if !ok {
a.Logger.Errorw("Context error",
"error", "context")
}
// NOTE: An invalid subdomain should not happen - the auth handler should
// reject POSTs with an invalid subdomain before this handler. Reject any
// invalid subdomains anyway as a matter of caution.
if !validSubdomain(atxt.Subdomain) {
a.Logger.Errorw("Bad update data",
"error", "subdomain",
"subdomain", atxt.Subdomain,
"txt", atxt.Value)
updStatus = http.StatusBadRequest
upd = jsonError("bad_subdomain")
} else if !validTXT(atxt.Value) {
a.Logger.Errorw("Bad update data",
"error", "txt",
"subdomain", atxt.Subdomain,
"txt", atxt.Value)
updStatus = http.StatusBadRequest
upd = jsonError("bad_txt")
} else if validSubdomain(atxt.Subdomain) && validTXT(atxt.Value) {
err := a.DB.Update(atxt.ACMETxtPost)
if err != nil {
a.Logger.Errorw("Error while trying to update record",
"error", err.Error())
updStatus = http.StatusInternalServerError
upd = jsonError("db_error")
} else {
a.Logger.Debugw("TXT record updated",
"subdomain", atxt.Subdomain,
"txt", atxt.Value)
updStatus = http.StatusOK
upd = []byte("{\"txt\": \"" + atxt.Value + "\"}")
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(updStatus)
_, _ = w.Write(upd)
}
+59
View File
@@ -0,0 +1,59 @@
package api
import (
"fmt"
"regexp"
"strings"
"unicode/utf8"
"github.com/google/uuid"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
func jsonError(message string) []byte {
return []byte(fmt.Sprintf("{\"error\": \"%s\"}", message))
}
func getValidUsername(u string) (uuid.UUID, error) {
uname, err := uuid.Parse(u)
if err != nil {
return uuid.UUID{}, err
}
return uname, nil
}
func validKey(k string) bool {
kn := acmedns.SanitizeString(k)
if utf8.RuneCountInString(k) == 40 && utf8.RuneCountInString(kn) == 40 {
// Correct length and all chars valid
return true
}
return false
}
func getIPListFromHeader(header string) []string {
iplist := []string{}
for _, v := range strings.Split(header, ",") {
if len(v) > 0 {
// Ignore empty values
iplist = append(iplist, strings.TrimSpace(v))
}
}
return iplist
}
func validSubdomain(s string) bool {
// URL safe base64 alphabet without padding as defined in ACME
RegExp := regexp.MustCompile("^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$")
return RegExp.MatchString(s)
}
func validTXT(s string) bool {
sn := acmedns.SanitizeString(s)
if utf8.RuneCountInString(s) == 43 && utf8.RuneCountInString(sn) == 43 {
// 43 chars is the current LE auth key size, but not limited / defined by ACME
return true
}
return false
}
@@ -1,12 +1,15 @@
package main
package api
import (
"github.com/satori/go.uuid"
"testing"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/google/uuid"
)
func TestGetValidUsername(t *testing.T) {
v1, _ := uuid.FromString("a097455b-52cc-4569-90c8-7a4b97c6eba8")
v1, _ := uuid.Parse("a097455b-52cc-4569-90c8-7a4b97c6eba8")
for i, test := range []struct {
uname string
output uuid.UUID
@@ -54,7 +57,9 @@ func TestGetValidSubdomain(t *testing.T) {
output bool
}{
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", true},
{"a-97455b-52cc-4569-90c8-7a4b97c6eba8", false},
{"a-97455b-52cc-4569-90c8-7a4b97c6eba8", true},
{"foo.example.com", false},
{"foo-example-com", true},
{"", false},
{"&!#!25123!%!'%", false},
} {
@@ -100,7 +105,7 @@ func TestCorrectPassword(t *testing.T) {
false},
{"", "", false},
} {
ret := correctPassword(test.pw, test.hash)
ret := acmedns.CorrectPassword(test.pw, test.hash)
if ret != test.output {
t.Errorf("Test %d: Expected return value %t, but got %t", i, test.output, ret)
}
@@ -109,12 +114,12 @@ func TestCorrectPassword(t *testing.T) {
func TestGetValidCIDRMasks(t *testing.T) {
for i, test := range []struct {
input cidrslice
output cidrslice
input acmedns.Cidrslice
output acmedns.Cidrslice
}{
{cidrslice{"10.0.0.1/24"}, cidrslice{"10.0.0.1/24"}},
{cidrslice{"invalid", "127.0.0.1/32"}, cidrslice{"127.0.0.1/32"}},
{cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}, cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}},
{acmedns.Cidrslice{"10.0.0.1/24"}, acmedns.Cidrslice{"10.0.0.1/24"}},
{acmedns.Cidrslice{"invalid", "127.0.0.1/32"}, acmedns.Cidrslice{"127.0.0.1/32"}},
{acmedns.Cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}, acmedns.Cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}},
} {
ret := test.input.ValidEntries()
if len(ret) == len(test.output) {
+361
View File
@@ -0,0 +1,361 @@
package database
import (
"database/sql"
"encoding/json"
"fmt"
"regexp"
"strconv"
"sync"
"time"
_ "github.com/glebarez/go-sqlite"
_ "github.com/lib/pq"
"github.com/google/uuid"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
type acmednsdb struct {
DB *sql.DB
Mutex sync.Mutex
Logger *zap.SugaredLogger
Config *acmedns.AcmeDnsConfig
}
// DBVersion shows the database version this code uses. This is used for update checks.
var DBVersion = 1
var acmeTable = `
CREATE TABLE IF NOT EXISTS acmedns(
Name TEXT,
Value TEXT
);`
var userTable = `
CREATE TABLE IF NOT EXISTS records(
Username TEXT UNIQUE NOT NULL PRIMARY KEY,
Password TEXT UNIQUE NOT NULL,
Subdomain TEXT UNIQUE NOT NULL,
AllowFrom TEXT
);`
var txtTable = `
CREATE TABLE IF NOT EXISTS txt(
Subdomain TEXT NOT NULL,
Value TEXT NOT NULL DEFAULT '',
LastUpdate INT
);`
var txtTablePG = `
CREATE TABLE IF NOT EXISTS txt(
rowid SERIAL,
Subdomain TEXT NOT NULL,
Value TEXT NOT NULL DEFAULT '',
LastUpdate INT
);`
// getSQLiteStmt replaces all PostgreSQL prepared statement placeholders (eg. $1, $2) with SQLite variant "?"
func getSQLiteStmt(s string) string {
re, _ := regexp.Compile(`\$[0-9]`)
return re.ReplaceAllString(s, "?")
}
func Init(config *acmedns.AcmeDnsConfig, logger *zap.SugaredLogger) (acmedns.AcmednsDB, error) {
var d = &acmednsdb{Config: config, Logger: logger}
d.Mutex.Lock()
defer d.Mutex.Unlock()
db, err := sql.Open(config.Database.Engine, config.Database.Connection)
if err != nil {
return d, err
}
d.DB = db
// Check version first to try to catch old versions without version string
var versionString string
_ = d.DB.QueryRow("SELECT Value FROM acmedns WHERE Name='db_version'").Scan(&versionString)
if versionString == "" {
versionString = "0"
}
_, _ = d.DB.Exec(acmeTable)
_, _ = d.DB.Exec(userTable)
if config.Database.Engine == "sqlite" {
_, _ = d.DB.Exec(txtTable)
} else {
_, _ = d.DB.Exec(txtTablePG)
}
// If everything is fine, handle db upgrade tasks
if err == nil {
err = d.checkDBUpgrades(versionString)
}
if err == nil {
if versionString == "0" {
// No errors so we should now be in version 1
insversion := fmt.Sprintf("INSERT INTO acmedns (Name, Value) values('db_version', '%d')", DBVersion)
_, err = db.Exec(insversion)
}
}
return d, err
}
func (d *acmednsdb) checkDBUpgrades(versionString string) error {
var err error
version, err := strconv.Atoi(versionString)
if err != nil {
return err
}
if version != DBVersion {
return d.handleDBUpgrades(version)
}
return nil
}
func (d *acmednsdb) handleDBUpgrades(version int) error {
if version == 0 {
return d.handleDBUpgradeTo1()
}
return nil
}
func (d *acmednsdb) handleDBUpgradeTo1() error {
var err error
var subdomains []string
rows, err := d.DB.Query("SELECT Subdomain FROM records")
if err != nil {
d.Logger.Errorw("Error in DB upgrade",
"error", err.Error())
return err
}
defer rows.Close()
for rows.Next() {
var subdomain string
err = rows.Scan(&subdomain)
if err != nil {
d.Logger.Errorw("Error in DB upgrade while reading values",
"error", err.Error())
return err
}
subdomains = append(subdomains, subdomain)
}
err = rows.Err()
if err != nil {
d.Logger.Errorw("Error in DB upgrade while inserting values",
"error", err.Error())
return err
}
tx, err := d.DB.Begin()
// Rollback if errored, commit if not
defer func() {
if err != nil {
_ = tx.Rollback()
return
}
_ = tx.Commit()
}()
_, _ = tx.Exec("DELETE FROM txt")
for _, subdomain := range subdomains {
if subdomain != "" {
// Insert two rows for each subdomain to txt table
err = d.NewTXTValuesInTransaction(tx, subdomain)
if err != nil {
d.Logger.Errorw("Error in DB upgrade while inserting values",
"error", err.Error())
return err
}
}
}
// SQLite doesn't support dropping columns
if d.Config.Database.Engine != "sqlite" {
_, _ = tx.Exec("ALTER TABLE records DROP COLUMN IF EXISTS Value")
_, _ = tx.Exec("ALTER TABLE records DROP COLUMN IF EXISTS LastActive")
}
_, err = tx.Exec("UPDATE acmedns SET Value='1' WHERE Name='db_version'")
return err
}
// NewTXTValuesInTransaction creates two rows for subdomain to the txt table
func (d *acmednsdb) NewTXTValuesInTransaction(tx *sql.Tx, subdomain string) error {
var err error
instr := fmt.Sprintf("INSERT INTO txt (Subdomain, LastUpdate) values('%s', 0)", subdomain)
_, _ = tx.Exec(instr)
_, _ = tx.Exec(instr)
return err
}
func (d *acmednsdb) Register(afrom acmedns.Cidrslice) (acmedns.ACMETxt, error) {
d.Mutex.Lock()
defer d.Mutex.Unlock()
var err error
tx, err := d.DB.Begin()
// Rollback if errored, commit if not
defer func() {
if err != nil {
_ = tx.Rollback()
return
}
_ = tx.Commit()
}()
a := acmedns.NewACMETxt()
a.AllowFrom = acmedns.Cidrslice(afrom.ValidEntries())
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.Password), 10)
regSQL := `
INSERT INTO records(
Username,
Password,
Subdomain,
AllowFrom)
values($1, $2, $3, $4)`
if d.Config.Database.Engine == "sqlite" {
regSQL = getSQLiteStmt(regSQL)
}
sm, err := tx.Prepare(regSQL)
if err != nil {
d.Logger.Errorw("Database error in prepare",
"error", err.Error())
return a, fmt.Errorf("failed to prepare registration statement: %w", err)
}
defer sm.Close()
_, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, a.AllowFrom.JSON())
if err == nil {
err = d.NewTXTValuesInTransaction(tx, a.Subdomain)
}
return a, err
}
func (d *acmednsdb) GetByUsername(u uuid.UUID) (acmedns.ACMETxt, error) {
d.Mutex.Lock()
defer d.Mutex.Unlock()
var results []acmedns.ACMETxt
getSQL := `
SELECT Username, Password, Subdomain, AllowFrom
FROM records
WHERE Username=$1 LIMIT 1
`
if d.Config.Database.Engine == "sqlite" {
getSQL = getSQLiteStmt(getSQL)
}
sm, err := d.DB.Prepare(getSQL)
if err != nil {
return acmedns.ACMETxt{}, err
}
defer sm.Close()
rows, err := sm.Query(u.String())
if err != nil {
return acmedns.ACMETxt{}, fmt.Errorf("failed to query user: %w", err)
}
defer rows.Close()
// It will only be one row though
for rows.Next() {
txt, err := d.getModelFromRow(rows)
if err != nil {
return acmedns.ACMETxt{}, err
}
results = append(results, txt)
}
if len(results) > 0 {
return results[0], nil
}
return acmedns.ACMETxt{}, fmt.Errorf("user not found: %s", u.String())
}
func (d *acmednsdb) GetTXTForDomain(domain string) ([]string, error) {
d.Mutex.Lock()
defer d.Mutex.Unlock()
domain = acmedns.SanitizeString(domain)
var txts []string
getSQL := `
SELECT Value FROM txt WHERE Subdomain=$1 LIMIT 2
`
if d.Config.Database.Engine == "sqlite" {
getSQL = getSQLiteStmt(getSQL)
}
sm, err := d.DB.Prepare(getSQL)
if err != nil {
return txts, err
}
defer sm.Close()
rows, err := sm.Query(domain)
if err != nil {
return txts, err
}
defer rows.Close()
for rows.Next() {
var rtxt string
err = rows.Scan(&rtxt)
if err != nil {
return txts, err
}
txts = append(txts, rtxt)
}
return txts, nil
}
func (d *acmednsdb) Update(a acmedns.ACMETxtPost) error {
d.Mutex.Lock()
defer d.Mutex.Unlock()
var err error
// Data in a is already sanitized
timenow := time.Now().Unix()
updSQL := `
UPDATE txt SET Value=$1, LastUpdate=$2
WHERE rowid=(
SELECT rowid FROM txt WHERE Subdomain=$3 ORDER BY LastUpdate LIMIT 1)
`
if d.Config.Database.Engine == "sqlite" {
updSQL = getSQLiteStmt(updSQL)
}
sm, err := d.DB.Prepare(updSQL)
if err != nil {
return err
}
defer sm.Close()
_, err = sm.Exec(a.Value, timenow, a.Subdomain)
if err != nil {
return err
}
return nil
}
func (d *acmednsdb) getModelFromRow(r *sql.Rows) (acmedns.ACMETxt, error) {
txt := acmedns.ACMETxt{}
afrom := ""
err := r.Scan(
&txt.Username,
&txt.Password,
&txt.Subdomain,
&afrom)
if err != nil {
d.Logger.Errorw("Row scan error",
"error", err.Error())
}
cslice := acmedns.Cidrslice{}
err = json.Unmarshal([]byte(afrom), &cslice)
if err != nil {
d.Logger.Errorw("JSON unmarshall error",
"error", err.Error())
}
txt.AllowFrom = cslice
return txt, err
}
func (d *acmednsdb) Close() {
d.DB.Close()
}
func (d *acmednsdb) GetBackend() *sql.DB {
return d.DB
}
func (d *acmednsdb) SetBackend(backend *sql.DB) {
d.DB = backend
}
+74 -71
View File
@@ -1,11 +1,15 @@
package main
package database
import (
"database/sql"
"database/sql/driver"
"errors"
"github.com/erikstmartin/go-testdb"
"testing"
"github.com/erikstmartin/go-testdb"
"go.uber.org/zap"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
type testResult struct {
@@ -21,42 +25,38 @@ func (r testResult) RowsAffected() (int64, error) {
return r.affectedRows, nil
}
func TestDBInit(t *testing.T) {
fakeDB := new(acmedb)
err := fakeDB.Init("notarealegine", "connectionstring")
if err == nil {
t.Errorf("Was expecting error, didn't get one.")
}
func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger) {
c := acmedns.AcmeDnsConfig{}
c.Database.Engine = "sqlite"
c.Database.Connection = ":memory:"
l := zap.NewNop().Sugar()
return c, l
}
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
return testResult{1, 0}, errors.New("Prepared query error")
})
defer testdb.Reset()
errorDB := new(acmedb)
err = errorDB.Init("testdb", "")
if err == nil {
t.Errorf("Was expecting DB initiation error but got none")
}
errorDB.Close()
func fakeDB() acmedns.AcmednsDB {
conf, logger := fakeConfigAndLogger()
db, _ := Init(&conf, logger)
return db
}
func TestRegisterNoCIDR(t *testing.T) {
// Register tests
_, err := DB.Register(cidrslice{})
DB := fakeDB()
_, err := DB.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Registration failed, got error [%v]", err)
}
}
func TestRegisterMany(t *testing.T) {
DB := fakeDB()
for i, test := range []struct {
input cidrslice
output cidrslice
input acmedns.Cidrslice
output acmedns.Cidrslice
}{
{cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}, cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}},
{cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, cidrslice{}},
{cidrslice{"7.6.5.4/32", "invalid", "1.0.0.1/2"}, cidrslice{"7.6.5.4/32", "1.0.0.1/2"}},
{acmedns.Cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}, acmedns.Cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}},
{acmedns.Cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, acmedns.Cidrslice{}},
{acmedns.Cidrslice{"7.6.5.4/32", "invalid", "1.0.0.1/2"}, acmedns.Cidrslice{"7.6.5.4/32", "1.0.0.1/2"}},
} {
user, err := DB.Register(test.input)
if err != nil {
@@ -67,18 +67,19 @@ func TestRegisterMany(t *testing.T) {
t.Errorf("Test %d: Got error when fetching username: [%v]", i, err)
}
if len(user.AllowFrom) != len(test.output) {
t.Errorf("Test %d: Expected to recieve struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(user.AllowFrom))
t.Errorf("Test %d: Expected to receive struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(user.AllowFrom))
}
if len(res.AllowFrom) != len(test.output) {
t.Errorf("Test %d: Expected to recieve struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(res.AllowFrom))
t.Errorf("Test %d: Expected to receive struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(res.AllowFrom))
}
}
}
func TestGetByUsername(t *testing.T) {
DB := fakeDB()
// Create reg to refer to
reg, err := DB.Register(cidrslice{})
reg, err := DB.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Registration failed, got error [%v]", err)
}
@@ -97,13 +98,14 @@ func TestGetByUsername(t *testing.T) {
}
// regUser password already is a bcrypt hash
if !correctPassword(reg.Password, regUser.Password) {
if !acmedns.CorrectPassword(reg.Password, regUser.Password) {
t.Errorf("The password [%s] does not match the hash [%s]", reg.Password, regUser.Password)
}
}
func TestPrepareErrors(t *testing.T) {
reg, _ := DB.Register(cidrslice{})
DB := fakeDB()
reg, _ := DB.Register(acmedns.Cidrslice{})
tdb, err := sql.Open("testdb", "")
if err != nil {
t.Errorf("Got error: %v", err)
@@ -118,14 +120,15 @@ func TestPrepareErrors(t *testing.T) {
t.Errorf("Expected error, but didn't get one")
}
_, err = DB.GetByDomain(reg.Subdomain)
_, err = DB.GetTXTForDomain(reg.Subdomain)
if err == nil {
t.Errorf("Expected error, but didn't get one")
}
}
func TestQueryExecErrors(t *testing.T) {
reg, _ := DB.Register(cidrslice{})
DB := fakeDB()
reg, _ := DB.Register(acmedns.Cidrslice{})
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
return testResult{1, 0}, errors.New("Prepared query error")
})
@@ -151,17 +154,17 @@ func TestQueryExecErrors(t *testing.T) {
t.Errorf("Expected error from exec, but got none")
}
_, err = DB.GetByDomain(reg.Subdomain)
_, err = DB.GetTXTForDomain(reg.Subdomain)
if err == nil {
t.Errorf("Expected error from exec in GetByDomain, but got none")
}
_, err = DB.Register(cidrslice{})
_, err = DB.Register(acmedns.Cidrslice{})
if err == nil {
t.Errorf("Expected error from exec in Register, but got none")
}
reg.Value = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
err = DB.Update(reg)
err = DB.Update(reg.ACMETxtPost)
if err == nil {
t.Errorf("Expected error from exec in Update, but got none")
}
@@ -169,7 +172,8 @@ func TestQueryExecErrors(t *testing.T) {
}
func TestQueryScanErrors(t *testing.T) {
reg, _ := DB.Register(cidrslice{})
DB := fakeDB()
reg, _ := DB.Register(acmedns.Cidrslice{})
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
return testResult{1, 0}, errors.New("Prepared query error")
@@ -195,15 +199,11 @@ func TestQueryScanErrors(t *testing.T) {
if err == nil {
t.Errorf("Expected error from scan in, but got none")
}
_, err = DB.GetByDomain(reg.Subdomain)
if err == nil {
t.Errorf("Expected error from scan in GetByDomain, but got none")
}
}
func TestBadDBValues(t *testing.T) {
reg, _ := DB.Register(cidrslice{})
DB := fakeDB()
reg, _ := DB.Register(acmedns.Cidrslice{})
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"}
@@ -226,54 +226,65 @@ func TestBadDBValues(t *testing.T) {
t.Errorf("Expected error from scan in, but got none")
}
_, err = DB.GetByDomain(reg.Subdomain)
_, err = DB.GetTXTForDomain(reg.Subdomain)
if err == nil {
t.Errorf("Expected error from scan in GetByDomain, but got none")
}
}
func TestGetByDomain(t *testing.T) {
var regDomain = ACMETxt{}
func TestGetTXTForDomain(t *testing.T) {
DB := fakeDB()
// Create reg to refer to
reg, err := DB.Register(cidrslice{})
reg, err := DB.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Registration failed, got error [%v]", err)
}
regDomainSlice, err := DB.GetByDomain(reg.Subdomain)
txtval1 := "___validation_token_received_from_the_ca___"
txtval2 := "___validation_token_received_YEAH_the_ca___"
reg.Value = txtval1
_ = DB.Update(reg.ACMETxtPost)
reg.Value = txtval2
_ = DB.Update(reg.ACMETxtPost)
regDomainSlice, err := DB.GetTXTForDomain(reg.Subdomain)
if err != nil {
t.Errorf("Could not get test user, got error [%v]", err)
}
if len(regDomainSlice) == 0 {
t.Errorf("No rows returned for GetByDomain [%s]", reg.Subdomain)
} else {
regDomain = regDomainSlice[0]
t.Errorf("No rows returned for GetTXTForDomain [%s]", reg.Subdomain)
}
if reg.Username != regDomain.Username {
t.Errorf("GetByUsername username [%q] did not match the original [%q]", regDomain.Username, reg.Username)
var val1found = false
var val2found = false
for _, v := range regDomainSlice {
if v == txtval1 {
val1found = true
}
if v == txtval2 {
val2found = true
}
}
if reg.Subdomain != regDomain.Subdomain {
t.Errorf("GetByUsername subdomain [%q] did not match the original [%q]", regDomain.Subdomain, reg.Subdomain)
if !val1found {
t.Errorf("No TXT value found for val1")
}
// regDomain password already is a bcrypt hash
if !correctPassword(reg.Password, regDomain.Password) {
t.Errorf("The password [%s] does not match the hash [%s]", reg.Password, regDomain.Password)
if !val2found {
t.Errorf("No TXT value found for val2")
}
// Not found
regNotfound, _ := DB.GetByDomain("does-not-exist")
regNotfound, _ := DB.GetTXTForDomain("does-not-exist")
if len(regNotfound) > 0 {
t.Errorf("No records should be returned.")
}
}
func TestUpdate(t *testing.T) {
DB := fakeDB()
// Create reg to refer to
reg, err := DB.Register(cidrslice{})
reg, err := DB.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Registration failed, got error [%v]", err)
}
@@ -290,16 +301,8 @@ func TestUpdate(t *testing.T) {
regUser.Password = "nevergonnagiveyouup"
regUser.Value = validTXT
err = DB.Update(regUser)
err = DB.Update(regUser.ACMETxtPost)
if err != nil {
t.Errorf("DB Update failed, got error: [%v]", err)
}
updUser, err := DB.GetByUsername(regUser.Username)
if err != nil {
t.Errorf("GetByUsername threw error [%v]", err)
}
if updUser.Value != validTXT {
t.Errorf("Update failed, fetched value [%s] does not match the update value [%s]", updUser.Value, validTXT)
}
}
+396
View File
@@ -0,0 +1,396 @@
package nameserver
import (
"database/sql"
"database/sql/driver"
"errors"
"fmt"
"sync"
"testing"
"github.com/erikstmartin/go-testdb"
"github.com/miekg/dns"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/joohoi/acme-dns/pkg/database"
)
type resolver struct {
server string
}
var records = []string{
"auth.example.org. A 192.168.1.100",
"ns1.auth.example.org. A 192.168.1.101",
"cn.example.org CNAME something.example.org.",
"!''b', unparseable ",
"ns2.auth.example.org. A 192.168.1.102",
}
func loggerHasEntryWithMessage(message string, logObserver *observer.ObservedLogs) bool {
return len(logObserver.FilterMessage(message).All()) > 0
}
func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger, *observer.ObservedLogs) {
c := acmedns.AcmeDnsConfig{}
c.Database.Engine = "sqlite"
c.Database.Connection = ":memory:"
obsCore, logObserver := observer.New(zap.DebugLevel)
obsLogger := zap.New(obsCore).Sugar()
return c, obsLogger, logObserver
}
func setupDNS() (acmedns.AcmednsNS, acmedns.AcmednsDB, *observer.ObservedLogs) {
config, logger, logObserver := fakeConfigAndLogger()
config.General.Domain = "auth.example.org"
config.General.Listen = "127.0.0.1:15353"
config.General.Proto = "udp"
config.General.Nsname = "ns1.auth.example.org"
config.General.Nsadmin = "admin.example.org"
config.General.StaticRecords = records
config.General.Debug = false
db, _ := database.Init(&config, logger)
server := Nameserver{Config: &config, DB: db, Logger: logger, personalAuthKey: ""}
server.Domains = make(map[string]Records)
server.Server = &dns.Server{Addr: config.General.Listen, Net: config.General.Proto}
server.ParseRecords()
server.OwnDomain = "auth.example.org."
return &server, db, logObserver
}
func (r *resolver) lookup(host string, qtype uint16) (*dns.Msg, error) {
msg := new(dns.Msg)
msg.Id = dns.Id()
msg.Question = make([]dns.Question, 1)
msg.Question[0] = dns.Question{Name: dns.Fqdn(host), Qtype: qtype, Qclass: dns.ClassINET}
in, err := dns.Exchange(msg, r.server)
if err != nil {
return in, fmt.Errorf("Error querying the server [%v]", err)
}
if in != nil && in.Rcode != dns.RcodeSuccess {
return in, fmt.Errorf("Received error from the server [%s]", dns.RcodeToString[in.Rcode])
}
return in, nil
}
func TestQuestionDBError(t *testing.T) {
config, logger, _ := fakeConfigAndLogger()
config.General.Listen = "127.0.0.1:15353"
config.General.Proto = "udp"
config.General.Domain = "auth.example.org"
config.General.Nsname = "ns1.auth.example.org"
config.General.Nsadmin = "admin.example.org"
config.General.StaticRecords = records
config.General.Debug = false
db, _ := database.Init(&config, logger)
server := Nameserver{Config: &config, DB: db, Logger: logger, personalAuthKey: ""}
server.Domains = make(map[string]Records)
server.ParseRecords()
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"}
return testdb.RowsFromSlice(columns, [][]driver.Value{}), errors.New("Prepared query error")
})
defer testdb.Reset()
tdb, err := sql.Open("testdb", "")
if err != nil {
t.Errorf("Got error: %v", err)
}
oldDb := db.GetBackend()
db.SetBackend(tdb)
defer db.SetBackend(oldDb)
q := dns.Question{Name: dns.Fqdn("whatever.tld"), Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
_, err = server.answerTXT(q)
if err == nil {
t.Errorf("Expected error but got none")
}
}
func TestParse(t *testing.T) {
config, logger, logObserver := fakeConfigAndLogger()
config.General.Listen = "127.0.0.1:15353"
config.General.Proto = "udp"
config.General.Domain = ")"
config.General.Nsname = "ns1.auth.example.org"
config.General.Nsadmin = "admin.example.org"
config.General.StaticRecords = records
config.General.Debug = false
config.General.StaticRecords = []string{}
db, _ := database.Init(&config, logger)
server := Nameserver{Config: &config, DB: db, Logger: logger, personalAuthKey: ""}
server.Domains = make(map[string]Records)
server.ParseRecords()
if !loggerHasEntryWithMessage("Error while adding SOA record", logObserver) {
t.Errorf("Expected SOA parsing to return error, but did not find one")
}
}
func TestResolveA(t *testing.T) {
server, _, _ := setupDNS()
errChan := make(chan error, 1)
waitLock := sync.Mutex{}
waitLock.Lock()
server.SetNotifyStartedFunc(waitLock.Unlock)
go server.Start(errChan)
waitLock.Lock()
resolv := resolver{server: "127.0.0.1:15353"}
answer, err := resolv.lookup("auth.example.org", dns.TypeA)
if err != nil {
t.Errorf("%v", err)
return
}
if len(answer.Answer) == 0 {
t.Error("No answer for DNS query")
return
}
_, err = resolv.lookup("nonexistent.domain.tld", dns.TypeA)
if err == nil {
t.Errorf("Was expecting error because of NXDOMAIN but got none")
return
}
}
func TestEDNS(t *testing.T) {
resolv := resolver{server: "127.0.0.1:15353"}
answer, _ := resolv.lookup("auth.example.org", dns.TypeOPT)
if answer.Rcode != dns.RcodeSuccess {
t.Errorf("Was expecing NOERROR rcode for OPT query, but got [%s] instead.", dns.RcodeToString[answer.Rcode])
}
}
func TestEDNSA(t *testing.T) {
msg := new(dns.Msg)
msg.Id = dns.Id()
msg.Question = make([]dns.Question, 1)
msg.Question[0] = dns.Question{Name: dns.Fqdn("auth.example.org"), Qtype: dns.TypeA, Qclass: dns.ClassINET}
// Set EDNS0 with DO=1
msg.SetEdns0(512, true)
in, err := dns.Exchange(msg, "127.0.0.1:15353")
if err != nil {
t.Errorf("Error querying the server [%v]", err)
}
if in != nil && in.Rcode != dns.RcodeSuccess {
t.Errorf("Received error from the server [%s]", dns.RcodeToString[in.Rcode])
}
opt := in.IsEdns0()
if opt == nil {
t.Errorf("Should have got OPT back")
}
}
func TestEDNSBADVERS(t *testing.T) {
msg := new(dns.Msg)
msg.Id = dns.Id()
msg.Question = make([]dns.Question, 1)
msg.Question[0] = dns.Question{Name: dns.Fqdn("auth.example.org"), Qtype: dns.TypeA, Qclass: dns.ClassINET}
// Set EDNS0 with version 1
o := new(dns.OPT)
o.SetVersion(1)
o.Hdr.Name = "."
o.Hdr.Rrtype = dns.TypeOPT
msg.Extra = append(msg.Extra, o)
in, err := dns.Exchange(msg, "127.0.0.1:15353")
if err != nil {
t.Errorf("Error querying the server [%v]", err)
}
if in != nil && in.Rcode != dns.RcodeBadVers {
t.Errorf("Received unexpected rcode from the server [%s]", dns.RcodeToString[in.Rcode])
}
}
func TestResolveCNAME(t *testing.T) {
resolv := resolver{server: "127.0.0.1:15353"}
expected := "cn.example.org. 3600 IN CNAME something.example.org."
answer, err := resolv.lookup("cn.example.org", dns.TypeCNAME)
if err != nil {
t.Errorf("Got unexpected error: %s", err)
}
if len(answer.Answer) != 1 {
t.Errorf("Expected exactly 1 RR in answer, but got %d instead.", len(answer.Answer))
}
if answer.Answer[0].Header().Rrtype != dns.TypeCNAME {
t.Errorf("Expected a CNAME answer, but got [%s] instead.", dns.TypeToString[answer.Answer[0].Header().Rrtype])
}
if answer.Answer[0].String() != expected {
t.Errorf("Expected CNAME answer [%s] but got [%s] instead.", expected, answer.Answer[0].String())
}
}
func TestAuthoritative(t *testing.T) {
resolv := resolver{server: "127.0.0.1:15353"}
answer, _ := resolv.lookup("nonexistent.auth.example.org", dns.TypeA)
if answer.Rcode != dns.RcodeNameError {
t.Errorf("Was expecing NXDOMAIN rcode, but got [%s] instead.", dns.RcodeToString[answer.Rcode])
}
if len(answer.Ns) != 1 {
t.Errorf("Was expecting exactly one answer (SOA) for invalid subdomain, but got %d", len(answer.Ns))
}
if answer.Ns[0].Header().Rrtype != dns.TypeSOA {
t.Errorf("Was expecting SOA record as answer for NXDOMAIN but got [%s]", dns.TypeToString[answer.Ns[0].Header().Rrtype])
}
if !answer.Authoritative {
t.Errorf("Was expecting authoritative bit to be set")
}
nanswer, _ := resolv.lookup("nonexsitent.nonauth.tld", dns.TypeA)
if len(nanswer.Answer) > 0 {
t.Errorf("Didn't expect answers for non authotitative domain query")
}
if nanswer.Authoritative {
t.Errorf("Authoritative bit should not be set for non-authoritative domain.")
}
}
func TestResolveTXT(t *testing.T) {
iServer, db, _ := setupDNS()
server := iServer.(*Nameserver)
var validTXT string
// acme-dns validation in pkg/api/util.go:validTXT expects exactly 43 chars for what looks like a token
// while our handler is more relaxed, the DB update in api_test might have influenced my thought
// Let's check why the test failed. Ah, "Received error from the server [REFUSED]"? No, "NXDOMAIN"?
// Wait, the failure was: "Test 0: Expected answer but got: Received error from the server [SERVFAIL]"
// Or was it? The log was truncated.
// Actually, the registration atxt.Value is NOT used for Update, it uses ACMETxtPost.
// ACMETxtPost.Value needs to be valid.
atxt, err := db.Register(acmedns.Cidrslice{})
if err != nil {
t.Errorf("Could not initiate db record: [%v]", err)
return
}
update := acmedns.ACMETxtPost{
Subdomain: atxt.Subdomain,
Value: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", // 43 chars
}
validTXT = update.Value
err = db.Update(update)
if err != nil {
t.Errorf("Could not update db record: [%v]", err)
return
}
for i, test := range []struct {
subDomain string
expTXT string
getAnswer bool
validAnswer bool
}{
{atxt.Subdomain, validTXT, true, true},
{atxt.Subdomain, "invalid", true, false},
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", validTXT, false, false},
} {
q := dns.Question{Name: dns.Fqdn(test.subDomain + ".auth.example.org"), Qtype: dns.TypeTXT, Qclass: dns.ClassINET}
ansRRs, rcode, _, err := server.answer(q)
if err != nil {
if test.getAnswer {
t.Fatalf("Test %d: Expected answer but got: %v", i, err)
}
}
if len(ansRRs) > 0 {
if !test.getAnswer && rcode == dns.RcodeNameError {
t.Errorf("Test %d: Expected no answer, but got: [%v]", i, ansRRs)
}
if test.getAnswer {
err = hasExpectedTXTAnswer(ansRRs, test.expTXT)
if err != nil {
if test.validAnswer {
t.Errorf("Test %d: %v", i, err)
}
} else {
if !test.validAnswer {
t.Errorf("Test %d: Answer was not expected to be valid, answer [%q], compared to [%s]", i, ansRRs, test.expTXT)
}
}
}
} else {
if test.getAnswer {
t.Errorf("Test %d: Expected answer, but didn't get one", i)
}
}
}
}
func hasExpectedTXTAnswer(answer []dns.RR, cmpTXT string) error {
for _, record := range answer {
// We expect only one answer, so no need to loop through the answer slice
if rec, ok := record.(*dns.TXT); ok {
for _, txtValue := range rec.Txt {
if txtValue == cmpTXT {
return nil
}
}
} else {
errmsg := fmt.Sprintf("Got answer of unexpected type [%q]", answer[0])
return errors.New(errmsg)
}
}
return errors.New("Expected answer not found")
}
func TestAnswerTXTError(t *testing.T) {
config, logger, _ := fakeConfigAndLogger()
db, _ := database.Init(&config, logger)
server := Nameserver{Config: &config, DB: db, Logger: logger}
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
return testdb.RowsFromSlice([]string{}, [][]driver.Value{}), errors.New("DB error")
})
defer testdb.Reset()
tdb, _ := sql.Open("testdb", "")
oldDb := db.GetBackend()
db.SetBackend(tdb)
defer db.SetBackend(oldDb)
q := dns.Question{Name: "whatever.auth.example.org.", Qtype: dns.TypeTXT}
_, err := server.answerTXT(q)
if err == nil {
t.Errorf("Expected error from answerTXT when DB fails, got nil")
}
}
func TestAnswerNameError(t *testing.T) {
iServer, _, _ := setupDNS()
server := iServer.(*Nameserver)
q := dns.Question{Name: "notauth.com.", Qtype: dns.TypeA}
_, rcode, auth, _ := server.answer(q)
if rcode != dns.RcodeNameError {
t.Errorf("Expected NXDOMAIN for non-authoritative domain, got %s", dns.RcodeToString[rcode])
}
if auth {
t.Errorf("Expected auth bit to be false for non-authoritative domain")
}
}
func TestCaseInsensitiveResolveA(t *testing.T) {
resolv := resolver{server: "127.0.0.1:15353"}
answer, err := resolv.lookup("aUtH.eXAmpLe.org", dns.TypeA)
if err != nil {
t.Errorf("%v", err)
}
if len(answer.Answer) == 0 {
t.Error("No answer for DNS query")
}
}
func TestCaseInsensitiveResolveSOA(t *testing.T) {
resolv := resolver{server: "127.0.0.1:15353"}
answer, _ := resolv.lookup("doesnotexist.aUtH.eXAmpLe.org", dns.TypeSOA)
if answer.Rcode != dns.RcodeNameError {
t.Errorf("Was expecing NXDOMAIN rcode, but got [%s] instead.", dns.RcodeToString[answer.Rcode])
}
if len(answer.Ns) == 0 {
t.Error("No SOA answer for DNS query")
}
}
+160
View File
@@ -0,0 +1,160 @@
package nameserver
import (
"fmt"
"strings"
"github.com/miekg/dns"
)
func (n *Nameserver) handleRequest(w dns.ResponseWriter, r *dns.Msg) {
m := new(dns.Msg)
m.SetReply(r)
// handle edns0
opt := r.IsEdns0()
if opt != nil {
if opt.Version() != 0 {
// Only EDNS0 is standardized
m.Rcode = dns.RcodeBadVers
m.SetEdns0(512, false)
} else {
// We can safely do this as we know that we're not setting other OPT RRs within acme-dns.
m.SetEdns0(512, false)
if r.Opcode == dns.OpcodeQuery {
n.readQuery(m)
}
}
} else {
if r.Opcode == dns.OpcodeQuery {
n.readQuery(m)
}
}
_ = w.WriteMsg(m)
}
func (n *Nameserver) readQuery(m *dns.Msg) {
var authoritative = false
for _, que := range m.Question {
if rr, rc, auth, err := n.answer(que); err == nil {
if auth {
authoritative = auth
}
m.Rcode = rc
m.Answer = append(m.Answer, rr...)
}
}
m.Authoritative = authoritative
if authoritative {
if m.Rcode == dns.RcodeNameError {
m.Ns = append(m.Ns, n.SOA)
}
}
}
func (n *Nameserver) answer(q dns.Question) ([]dns.RR, int, bool, error) {
var rcode int
var err error
var txtRRs []dns.RR
loweredName := strings.ToLower(q.Name)
var authoritative = n.isAuthoritative(loweredName)
if !n.isOwnChallenge(loweredName) && !n.answeringForDomain(loweredName) {
rcode = dns.RcodeNameError
}
r, _ := n.getRecord(loweredName, q.Qtype)
if q.Qtype == dns.TypeTXT {
if n.isOwnChallenge(loweredName) {
txtRRs, err = n.answerOwnChallenge(q)
} else {
txtRRs, err = n.answerTXT(q)
}
if err == nil {
r = append(r, txtRRs...)
}
}
if len(r) > 0 {
// Make sure that we return NOERROR if there were dynamic records for the domain
rcode = dns.RcodeSuccess
}
n.Logger.Debugw("Answering question for domain",
"qtype", dns.TypeToString[q.Qtype],
"domain", q.Name,
"rcode", dns.RcodeToString[rcode])
return r, rcode, authoritative, nil
}
func (n *Nameserver) answerTXT(q dns.Question) ([]dns.RR, error) {
var ra []dns.RR
subdomain := sanitizeDomainQuestion(q.Name)
atxt, err := n.DB.GetTXTForDomain(subdomain)
if err != nil {
n.Logger.Errorw("Error while trying to get record",
"error", err.Error())
return ra, err
}
for _, v := range atxt {
if len(v) > 0 {
r := new(dns.TXT)
r.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}
r.Txt = append(r.Txt, v)
ra = append(ra, r)
}
}
return ra, nil
}
func (n *Nameserver) isAuthoritative(name string) bool {
if n.answeringForDomain(name) {
return true
}
off := 0
for {
i, next := dns.NextLabel(name, off)
if next {
return false
}
off = i
if n.answeringForDomain(name[off:]) {
return true
}
}
}
func (n *Nameserver) isOwnChallenge(name string) bool {
if strings.HasPrefix(name, "_acme-challenge.") {
domain := name[16:]
if domain == n.OwnDomain {
return true
}
}
return false
}
// answeringForDomain checks if we have any records for a domain
func (n *Nameserver) answeringForDomain(name string) bool {
if n.OwnDomain == name {
return true
}
_, ok := n.Domains[name]
return ok
}
func (n *Nameserver) getRecord(name string, qtype uint16) ([]dns.RR, error) {
var rr []dns.RR
var cnames []dns.RR
domain, ok := n.Domains[name]
if !ok {
return rr, fmt.Errorf("no records for domain %s", name)
}
for _, ri := range domain.Records {
if ri.Header().Rrtype == qtype {
rr = append(rr, ri)
}
if ri.Header().Rrtype == dns.TypeCNAME {
cnames = append(cnames, ri)
}
}
if len(rr) == 0 {
return cnames, nil
}
return rr, nil
}
+150
View File
@@ -0,0 +1,150 @@
package nameserver
import (
"testing"
"github.com/miekg/dns"
)
func TestNameserver_isOwnChallenge(t *testing.T) {
type fields struct {
OwnDomain string
}
type args struct {
name string
}
tests := []struct {
name string
fields fields
args args
want bool
}{
{
name: "is own challenge",
fields: fields{
OwnDomain: "some-domain.test.",
},
args: args{
name: "_acme-challenge.some-domain.test.",
},
want: true,
},
{
name: "challenge but not for us",
fields: fields{
OwnDomain: "some-domain.test.",
},
args: args{
name: "_acme-challenge.some-other-domain.test.",
},
want: false,
},
{
name: "not a challenge",
fields: fields{
OwnDomain: "domain.test.",
},
args: args{
name: "domain.test.",
},
want: false,
},
{
name: "other request challenge",
fields: fields{
OwnDomain: "domain.test.",
},
args: args{
name: "my-domain.test.",
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n := &Nameserver{
OwnDomain: tt.fields.OwnDomain,
}
if got := n.isOwnChallenge(tt.args.name); got != tt.want {
t.Errorf("isOwnChallenge() = %v, want %v", got, tt.want)
}
})
}
}
func TestNameserver_isAuthoritative(t *testing.T) {
type fields struct {
OwnDomain string
Domains map[string]Records
}
type args struct {
q dns.Question
}
tests := []struct {
name string
fields fields
args args
want bool
}{
{
name: "is authoritative own domain",
fields: fields{
OwnDomain: "auth.domain.",
},
args: args{
q: dns.Question{Name: "auth.domain."},
},
want: true,
},
{
name: "is authoritative other domain",
fields: fields{
OwnDomain: "auth.domain.",
Domains: map[string]Records{
"other-domain.test.": {Records: nil},
},
},
args: args{
q: dns.Question{Name: "other-domain.test."},
},
want: true,
},
{
name: "is authoritative sub domain",
fields: fields{
OwnDomain: "auth.domain.",
Domains: map[string]Records{
"other-domain.test.": {Records: nil},
},
},
args: args{
q: dns.Question{Name: "sub.auth.domain."},
},
want: true,
},
{
name: "is not authoritative own",
fields: fields{
OwnDomain: "auth.domain.",
Domains: map[string]Records{
"other-domain.test.": {Records: nil},
},
},
args: args{
q: dns.Question{Name: "special-auth.domain."},
},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n := &Nameserver{
OwnDomain: tt.fields.OwnDomain,
Domains: tt.fields.Domains,
}
if got := n.isAuthoritative(tt.args.q.Name); got != tt.want {
t.Errorf("isAuthoritative() = %v, want %v", got, tt.want)
}
})
}
}
+106
View File
@@ -0,0 +1,106 @@
package nameserver
import (
"fmt"
"strings"
"sync"
"github.com/miekg/dns"
"go.uber.org/zap"
"github.com/joohoi/acme-dns/pkg/acmedns"
)
// Records is a slice of ResourceRecords
type Records struct {
Records []dns.RR
}
type Nameserver struct {
Config *acmedns.AcmeDnsConfig
DB acmedns.AcmednsDB
Logger *zap.SugaredLogger
Server *dns.Server
OwnDomain string
NotifyStartedFunc func()
SOA dns.RR
mu sync.RWMutex
personalAuthKey string
Domains map[string]Records
errChan chan error
}
func InitAndStart(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.SugaredLogger, errChan chan error) []acmedns.AcmednsNS {
dnsservers := make([]acmedns.AcmednsNS, 0)
waitLock := sync.Mutex{}
if strings.HasPrefix(config.General.Proto, "both") {
// Handle the case where DNS server should be started for both udp and tcp
udpProto := "udp"
tcpProto := "tcp"
if strings.HasSuffix(config.General.Proto, "4") {
udpProto += "4"
tcpProto += "4"
} else if strings.HasSuffix(config.General.Proto, "6") {
udpProto += "6"
tcpProto += "6"
}
dnsServerUDP := NewDNSServer(config, db, logger, udpProto)
dnsservers = append(dnsservers, dnsServerUDP)
dnsServerUDP.ParseRecords()
dnsServerTCP := NewDNSServer(config, db, logger, tcpProto)
dnsservers = append(dnsservers, dnsServerTCP)
dnsServerTCP.ParseRecords()
// wait for the server to get started to proceed
waitLock.Lock()
dnsServerUDP.SetNotifyStartedFunc(waitLock.Unlock)
go dnsServerUDP.Start(errChan)
waitLock.Lock()
dnsServerTCP.SetNotifyStartedFunc(waitLock.Unlock)
go dnsServerTCP.Start(errChan)
waitLock.Lock()
} else {
dnsServer := NewDNSServer(config, db, logger, config.General.Proto)
dnsservers = append(dnsservers, dnsServer)
dnsServer.ParseRecords()
waitLock.Lock()
dnsServer.SetNotifyStartedFunc(waitLock.Unlock)
go dnsServer.Start(errChan)
waitLock.Lock()
}
return dnsservers
}
// NewDNSServer parses the DNS records from config and returns a new DNSServer struct
func NewDNSServer(config *acmedns.AcmeDnsConfig, db acmedns.AcmednsDB, logger *zap.SugaredLogger, proto string) acmedns.AcmednsNS {
// dnsServerTCP := NewDNSServer(DB, Config.General.Listen, tcpProto, Config.General.Domain)
server := Nameserver{Config: config, DB: db, Logger: logger}
server.Server = &dns.Server{Addr: config.General.Listen, Net: proto}
domain := config.General.Domain
if !strings.HasSuffix(domain, ".") {
domain = domain + "."
}
server.OwnDomain = strings.ToLower(domain)
server.personalAuthKey = ""
server.Domains = make(map[string]Records)
return &server
}
func (n *Nameserver) Start(errorChannel chan error) {
n.errChan = errorChannel
dns.HandleFunc(".", n.handleRequest)
n.Logger.Infow("Starting DNS listener",
"addr", n.Server.Addr,
"proto", n.Server.Net)
if n.NotifyStartedFunc != nil {
n.Server.NotifyStartedFunc = n.NotifyStartedFunc
}
err := n.Server.ListenAndServe()
if err != nil {
errorChannel <- fmt.Errorf("DNS server %s failed: %w", n.Server.Net, err)
}
}
func (n *Nameserver) SetNotifyStartedFunc(fun func()) {
n.Server.NotifyStartedFunc = fun
}
+52
View File
@@ -0,0 +1,52 @@
package nameserver
import (
"fmt"
"strings"
"time"
"github.com/miekg/dns"
)
// ParseRecords parses a slice of DNS record string
func (n *Nameserver) ParseRecords() {
for _, v := range n.Config.General.StaticRecords {
rr, err := dns.NewRR(strings.ToLower(v))
if err != nil {
n.Logger.Errorw("Could not parse RR from config",
"error", err.Error(),
"rr", v)
continue
}
// Add parsed RR
n.appendRR(rr)
}
// Create serial
serial := time.Now().Format("2006010215")
// Add SOA
SOAstring := fmt.Sprintf("%s. SOA %s. %s. %s 28800 7200 604800 86400", strings.ToLower(n.Config.General.Domain), strings.ToLower(n.Config.General.Nsname), strings.ToLower(n.Config.General.Nsadmin), serial)
soarr, err := dns.NewRR(SOAstring)
if err != nil {
n.Logger.Errorw("Error while adding SOA record",
"error", err.Error(),
"soa", SOAstring)
} else {
n.appendRR(soarr)
n.SOA = soarr
}
}
func (n *Nameserver) appendRR(rr dns.RR) {
addDomain := rr.Header().Name
_, ok := n.Domains[addDomain]
if !ok {
n.Domains[addDomain] = Records{[]dns.RR{rr}}
} else {
drecs := n.Domains[addDomain]
drecs.Records = append(drecs.Records, rr)
n.Domains[addDomain] = drecs
}
n.Logger.Debugw("Adding new record to domain",
"recordtype", dns.TypeToString[rr.Header().Rrtype],
"domain", addDomain)
}
+12
View File
@@ -0,0 +1,12 @@
package nameserver
import "strings"
func sanitizeDomainQuestion(d string) string {
dom := strings.ToLower(d)
firstDot := strings.Index(d, ".")
if firstDot > 0 {
dom = dom[0:firstDot]
}
return dom
}
+20
View File
@@ -0,0 +1,20 @@
package nameserver
import "github.com/miekg/dns"
// SetOwnAuthKey sets the ACME challenge token for completing dns validation for acme-dns server itself
func (n *Nameserver) SetOwnAuthKey(key string) {
n.mu.Lock()
defer n.mu.Unlock()
n.personalAuthKey = key
}
// answerOwnChallenge answers to ACME challenge for acme-dns own certificate
func (n *Nameserver) answerOwnChallenge(q dns.Question) ([]dns.RR, error) {
n.mu.RLock()
defer n.mu.RUnlock()
r := new(dns.TXT)
r.Hdr = dns.RR_Header{Name: q.Name, Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1}
r.Txt = append(r.Txt, n.personalAuthKey)
return []dns.RR{r}, nil
}
+65
View File
@@ -0,0 +1,65 @@
package nameserver
import (
"reflect"
"testing"
"github.com/miekg/dns"
)
func TestNameserver_answerOwnChallenge(t *testing.T) {
type fields struct {
personalAuthKey string
}
type args struct {
q dns.Question
}
tests := []struct {
name string
fields fields
args args
want []dns.RR
wantErr bool
}{
{
name: "answer own challenge",
fields: fields{
personalAuthKey: "some key text",
},
args: args{
q: dns.Question{
Name: "something",
Qtype: 0,
Qclass: 0,
},
},
want: []dns.RR{
&dns.TXT{
Hdr: dns.RR_Header{Name: "something", Rrtype: dns.TypeTXT, Class: dns.ClassINET, Ttl: 1},
Txt: []string{"some key text"},
},
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
n := &Nameserver{}
n.SetOwnAuthKey(tt.fields.personalAuthKey)
if n.personalAuthKey != tt.fields.personalAuthKey {
t.Errorf("failed to set personal auth key: got = %s, want %s", n.personalAuthKey, tt.fields.personalAuthKey)
return
}
got, err := n.answerOwnChallenge(tt.args.q)
if (err != nil) != tt.wantErr {
t.Errorf("answerOwnChallenge() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("answerOwnChallenge() got = %v, want %v", got, tt.want)
}
})
}
}
Executable
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
# go test doesn't play well with noexec /tmp
sudo mkdir /gotmp
sudo mount tmpfs -t tmpfs /gotmp
TMPDIR=/gotmp go test -v -race
sudo umount /gotmp
sudo rm -rf /gotmp
+37
View File
@@ -0,0 +1,37 @@
# acme-dns E2E Testing Suite
This directory contains the end-to-end (E2E) testing suite for `acme-dns`. The suite runs in a containerized environment to ensure a consistent and isolated test execution.
## Overview
The E2E suite consists of:
- A Dockerized `acme-dns` server.
- A Python-based `tester` container that performs API and DNS operations.
- A GitHub Actions workflow for CI/CD integration.
## Prerequisites
- [Docker](https://www.docker.com/get-started)
- [Docker Compose](https://docs.docker.com/compose/install/)
## Running Locally
To run the full E2E suite locally, execute the following command from the root of the repository:
```bash
docker compose -f test/e2e/docker-compose.yml up --build --abort-on-container-exit
```
The `tester` container will return an exit code of `0` on success and `1` on failure.
## Test Flow
The `tester.py` script follows these steps:
1. **Wait for Ready**: Polls the `/health` endpoint until the API is available.
2. **Account Registration**: Registers a NEW account at `/register`.
3. **TXT Update**: Performs TWO sequential updates to the TXT records of the newly created subdomain.
4. **DNS Verification**: Directly queries the `acme-dns` server (on port 53) used in the test to verify that the TXT records have been correctly updated and are resolvable.
## CI/CD integration
The tests are automatically run on every push and pull request to the `master` branch via GitHub Actions. The workflow configuration can be found in `.github/workflows/e2e.yml`.
+16
View File
@@ -0,0 +1,16 @@
FROM golang:alpine AS builder
RUN apk add --update gcc musl-dev git
WORKDIR /app
COPY . .
RUN CGO_ENABLED=1 go build -o acme-dns
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/acme-dns .
COPY --from=builder /app/config.cfg /etc/acme-dns/config.cfg
RUN mkdir -p /var/lib/acme-dns
RUN apk --no-cache add ca-certificates && update-ca-certificates
VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"]
ENTRYPOINT ["./acme-dns", "-c", "/etc/acme-dns/config.cfg"]
EXPOSE 53 80 443
EXPOSE 53/udp
+28
View File
@@ -0,0 +1,28 @@
[general]
listen = "0.0.0.0:53"
protocol = "both"
domain = "auth.example.org"
nsname = "auth.example.org"
nsadmin = "admin.example.org"
records = [
"auth.example.org. A 127.0.0.1",
"auth.example.org. NS auth.example.org.",
]
debug = true
[database]
engine = "sqlite"
connection = "/var/lib/acme-dns/acme-dns.db"
[api]
ip = "0.0.0.0"
disable_registration = false
port = "80"
tls = "none"
corsorigins = ["*"]
use_header = false
[logconfig]
loglevel = "debug"
logtype = "stdout"
logformat = "text"
+30
View File
@@ -0,0 +1,30 @@
version: '3'
services:
acme-dns:
build:
context: ../../
dockerfile: test/e2e/Dockerfile.e2e
ports:
- "15353:53/udp"
- "18080:80"
- "18443:443"
volumes:
- ./config.cfg:/etc/acme-dns/config.cfg
- e2e-data:/var/lib/acme-dns
tester:
image: python:3.9-slim
depends_on:
- acme-dns
volumes:
- .:/test
working_dir: /test
command: >
sh -c "pip install -r requirements.txt && python tester.py"
environment:
- ACMEDNS_URL=http://acme-dns:80
- DNS_SERVER=acme-dns
- DNS_PORT=53
volumes:
e2e-data:
+2
View File
@@ -0,0 +1,2 @@
requests
dnspython
+105
View File
@@ -0,0 +1,105 @@
import requests
import dns.resolver
import os
import time
import sys
import socket
ACMEDNS_URL = os.environ.get("ACMEDNS_URL", "http://localhost:80")
DNS_SERVER = os.environ.get("DNS_SERVER", "localhost")
DNS_PORT = int(os.environ.get("DNS_PORT", 53))
def wait_for_server():
print(f"Waiting for acme-dns at {ACMEDNS_URL}...")
for i in range(30):
try:
resp = requests.get(f"{ACMEDNS_URL}/health")
if resp.status_code == 200:
print("Server is up!")
return True
except:
pass
time.sleep(1)
return False
def test_flow():
# 1. Register account
print("Registering account...")
resp = requests.post(f"{ACMEDNS_URL}/register")
if resp.status_code != 201:
print(f"Failed to register: {resp.status_code} {resp.text}")
return False
account = resp.json()
username = account['username']
api_key = account['password']
subdomain = account['subdomain']
fulldomain = account['fulldomain']
print(f"Registered subdomain: {subdomain}")
# 2. Update TXT records
headers = {
"X-Api-User": username,
"X-Api-Key": api_key
}
txt_values = ["secret_token_1", "secret_token_2"]
for val in txt_values:
print(f"Updating TXT record with value: {val}")
# Let's Encrypt uses 43 char tokens usually, but our validation is flexible now (or we use a dummy one)
# Actually our current validation in pkg/api/util.go still expects 43 chars if I recall correctly
# Let's use 43 chars just in case
dummy_val = val.ljust(43, '_')[:43]
payload = {
"subdomain": subdomain,
"txt": dummy_val
}
resp = requests.post(f"{ACMEDNS_URL}/update", headers=headers, json=payload)
if resp.status_code != 200:
print(f"Failed to update: {resp.status_code} {resp.text}")
return False
print("Updates successful. Waiting for DNS propagation (local cache)...")
time.sleep(2)
# 3. Verify DNS resolution
print(f"Resolving TXT records for {fulldomain}...")
# Resolve hostname to IP if needed
try:
dns_server_ip = socket.gethostbyname(DNS_SERVER)
except:
dns_server_ip = DNS_SERVER
resolver = dns.resolver.Resolver()
resolver.nameservers = [dns_server_ip]
resolver.port = DNS_PORT
try:
answers = resolver.resolve(fulldomain, "TXT")
resolved_values = [str(rdata).strip('"') for rdata in answers]
print(f"Resolved values: {resolved_values}")
# Check if both are present
for val in txt_values:
dummy_val = val.ljust(43, '_')[:43]
if dummy_val not in resolved_values:
print(f"Expected value {dummy_val} not found in resolved values")
return False
except Exception as e:
print(f"DNS resolution failed: {e}")
return False
print("E2E Test Passed Successfully!")
return True
if __name__ == "__main__":
if not wait_for_server():
print("Server timed out.")
sys.exit(1)
if not test_flow():
sys.exit(1)
sys.exit(0)
-87
View File
@@ -1,87 +0,0 @@
package main
import (
"database/sql"
"github.com/miekg/dns"
"github.com/satori/go.uuid"
"sync"
)
// DNSConf is global configuration struct
var DNSConf DNSConfig
// DB is used to access the database functions in acme-dns
var DB database
// RR holds the static DNS records
var RR Records
// Records is for static records
type Records struct {
Records map[uint16]map[string][]dns.RR
}
// DNSConfig holds the config structure
type DNSConfig struct {
General general
Database dbsettings
API httpapi
Logconfig logconfig
}
// Auth middleware
type authMiddleware struct{}
// Config file general section
type general struct {
Listen string
Proto string `toml:"protocol"`
Domain string
Nsname string
Nsadmin string
Debug bool
StaticRecords []string `toml:"records"`
}
type dbsettings struct {
Engine string
Connection string
}
// API config
type httpapi struct {
Domain string `toml:"api_domain"`
Port string
TLS string
TLSCertPrivkey string `toml:"tls_cert_privkey"`
TLSCertFullchain string `toml:"tls_cert_fullchain"`
CorsOrigins []string
UseHeader bool `toml:"use_header"`
HeaderName string `toml:"header_name"`
}
// Logging config
type logconfig struct {
Level string `toml:"loglevel"`
Logtype string `toml:"logtype"`
File string `toml:"logfile"`
Format string `toml:"logformat"`
}
type acmedb struct {
sync.Mutex
DB *sql.DB
}
type database interface {
Init(string, string) error
Register(cidrslice) (ACMETxt, error)
GetByUsername(uuid.UUID) (ACMETxt, error)
GetByDomain(string) ([]ACMETxt, error)
Update(ACMETxt) error
GetBackend() *sql.DB
SetBackend(*sql.DB)
Close()
Lock()
Unlock()
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !windows
package main
import "syscall"
func setUmask() {
syscall.Umask(0077)
}
+7
View File
@@ -0,0 +1,7 @@
//go:build windows
package main
func setUmask() {
// umask is not supported on Windows
}
-82
View File
@@ -1,82 +0,0 @@
package main
import (
"crypto/rand"
"math/big"
"regexp"
"strings"
"github.com/BurntSushi/toml"
log "github.com/Sirupsen/logrus"
"github.com/miekg/dns"
)
func readConfig(fname string) DNSConfig {
var conf DNSConfig
// Practically never errors
_, _ = toml.DecodeFile(fname, &conf)
return conf
}
func sanitizeString(s string) string {
// URL safe base64 alphabet without padding as defined in ACME
re, _ := regexp.Compile("[^A-Za-z\\-\\_0-9]+")
return re.ReplaceAllString(s, "")
}
func generatePassword(length int) string {
ret := make([]byte, length)
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz1234567890-_"
alphalen := big.NewInt(int64(len(alphabet)))
for i := 0; i < length; i++ {
c, _ := rand.Int(rand.Reader, alphalen)
r := int(c.Int64())
ret[i] = alphabet[r]
}
return string(ret)
}
func sanitizeDomainQuestion(d string) string {
dom := strings.ToLower(d)
firstDot := strings.Index(d, ".")
if firstDot > 0 {
dom = dom[0:firstDot]
}
return dom
}
func setupLogging(format string, level string) {
if format == "json" {
log.SetFormatter(&log.JSONFormatter{})
}
switch level {
default:
log.SetLevel(log.WarnLevel)
case "debug":
log.SetLevel(log.DebugLevel)
case "info":
log.SetLevel(log.InfoLevel)
case "error":
log.SetLevel(log.ErrorLevel)
}
// TODO: file logging
}
func startDNS(listen string, proto string) *dns.Server {
// DNS server part
dns.HandleFunc(".", handleRequest)
server := &dns.Server{Addr: listen, Net: proto}
go server.ListenAndServe()
return server
}
func getIPListFromHeader(header string) []string {
iplist := []string{}
for _, v := range strings.Split(header, ",") {
if len(v) > 0 {
// Ignore empty values
iplist = append(iplist, strings.TrimSpace(v))
}
}
return iplist
}
-97
View File
@@ -1,97 +0,0 @@
package main
import (
log "github.com/Sirupsen/logrus"
"io/ioutil"
"os"
"testing"
)
func TestSetupLogging(t *testing.T) {
for i, test := range []struct {
format string
level string
expected string
}{
{"text", "warning", "warning"},
{"json", "debug", "debug"},
{"text", "info", "info"},
{"json", "error", "error"},
{"text", "something", "warning"},
} {
setupLogging(test.format, test.level)
if log.GetLevel().String() != test.expected {
t.Errorf("Test %d: Expected loglevel %s but got %s", i, test.expected, log.GetLevel().String())
}
}
}
func TestReadConfig(t *testing.T) {
for i, test := range []struct {
inFile []byte
output DNSConfig
}{
{
[]byte("[general]\nlisten = \":53\"\ndebug = true\n[api]\napi_domain = \"something.strange\""),
DNSConfig{
General: general{
Listen: ":53",
Debug: true,
},
API: httpapi{
Domain: "something.strange",
},
},
},
{
[]byte("[\x00[[[[[[[[[de\nlisten =]"),
DNSConfig{},
},
} {
tmpfile, err := ioutil.TempFile("", "acmedns")
if err != nil {
t.Error("Could not create temporary file")
}
defer os.Remove(tmpfile.Name())
if _, err := tmpfile.Write(test.inFile); err != nil {
t.Error("Could not write to temporary file")
}
if err := tmpfile.Close(); err != nil {
t.Error("Could not close temporary file")
}
ret := readConfig(tmpfile.Name())
if ret.General.Listen != test.output.General.Listen {
t.Errorf("Test %d: Expected listen value %s, but got %s", i, test.output.General.Listen, ret.General.Listen)
}
if ret.API.Domain != test.output.API.Domain {
t.Errorf("Test %d: Expected HTTP API domain %s, but got %s", i, test.output.API.Domain, ret.API.Domain)
}
}
}
func TestGetIPListFromHeader(t *testing.T) {
for i, test := range []struct {
input string
output []string
}{
{"1.1.1.1, 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
{" 1.1.1.1 , 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
{",1.1.1.1 ,2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
} {
res := getIPListFromHeader(test.input)
if len(res) != len(test.output) {
t.Errorf("Test %d: Expected [%d] items in return list, but got [%d]", i, len(test.output), len(res))
} else {
for j, vv := range test.output {
if res[j] != vv {
t.Errorf("Test %d: Expected return value [%v] but got [%v]", j, test.output, res)
}
}
}
}
}
-49
View File
@@ -1,49 +0,0 @@
package main
import (
"unicode/utf8"
"github.com/satori/go.uuid"
"golang.org/x/crypto/bcrypt"
)
func getValidUsername(u string) (uuid.UUID, error) {
uname, err := uuid.FromString(u)
if err != nil {
return uuid.UUID{}, err
}
return uname, nil
}
func validKey(k string) bool {
kn := sanitizeString(k)
if utf8.RuneCountInString(k) == 40 && utf8.RuneCountInString(kn) == 40 {
// Correct length and all chars valid
return true
}
return false
}
func validSubdomain(s string) bool {
_, err := uuid.FromString(s)
if err == nil {
return true
}
return false
}
func validTXT(s string) bool {
sn := sanitizeString(s)
if utf8.RuneCountInString(s) == 43 && utf8.RuneCountInString(sn) == 43 {
// 43 chars is the current LE auth key size, but not limited / defined by ACME
return true
}
return false
}
func correctPassword(pw string, hash string) bool {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)); err == nil {
return true
}
return false
}
-549
View File
@@ -1,549 +0,0 @@
{
"comment": "",
"ignore": "test",
"package": [
{
"checksumSHA1": "hqDDDpue/5363luidNMBS8z8eJU=",
"path": "github.com/BurntSushi/toml",
"revision": "99064174e013895bbd9b025c31100bd1d9b590ca",
"revisionTime": "2016-07-17T15:07:09Z"
},
{
"checksumSHA1": "jRtYpPa7CRuA+LP4ELF9c9CjJao=",
"path": "github.com/Sirupsen/logrus",
"revision": "a437dfd2463eaedbec3dfe443e477d3b0a810b3f",
"revisionTime": "2016-11-18T19:45:39Z"
},
{
"checksumSHA1": "Lglgc8iIRhqbqd8fpAZKpo/eqeY=",
"path": "github.com/Sirupsen/logrus/hooks/test",
"revision": "a437dfd2463eaedbec3dfe443e477d3b0a810b3f",
"revisionTime": "2016-11-18T19:45:39Z"
},
{
"checksumSHA1": "kMfAFLobZymMrCOm/Xi/g9gnJOU=",
"path": "github.com/ajg/form",
"revision": "523a5da1a92f01b01f840b61689c0340a0243532",
"revisionTime": "2016-08-22T23:00:20Z"
},
{
"checksumSHA1": "OFu4xJEIjiI8Suu+j/gabfp+y6Q=",
"origin": "github.com/stretchr/testify/vendor/github.com/davecgh/go-spew/spew",
"path": "github.com/davecgh/go-spew/spew",
"revision": "18a02ba4a312f95da08ff4cfc0055750ce50ae9e",
"revisionTime": "2016-11-17T07:43:51Z"
},
{
"checksumSHA1": "0xIBiVOmW6JxXyxOZsBTtHF1Jxw=",
"path": "github.com/erikstmartin/go-testdb",
"revision": "8d10e4a1bae52cd8b81ffdec3445890d6dccab3d",
"revisionTime": "2016-02-19T21:45:06Z"
},
{
"checksumSHA1": "KCWVxG+J8SxHGlGiUghe0KBGsa8=",
"path": "github.com/fatih/structs",
"revision": "dc3312cb1a4513a366c4c9e622ad55c32df12ed3",
"revisionTime": "2016-08-07T23:55:29Z"
},
{
"checksumSHA1": "bnOeTmDN6UfzLWaifmbPnAH2yWs=",
"path": "github.com/gavv/gojsondiff",
"revision": "36046c6e558e7f854ebd3fd97d1e9812ebe8709b",
"revisionTime": "2016-05-10T20:49:56Z"
},
{
"checksumSHA1": "GJ1YuqzOYzEBDcO8wE2Jv4xihLI=",
"path": "github.com/gavv/gojsondiff/formatter",
"revision": "36046c6e558e7f854ebd3fd97d1e9812ebe8709b",
"revisionTime": "2016-05-10T20:49:56Z"
},
{
"checksumSHA1": "5B8ZLx876nOQv4dChpvamEEjHMs=",
"path": "github.com/gavv/httpexpect",
"revision": "35d8329d8ee24194c2103dfa7cd1c715be3bced2",
"revisionTime": "2016-11-16T16:40:02Z"
},
{
"checksumSHA1": "4HpMp8lo5lc64CIb3pULsFlr4ms=",
"path": "github.com/gavv/monotime",
"revision": "47d58efa69556a936a3c15eb2ed42706d968ab01",
"revisionTime": "2016-10-10T19:08:48Z"
},
{
"checksumSHA1": "gpccqXvJy99CBDrHS+m4BDZprvk=",
"path": "github.com/geekypanda/httpcache",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "V9dSQUcmEVqwUazrRx8RB6XwTdk=",
"path": "github.com/geekypanda/httpcache/internal",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "AauUe5dA6Ex6d4wCI88Tpl72kE8=",
"path": "github.com/geekypanda/httpcache/internal/fhttp",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "wSO3uLsYdlhjq+mXJsw1FYRhrhU=",
"path": "github.com/geekypanda/httpcache/internal/fhttp/rule",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "XvHvSUy+R57XJTGV7Q8SoAuXpd4=",
"path": "github.com/geekypanda/httpcache/internal/nethttp",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "y84oxzFPj8hrrVEh3m6rnx9WpYA=",
"path": "github.com/geekypanda/httpcache/internal/nethttp/rule",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "G3LMqGx0ztSCcFB9SX7K01owtvY=",
"path": "github.com/geekypanda/httpcache/internal/server",
"revision": "76ba6c68462ae362cda7564c44492b95322b363a",
"revisionTime": "2016-11-19T13:53:50Z"
},
{
"checksumSHA1": "25qSuESQLAwpJKpK8+Ne81GtQ40=",
"origin": "github.com/kataras/go-fs/vendor/github.com/google/go-github/github",
"path": "github.com/google/go-github/github",
"revision": "c029e113d9faaf558b730f06041c8bf9545a3502",
"revisionTime": "2016-10-31T04:20:56Z"
},
{
"checksumSHA1": "yyAzHoiVLu+xywYI2BDyRq6sOqE=",
"path": "github.com/google/go-querystring/query",
"revision": "9235644dd9e52eeae6fa48efd539fdc351a0af53",
"revisionTime": "2016-03-11T01:20:12Z"
},
{
"checksumSHA1": "XjzE8S3JcN+F48Tmv6ZAf7kwqKU=",
"origin": "github.com/kataras/go-websocket/vendor/github.com/gorilla/websocket",
"path": "github.com/gorilla/websocket",
"revision": "188e6bbd55486e22f0ddc3f013105c518548fbbb",
"revisionTime": "2016-11-04T23:40:48Z"
},
{
"checksumSHA1": "poYpUe2RyFrWeBoTAdB6eM4F+eM=",
"origin": "github.com/kataras/go-fs/vendor/github.com/hashicorp/go-version",
"path": "github.com/hashicorp/go-version",
"revision": "c029e113d9faaf558b730f06041c8bf9545a3502",
"revisionTime": "2016-10-31T04:20:56Z"
},
{
"checksumSHA1": "hwGdeQbcfc2RvIQS5wAaYRKJDd4=",
"path": "github.com/imdario/mergo",
"revision": "50d4dbd4eb0e84778abe37cefef140271d96fade",
"revisionTime": "2016-05-17T06:44:35Z"
},
{
"checksumSHA1": "XFHQ1CK3YYzMx9M/C4HSygSav6c=",
"path": "github.com/imkira/go-interpol",
"revision": "5accad8134979a6ac504d456a6c7f1c53da237ca",
"revisionTime": "2016-09-18T18:34:49Z"
},
{
"checksumSHA1": "Snx6GCbPUzXgc8J40CjQMvu2dFE=",
"path": "github.com/iris-contrib/formBinder",
"revision": "023b47796b500a9a9407e81cbf1cf5ebf45718e0",
"revisionTime": "2016-10-31T05:12:53Z"
},
{
"checksumSHA1": "i6IqjmScYfsN+3oZ+Vt+SO6kghw=",
"path": "github.com/iris-contrib/lego/acme",
"revision": "095d7f6459c501cb15319aa2754afa221b81a3ec",
"revisionTime": "2016-10-22T05:37:38Z"
},
{
"checksumSHA1": "tiu4UWUWrJctQNnfz/dRFog0ksI=",
"path": "github.com/iris-contrib/letsencrypt",
"revision": "1a3e5c619a13b307df3b1b4da7cb7e57d2e156dd",
"revisionTime": "2016-10-21T19:44:08Z"
},
{
"checksumSHA1": "56wyOoLznFBSCqliBRjiwKAs0R8=",
"path": "github.com/iris-contrib/middleware/cors",
"revision": "fd204bbe1fe40fb92800f5dfbb5d637776a30b46",
"revisionTime": "2016-10-31T04:52:57Z"
},
{
"checksumSHA1": "nGy5c2Euaeu0gEU0nxqFb6jO5Rw=",
"path": "github.com/iris-contrib/websocket",
"revision": "cc9f1712095295a828e9a2efaef388d30b9c7760",
"revisionTime": "2016-10-09T18:06:29Z"
},
{
"checksumSHA1": "oOOoWMOCyOoZ594DKzopz9w9kew=",
"path": "github.com/kataras/go-errors",
"revision": "0f977b82cc78d5d31bb75fb6f903ad9e852c8bbd",
"revisionTime": "2016-09-18T10:12:19Z"
},
{
"checksumSHA1": "oxrjhEMJaD/MqQwo3xHE8QA9Tfk=",
"path": "github.com/kataras/go-fs",
"revision": "c029e113d9faaf558b730f06041c8bf9545a3502",
"revisionTime": "2016-10-31T04:20:56Z"
},
{
"checksumSHA1": "WQ2UlASRdzSwbYYwUKUyadUxFx8=",
"path": "github.com/kataras/go-options",
"revision": "23b556c1b935c594ec6d71ff81ead4dbeec3aa8d",
"revisionTime": "2016-09-09T04:20:19Z"
},
{
"checksumSHA1": "xs0wwYHPqJTz0NBzH9tajb+tDqU=",
"path": "github.com/kataras/go-serializer",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "L2YxcGSPjpnO6V+fT/Cx1JU1nB4=",
"path": "github.com/kataras/go-serializer/data",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "sDz+RpxfMabDdSgU3hISAofwKlE=",
"path": "github.com/kataras/go-serializer/json",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "ACZvyU6FytObgwOB6UhPgNlVTAE=",
"path": "github.com/kataras/go-serializer/jsonp",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "7IyA1DftN+yYPQxppxaA7cUOeRM=",
"path": "github.com/kataras/go-serializer/markdown",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "ffDcrYR6cOsfl3Sbu5lnE+3SkP4=",
"path": "github.com/kataras/go-serializer/text",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "vqhmBFZ37nWG1jxPpvxynW1bwrE=",
"path": "github.com/kataras/go-serializer/xml",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "arCdUcupgxsKcfbzE3XLhYPu4B8=",
"path": "github.com/kataras/go-sessions",
"revision": "5fbb60d99b3cd100a2ae586cb49474368cebab58",
"revisionTime": "2016-11-06T05:58:01Z"
},
{
"checksumSHA1": "llGXIznKrKh9Xog3E8UW5HUGwx4=",
"path": "github.com/kataras/go-template",
"revision": "457f21178102f4688603eccbb4f2e8d5ae1023bf",
"revisionTime": "2016-11-11T10:06:00Z"
},
{
"checksumSHA1": "D+rA4C4aTWlXRhROhIwsMXcWqsM=",
"path": "github.com/kataras/go-template/html",
"revision": "457f21178102f4688603eccbb4f2e8d5ae1023bf",
"revisionTime": "2016-11-11T10:06:00Z"
},
{
"checksumSHA1": "rMMwNiM+ovdbJi+pqt23Pv5e6W8=",
"path": "github.com/kataras/go-websocket",
"revision": "188e6bbd55486e22f0ddc3f013105c518548fbbb",
"revisionTime": "2016-11-04T23:40:48Z"
},
{
"checksumSHA1": "TfPCJRr/ogxz1mH5+6BiCj6sl0w=",
"path": "github.com/kataras/iris",
"revision": "290a9cad3dab65f3eb1bbab3ef9a252bb59da74c",
"revisionTime": "2016-11-23T20:46:19Z"
},
{
"checksumSHA1": "8xYLTnyqaix1rdjB0EEeSTe14Wg=",
"path": "github.com/kataras/iris/httptest",
"revision": "290a9cad3dab65f3eb1bbab3ef9a252bb59da74c",
"revisionTime": "2016-11-23T20:46:19Z"
},
{
"checksumSHA1": "RrW2mq7rcdH2cK/3oizmdTipEK4=",
"path": "github.com/kataras/iris/utils",
"revision": "290a9cad3dab65f3eb1bbab3ef9a252bb59da74c",
"revisionTime": "2016-11-23T20:46:19Z"
},
{
"checksumSHA1": "vfzz7zTL9TZLpFO7NC1H6/Du3+s=",
"path": "github.com/klauspost/compress/flate",
"revision": "e3b7981a12dd3cab49afa1d3a50e715846f23732",
"revisionTime": "2016-11-06T14:34:36Z"
},
{
"checksumSHA1": "V1lQwkoDR1fPmZBSgkmZjgZofeU=",
"path": "github.com/klauspost/compress/gzip",
"revision": "e3b7981a12dd3cab49afa1d3a50e715846f23732",
"revisionTime": "2016-11-06T14:34:36Z"
},
{
"checksumSHA1": "+azPXaZpPF14YHRghNAer13ThQU=",
"path": "github.com/klauspost/compress/zlib",
"revision": "e3b7981a12dd3cab49afa1d3a50e715846f23732",
"revisionTime": "2016-11-06T14:34:36Z"
},
{
"checksumSHA1": "iKPMvbAueGfdyHcWCgzwKzm8WVo=",
"path": "github.com/klauspost/cpuid",
"revision": "09cded8978dc9e80714c4d85b0322337b0a1e5e0",
"revisionTime": "2016-03-02T07:53:16Z"
},
{
"checksumSHA1": "BM6ZlNJmtKy3GBoWwg2X55gnZ4A=",
"path": "github.com/klauspost/crc32",
"revision": "cb6bfca970f6908083f26f39a79009d608efd5cd",
"revisionTime": "2016-10-16T15:41:25Z"
},
{
"checksumSHA1": "avqi4lkviHdrNJ92cXCwrw9x870=",
"path": "github.com/lib/pq",
"revision": "d8eeeb8bae8896dd8e1b7e514ab0d396c4f12a1b",
"revisionTime": "2016-11-03T02:43:54Z"
},
{
"checksumSHA1": "xppHi82MLqVx1eyQmbhTesAEjx8=",
"path": "github.com/lib/pq/oid",
"revision": "d8eeeb8bae8896dd8e1b7e514ab0d396c4f12a1b",
"revisionTime": "2016-11-03T02:43:54Z"
},
{
"checksumSHA1": "gQPNnwneFBYZXKVN0PaKrqiGemA=",
"path": "github.com/mattn/go-sqlite3",
"revision": "fba66eb11643069e747022997e9be3b502b2c6fb",
"revisionTime": "2016-11-11T16:58:19Z"
},
{
"checksumSHA1": "z2i7dm7KC0aicOx2PLcHRv6NibU=",
"origin": "github.com/kataras/go-serializer/vendor/github.com/microcosm-cc/bluemonday",
"path": "github.com/microcosm-cc/bluemonday",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "VZrdtf1OtAeYaHwL1opfi08HwnM=",
"path": "github.com/miekg/dns",
"revision": "271c58e0c14f552178ea321a545ff9af38930f39",
"revisionTime": "2016-11-22T06:12:14Z"
},
{
"checksumSHA1": "CxNwJP++vjUAyy3bbJnNss1Il9Q=",
"path": "github.com/moul/http2curl",
"revision": "4e24498b31dba4683efb9d35c1c8a91e2eda28c8",
"revisionTime": "2016-10-31T19:45:48Z"
},
{
"checksumSHA1": "zKKp5SZ3d3ycKe4EKMNT0BqAWBw=",
"origin": "github.com/stretchr/testify/vendor/github.com/pmezard/go-difflib/difflib",
"path": "github.com/pmezard/go-difflib/difflib",
"revision": "18a02ba4a312f95da08ff4cfc0055750ce50ae9e",
"revisionTime": "2016-11-17T07:43:51Z"
},
{
"checksumSHA1": "41hlerAYPe6EFKtgmK/AEf5xBP4=",
"origin": "github.com/kataras/go-serializer/vendor/github.com/russross/blackfriday",
"path": "github.com/russross/blackfriday",
"revision": "0bd874a15c70db74ef2e668e5eeda27041f03b81",
"revisionTime": "2016-10-31T04:11:45Z"
},
{
"checksumSHA1": "zmC8/3V4ls53DJlNTKDZwPSC/dA=",
"path": "github.com/satori/go.uuid",
"revision": "b061729afc07e77a8aa4fad0a2fd840958f1942a",
"revisionTime": "2016-09-27T10:08:44Z"
},
{
"checksumSHA1": "4RKtyBgrsGEZwtiypp6uq6139MQ=",
"path": "github.com/sergi/go-diff/diffmatchpatch",
"revision": "552b4e9bbdca9e5adafd95ee98c822fdd11b330b",
"revisionTime": "2016-11-02T18:40:45Z"
},
{
"checksumSHA1": "kbgJvKG3NRoqU91rYnXGnyR+8HQ=",
"path": "github.com/shurcooL/sanitized_anchor_name",
"revision": "1dba4b3954bc059efc3991ec364f9f9a35f597d2",
"revisionTime": "2016-09-18T04:11:01Z"
},
{
"checksumSHA1": "fOuTjfiFhmBY4iJJXquzV4ojBy8=",
"origin": "github.com/iris-contrib/lego/vendor/github.com/square/go-jose",
"path": "github.com/square/go-jose",
"revision": "095d7f6459c501cb15319aa2754afa221b81a3ec",
"revisionTime": "2016-10-22T05:37:38Z"
},
{
"checksumSHA1": "hIEmcd7hIDqO/xWSp1rJJHd0TpE=",
"path": "github.com/stretchr/testify/assert",
"revision": "18a02ba4a312f95da08ff4cfc0055750ce50ae9e",
"revisionTime": "2016-11-17T07:43:51Z"
},
{
"checksumSHA1": "omdvCNu8sJIc9FbOfObC484M7Dg=",
"path": "github.com/stretchr/testify/require",
"revision": "18a02ba4a312f95da08ff4cfc0055750ce50ae9e",
"revisionTime": "2016-11-17T07:43:51Z"
},
{
"checksumSHA1": "LTOa3BADhwvT0wFCknPueQALm8I=",
"path": "github.com/valyala/bytebufferpool",
"revision": "e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7",
"revisionTime": "2016-08-17T18:16:52Z"
},
{
"checksumSHA1": "hLWrEWJTTxuiI6/L71Jt20truqI=",
"path": "github.com/valyala/fasthttp",
"revision": "1c39678a4dd0122de1b9a7e14e49b3e99b7d60b9",
"revisionTime": "2016-11-28T09:50:28Z"
},
{
"checksumSHA1": "1j/ERUJk+d/UwnmA+oMUsrPxdSw=",
"path": "github.com/valyala/fasthttp/fasthttpadaptor",
"revision": "1c39678a4dd0122de1b9a7e14e49b3e99b7d60b9",
"revisionTime": "2016-11-28T09:50:28Z"
},
{
"checksumSHA1": "nMWLZCTKLciURGG8o/KeEPUExkY=",
"path": "github.com/valyala/fasthttp/fasthttputil",
"revision": "1c39678a4dd0122de1b9a7e14e49b3e99b7d60b9",
"revisionTime": "2016-11-28T09:50:28Z"
},
{
"checksumSHA1": "8qIEFviyMSKhh3e2vWdZFC6TNu4=",
"path": "github.com/valyala/fasthttp/stackless",
"revision": "1c39678a4dd0122de1b9a7e14e49b3e99b7d60b9",
"revisionTime": "2016-11-28T09:50:28Z"
},
{
"checksumSHA1": "drSl/ipSHSsHWWTrp3WZw4LN/No=",
"path": "github.com/xeipuuv/gojsonpointer",
"revision": "e0fe6f68307607d540ed8eac07a342c33fa1b54a",
"revisionTime": "2015-10-27T08:21:46Z"
},
{
"checksumSHA1": "pSoUW+qY6LwIJ5lFwGohPU5HUpg=",
"path": "github.com/xeipuuv/gojsonreference",
"revision": "e02fc20de94c78484cd5ffb007f8af96be030a45",
"revisionTime": "2015-08-08T06:50:54Z"
},
{
"checksumSHA1": "vLmkhv7RXt4uOoS564cBIMzLT88=",
"path": "github.com/xeipuuv/gojsonschema",
"revision": "e18f0065e8c148fcf567ac43a3f8f5b66ac0720b",
"revisionTime": "2016-11-19T18:01:51Z"
},
{
"checksumSHA1": "LmYXonZ72xAk0VmZB52DD+TTAOo=",
"path": "github.com/yalp/jsonpath",
"revision": "31a79c7593bb93eb10b163650d4a3e6ca190e4dc",
"revisionTime": "2015-08-12T00:39:00Z"
},
{
"checksumSHA1": "OCkp7qxxdxjpoM3T6Q3CTiMP5kM=",
"path": "github.com/yudai/golcs",
"revision": "d1c525dea8ce39ea9a783d33cf08932305373f2c",
"revisionTime": "2015-04-05T16:34:35Z"
},
{
"checksumSHA1": "TK1Yr8BbwionaaAvM+77lwAAx/8=",
"path": "golang.org/x/crypto/acme",
"revision": "ede567c8e044a5913dad1d1af3696d9da953104c",
"revisionTime": "2016-11-04T19:41:44Z"
},
{
"checksumSHA1": "0gEWevUuowrpoQgcLSG76u+y8Uw=",
"path": "golang.org/x/crypto/acme/autocert",
"revision": "ede567c8e044a5913dad1d1af3696d9da953104c",
"revisionTime": "2016-11-04T19:41:44Z"
},
{
"checksumSHA1": "vE43s37+4CJ2CDU6TlOUOYE0K9c=",
"path": "golang.org/x/crypto/bcrypt",
"revision": "ede567c8e044a5913dad1d1af3696d9da953104c",
"revisionTime": "2016-11-04T19:41:44Z"
},
{
"checksumSHA1": "JsJdKXhz87gWenMwBeejTOeNE7k=",
"path": "golang.org/x/crypto/blowfish",
"revision": "ede567c8e044a5913dad1d1af3696d9da953104c",
"revisionTime": "2016-11-04T19:41:44Z"
},
{
"checksumSHA1": "TJmmMKEHkGrmn+/39c9HiPpSQ3Q=",
"path": "golang.org/x/crypto/ocsp",
"revision": "ede567c8e044a5913dad1d1af3696d9da953104c",
"revisionTime": "2016-11-04T19:41:44Z"
},
{
"checksumSHA1": "9jjO5GjLa0XF/nfWihF02RoH4qc=",
"path": "golang.org/x/net/context",
"revision": "4971afdc2f162e82d185353533d3cf16188a9f4e",
"revisionTime": "2016-11-15T21:05:04Z"
},
{
"checksumSHA1": "WHc3uByvGaMcnSoI21fhzYgbOgg=",
"path": "golang.org/x/net/context/ctxhttp",
"revision": "4971afdc2f162e82d185353533d3cf16188a9f4e",
"revisionTime": "2016-11-15T21:05:04Z"
},
{
"checksumSHA1": "vqc3a+oTUGX8PmD0TS+qQ7gmN8I=",
"path": "golang.org/x/net/html",
"revision": "4971afdc2f162e82d185353533d3cf16188a9f4e",
"revisionTime": "2016-11-15T21:05:04Z"
},
{
"checksumSHA1": "00eQaGynDYrv3tL+C7l9xH0IDZg=",
"path": "golang.org/x/net/html/atom",
"revision": "4971afdc2f162e82d185353533d3cf16188a9f4e",
"revisionTime": "2016-11-15T21:05:04Z"
},
{
"checksumSHA1": "AmZIW67T/HUlTTflTmOIy6jdq74=",
"path": "golang.org/x/net/publicsuffix",
"revision": "4971afdc2f162e82d185353533d3cf16188a9f4e",
"revisionTime": "2016-11-15T21:05:04Z"
},
{
"checksumSHA1": "eFQDEix/mGnhwnFu/Hq63zMfrX8=",
"path": "golang.org/x/time/rate",
"revision": "f51c12702a4d776e4c1fa9b0fabab841babae631",
"revisionTime": "2016-10-28T04:02:39Z"
},
{
"checksumSHA1": "MeXzn+OFdrU9/TGeMVz0GsRX+dM=",
"path": "gopkg.in/DATA-DOG/go-sqlmock.v1",
"revision": "d4cd2ca2ad1cc2130bba385aab072218f131f636",
"revisionTime": "2016-11-02T12:49:59Z"
},
{
"checksumSHA1": "vSlztt3rfYwwDDKEiqUDWXl2LGw=",
"path": "gopkg.in/square/go-jose.v1/cipher",
"revision": "aa2e30fdd1fe9dd3394119af66451ae790d50e0d",
"revisionTime": "2016-09-23T00:08:11Z"
},
{
"checksumSHA1": "UYvcpB3og7YJHbRu4feZFxXAU/A=",
"path": "gopkg.in/square/go-jose.v1/json",
"revision": "aa2e30fdd1fe9dd3394119af66451ae790d50e0d",
"revisionTime": "2016-09-23T00:08:11Z"
}
],
"rootPath": "acme-dns"
}