Compare commits

...

129 Commits
v0.1 ... master

Author SHA1 Message Date
Joona Hoikkala
4e5a69e5fb
Fix errors in auto-released docker images (#399) 2026-02-05 18:09:31 +02:00
Joona Hoikkala
055f80bf9b
Build and push a new docker image to Docker Hub when a release is triggered (#397) 2026-02-05 17:32:32 +02:00
Joona Hoikkala
917ff10563
Update readme and changelog (#396) 2026-02-05 17:13:05 +02:00
Joona Hoikkala
c331e33178
Update goreleaser configuration and add a GitHub action to build a release on new version tags (#395) 2026-02-05 17:06:21 +02:00
Joona Hoikkala
5a7bc230b8
Refactoring (#325)
* Refactor core

* Re-added tests

* Small fixes

* Add tests for acmetxt cidrslice and util funcs

* Remove the last dangling reference to old logging package

* 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

* Updated dependencies, wrote changelog entry and fixed namespace for release

* 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>

* Minor refactoring, error returns and e2e testing suite

* Add a few tests

* Fix linter and umask setting

* Update github actions

* Refine concurrency configuration for GitHub actions

* HTTP timeouts to API, and self-validation mutex to nameserver ops

---------

Co-authored-by: Florian Ritterhoff <32478819+fritterhoff@users.noreply.github.com>
Co-authored-by: Jason Playne <jason@jasonplayne.com>
2026-02-05 16:04:15 +02:00
Joona Hoikkala
b7a0a8a7bc
Add timeout to golangci job (#369) 2024-12-15 13:19:23 +02:00
Joona Hoikkala
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
27e8251d11
Bump dependencies (#334) 2023-03-07 01:45:39 +02:00
Joona Hoikkala
6ba9360156
Update goreleaser config to work with latest version (#312) 2022-08-10 15:28:16 +03:00
Joona Hoikkala
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 Hoikkala
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 Hoikkala
68bb6ab654
Add acme-dns-client to the documentation (#253) 2021-01-11 18:07:22 +02:00
Joona Hoikkala
835fbb9ef6
Migrate to GitHub actions for coverage & unit test automation (#251) 2021-01-11 17:31:09 +02:00
Jonathan Vanasco
9c6ca258e1
relax subdomain validation from UUID to actual subdomain (#243) 2021-01-11 14:55:31 +02:00
Peter Lebbing
d573f771a8
README: Use https URLs for API testing (#230) 2021-01-11 14:50:48 +02:00
Brett Keller
035a219f9f
Add support for LE expiration notification e-mails on API cert (#227) 2021-01-11 14:35:54 +02:00
Wouter Tinus
19069f50ec
Update README.md (#210)
Add new website for win-acme
2020-02-10 19:27:16 +02:00
Joona Hoikkala
5de21916a3
Handle go modules properly in Dockerfile with Go 1.13 (#207) 2020-01-10 12:32:11 +02:00
Joona Hoikkala
3d5a512d1e
Revert "Update modules (#203)" (#206)
This reverts commit 1681de11d21b1cb45c2444818bddbe4ceb77d929.
2020-01-09 08:39:04 +02:00
Joona Hoikkala
9f7a158367
Update readme (#204) 2020-01-08 20:40:37 +02:00
Joona Hoikkala
1681de11d2
Update modules (#203) 2020-01-08 07:53:11 +02:00
Marvin Blum
88d3be685e Removed example for health check endpoint because it is misleading. (#181) 2019-10-21 16:13:22 +03:00
Joona Hoikkala
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 Hoikkala
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 Hoikkala
5b1e51534f
Migrate to Go 1.13 and gomodules (#187) 2019-10-17 14:21:04 +03:00
lateagain
637c82f407 Update acme-dns.service (#183)
Set working directory to service users home.
2019-09-18 16:05:32 +03:00
Daniel McCarney
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
znerol
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 Hoikkala
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 Nigro
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 Hoikkala
5d74ff1142
Prepare README for point release v0.7.2 (#153) 2019-02-07 09:58:07 +02:00
Ward Vandewege
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 Vandewege
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 Hoikkala
4f5fad0d32
Prepare for point release (#150)
* Prepare for point release
2019-02-06 12:13:00 +02:00
Ward Vandewege
5651772837 Make sure to initialize the SOA record for the tcp server, too. (#149) 2019-02-06 12:09:01 +02:00
Joona Hoikkala
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 Hoikkala
a88ee29755
Prepare readme for release (#146) 2019-02-03 20:32:02 +02:00
Joona Hoikkala
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 Hoikkala
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 Hoikkala
d695f72963
Fix the default configuration SQLite db path (#143) 2019-02-01 09:55:26 +02:00
Marvin Blum
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 Hoikkala
7fbb5261c8
Fix test not to assume preset UseHeaders value (#139) 2019-01-25 18:36:29 +02:00
Philip Vieira
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 Cook
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 Hoikkala
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 Hoikkala
20411b650f
Update changelog and prepare for release v0.6 (#125) 2018-10-31 15:13:29 +02:00
Joona Hoikkala
3bb130d055
Refuse dynamic updates properly (#124) 2018-10-31 15:01:42 +02:00
Joona Hoikkala
1d85bf75b0
Added Traefik and cert-manager to client list (#123) 2018-10-31 01:13:10 +02:00
Joona Hoikkala
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 fixer
a09073da12 Fix golint import path (#121) 2018-10-29 10:29:34 +02:00
Julien Schmidt
4bb5e265ab README: use absolute path for sqlite DB in example config (#116) 2018-09-28 11:40:50 +03:00
Julien Schmidt
b452d504c8 README: fix NS address in test command (#115) 2018-09-28 11:14:31 +03:00
Yannik Sembritzki
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 Sembritzki
f650e47fe5 fix README inconsistencies (#113) 2018-09-28 00:33:51 +03:00
Yannik Sembritzki
dc0dd43017 simplify example dns config (#106) 2018-09-27 23:58:37 +03:00
Julien Schmidt
c827ee4801 README: fix section of HTTP header vars in example config (#112) 2018-09-27 23:51:31 +03:00
Stefan Bethke
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 Hoikkala
d66ccffaf6
Sign releases (#105) 2018-08-12 21:04:07 +03:00
Joona Hoikkala
eeff02e63b
Changelog entries for v0.5 (#104) 2018-08-12 20:51:04 +03:00
Joona Hoikkala
90ae6ee268
Default value for added configuration option to keep backwards compatibility with old config (#103) 2018-08-12 20:19:58 +03:00
Joona Hoikkala
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 Hoikkala
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 Hoikkala
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 Hoikkala
75d4a30c1f
Update dependencies and replace uuid library (#100) 2018-08-10 16:51:32 +03:00
Daniel McCarney
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 McCarney
52e977ce1d README: Add goacmedns, a Go acme-dns client library to README. (#92) 2018-07-01 22:54:37 +03:00
Josh Soref
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 Soref
fbd5c65a6a Spelling (#89)
* spelling: the challenging

* spelling: effects

* spelling: received

* spelling: vagrantfile
2018-06-05 11:42:16 +03:00
Gabe Cook
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 Meyer
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 Bolger
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 Hoikkala
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 1c918fcaa4dd6278739e2f83579d386c79e7ee46.
2018-04-25 11:44:34 +03:00
Joona Hoikkala
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 Hoikkala
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 Hoikkala
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 Hoikkala
14f552e970
Merge pull request #66 from cpu/cpu-typo-fix
db_test: fix 'recieve' typo
2018-04-08 12:41:20 +03:00
Joona Hoikkala
f2d1fc692b
Merge branch 'master' into cpu-typo-fix 2018-04-08 12:37:14 +03:00
Joona Hoikkala
dd12fa7c9b
Merge pull request #64 from Daniel15/patch-2
Make installation instructions more comprehensive
2018-04-08 12:34:00 +03:00
Joona Hoikkala
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 Nigro
8eed75b495
Explain CNAMEs in DNS records section 2018-03-22 20:37:07 -07:00
Daniel Lo Nigro
46b9c45e1b
Remove unintended newline 2018-03-22 20:33:30 -07:00
Daniel Lo Nigro
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 Hoikkala
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 Nigro
3ee50d0c20
Make installation instructions more comprehensive 2018-03-18 23:47:45 -07:00
Joona Hoikkala
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 Hoikkala
01f010e35e
README: link to client implementation (#55) 2018-03-15 13:40:45 +02:00
Joona Hoikkala
cd3a587e43
Release v0.4 (#53) 2018-03-15 00:47:36 +02:00
Joona Hoikkala
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 Hoikkala
5c2e60a828
Add configuration option to disable registration endpoint (#51) 2018-03-14 23:35:39 +02:00
Joona Hoikkala
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 Hoikkala
92f8cc2802
Merge branch 'master' into cpu-differentiate-errors 2018-03-13 10:55:02 +02:00
Joona Hoikkala
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 McCarney
f463d07d0b
Merge branch 'master' into cpu-differentiate-errors 2018-03-01 09:54:41 -05:00
Joona Hoikkala
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 McCarney
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 Hoikkala
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 Hoikkala
830cceb62c
Release v0.3.2 (#40) 2018-02-01 16:10:24 +02:00
Leon Kyneur
9f896759f0 reinstall ca-certificates (#39) 2018-02-01 16:02:06 +02:00
Joona Hoikkala
476f6fc555
Only autobuild for linux, as sqlite needs cgo (#38) 2018-02-01 15:33:57 +02:00
Joona Hoikkala
e2e84ede6b
Goreleaser config (#37) 2018-02-01 14:20:40 +02:00
Joona Hoikkala
562d7cbad4
Make autocert use HTTP-01 challenge instead of TLS-SNI (#36) 2018-02-01 10:53:34 +02:00
Joona Hoikkala
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 Hoikkala
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 Hoikkala
c5337fc841
Update dependencies (#32) 2018-01-22 11:19:33 +02:00
Joona Hoikkala
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 Hoikkala
ba695134ce
Make cors messages respect the debug flag (#25) 2017-11-15 21:35:35 +02:00
Joona Hoikkala
b0cd264c71
Fail on malformed JSON payloads in register endpoint (#24) 2017-11-15 13:52:27 +02:00
Joona Hoikkala
02d42bff30
Removed unnecessary body check (#21) 2017-11-15 01:39:00 +02:00
Joona Hoikkala
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 Vlaswinkel
93871a7cec Fix config not being used (#19) 2017-11-14 13:01:46 +02:00
Joona Hoikkala
9c54da3ee6
Try to read config from under /etc (#18) 2017-11-14 00:42:30 +02:00
Joona Hoikkala
9c639223ce
Added dep vendoring (#17) 2017-11-12 23:57:45 +02:00
Joona Hoikkala
c70a6cffb0
Go 1.9 and Iris v8, added possibility to bind to IP (#15) 2017-11-12 23:40:15 +02:00
Daniel McCarney
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 Hoikkala
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 Hoikkala
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 Hoikkala
220ef6d3c0 Merge pull request #7 from joohoi/release-0.1
Release 0.1
2016-12-05 00:43:10 +02:00
73 changed files with 4456 additions and 2158 deletions

25
.github/workflows/e2e.yml vendored Normal file
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
.github/workflows/go_cov.yml vendored Normal file
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
.github/workflows/golangci-lint.yml vendored Normal file
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

43
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,43 @@
name: goreleaser
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: stable
- name: Import GPG key
id: import_gpg
uses: crazy-max/ghaction-import-gpg@v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v6
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GPG_FINGERPRINT: ${{ steps.import_gpg.outputs.fingerprint }}

4
.gitignore vendored
View File

@ -3,5 +3,5 @@ acme-dns.db
acme-dns.log acme-dns.log
.vagrant .vagrant
coverage.out coverage.out
vendor/*/ .idea/
/vendor/**/.git dist/

30
.golangci.yaml Normal file
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$

35
.goreleaser.yml Normal file
View File

@ -0,0 +1,35 @@
builds:
- binary: acme-dns
env:
- CGO_ENABLED=0
goos:
- linux
goarch:
- amd64
archives:
- id: tgz
format: tar.gz
files:
- LICENSE
- README.md
- Dockerfile
- config.cfg
- acme-dns.service
signs:
- artifacts: checksum
args: ["--batch", "-u", "{{ .Env.GPG_FINGERPRINT }}", "--output", "${signature}", "--detach-sign", "${artifact}"]
dockers:
- image_templates:
- "joohoi/acme-dns:{{ .Tag }}"
- "joohoi/acme-dns:latest"
dockerfile: Dockerfile.release
build_flag_templates:
- "--pull"
- "--label=org.opencontainers.image.created={{.Date}}"
- "--label=org.opencontainers.image.name={{.ProjectName}}"
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
- "--label=org.opencontainers.image.version={{.Version}}"

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
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,5 @@
{
"gopls": {
"formatting.local": "github.com/acme-dns/acme-dns"
}
}

59
CHANGELOG.md Normal file
View File

@ -0,0 +1,59 @@
# Changelog
## v2.0
- Update goreleaser configuration and add a GitHub action to build a release on new version tags (#395)
- Huge refactoring and modernization (#325)
## v1.1
- Add timeout to golangci job (#369)
- Update deps to support go 1.23 (#368)
- Bump dependencies (#334)
## 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

23
Dockerfile Normal file
View File

@ -0,0 +1,23 @@
FROM golang:alpine AS builder
LABEL maintainer="joona@kuori.org"
RUN apk add --update git
ENV GOPATH /tmp/buildcache
RUN git clone https://github.com/joohoi/acme-dns /tmp/acme-dns
WORKDIR /tmp/acme-dns
RUN CGO_ENABLED=0 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

12
Dockerfile.release Normal file
View File

@ -0,0 +1,12 @@
FROM alpine:latest
RUN apk --no-cache add ca-certificates && update-ca-certificates
RUN mkdir -p /etc/acme-dns
RUN mkdir -p /var/lib/acme-dns
COPY acme-dns /usr/local/bin/acme-dns
VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"]
ENTRYPOINT ["acme-dns"]
EXPOSE 53 80 443
EXPOSE 53/udp

View File

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2016 Joona Hoikkala Copyright (c) 2016-2026 Joona Hoikkala
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

253
README.md
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. 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. 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 ## Features
- Simplified DNS server, serving your ACME DNS challenges (TXT) - 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 - 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 - Limit /update API endpoint access to specific CIDR mask(s), defined in the /register request
- Supports SQLite & PostgreSQL as DB backends - 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) - Simple deployment (it's Go after all)
## Usage ## 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) [![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) - 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` ) - 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. - Crontab and forget.
## API ## 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. 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. 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``` ```POST /register```
@ -48,7 +54,8 @@ With the credentials, you can update the TXT response in the service to match th
"allowfrom": [ "allowfrom": [
"192.168.100.1/24", "192.168.100.1/24",
"1.2.3.4/32", "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": [ "allowfrom": [
"192.168.100.1/24", "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", "fulldomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a.auth.acme-dns.io",
"password": "htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z", "password": "htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z",
@ -76,14 +84,14 @@ The method allows you to update the TXT answer contents of your unique subdomain
#### Required headers #### Required headers
| Header name | Description | Example | | Header name | Description | Example |
| ------------- |--------------------------------------------|-------------------------------------------------------| | ------------- |--------------------------------------------|-------------------------------------------------------|
| X-Api-User | UUIDv4 username recieved from registration | `X-Api-User: c36f50e8-4632-44f0-83fe-e070fef28a10` | | X-Api-User | UUIDv4 username received from registration | `X-Api-User: c36f50e8-4632-44f0-83fe-e070fef28a10` |
| X-Api-Key | Password recieved from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` | | X-Api-Key | Password received from registration | `X-Api-Key: htB9mR9DYgcu9bX_afHF62erXaH2TS7bg9KW3F7Z` |
#### Example input #### Example input
```json ```json
{ {
"subdomain": "8e5700ea-a4bf-41c7-8a77-e990661dcc6a", "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``` ```Status: 200 OK```
```json ```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 ## 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. See the INSTALL section for information on how to do this.
## 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```
## Installation ## 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 ## Configuration
```bash ```bash
[general] [general]
# dns interface # DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
listen = ":53" # In this case acme-dns will error out and you will need to define the listening interface
# protocol, "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6" # for example: listen = "127.0.0.1:53"
protocol = "udp" 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 name to serve the requests off of
domain = "auth.example.org" domain = "auth.example.org"
# zone name server # zone name server
nsname = "ns1.auth.example.org" nsname = "auth.example.org"
# admin email address, where @ is substituted with . # admin email address, where @ is substituted with .
nsadmin = "admin.example.org" nsadmin = "admin.example.org"
# predefined records served in addition to the TXT # predefined records served in addition to the TXT
records = [ records = [
# default A # domain pointing to the public IP of your acme-dns server
"auth.example.org. A 192.168.1.100", "auth.example.org. A 198.51.100.1",
# A # specify that auth.example.org will resolve any *.auth.example.org records
"ns1.auth.example.org. A 192.168.1.100", "auth.example.org. NS auth.example.org.",
"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.",
] ]
# debug messages from CORS etc # debug messages from CORS etc
debug = false debug = false
@ -157,23 +256,34 @@ debug = false
# Database engine to use, sqlite3 or postgres # Database engine to use, sqlite3 or postgres
engine = "sqlite3" engine = "sqlite3"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres # 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" # connection = "postgres://user:password@localhost/acmedns_db"
[api] [api]
# domain name to listen requests for, mandatory if using tls = "letsencrypt" # listen ip eg. 127.0.0.1
api_domain = "" ip = "0.0.0.0"
# disable registration endpoint
disable_registration = false
# listen port, eg. 443 for default HTTPS # listen port, eg. 443 for default HTTPS
port = "8080" port = "443"
# possible values: "letsencrypt", "cert", "none" # possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "none" tls = "letsencryptstaging"
# only used if tls = "cert" # only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem" tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.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 # CORS AllowOrigins, wildcards can be used
corsorigins = [ 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] [logconfig]
# logging level: "error", "warning", "info" or "debug" # logging level: "error", "warning", "info" or "debug"
@ -184,25 +294,62 @@ logtype = "stdout"
# logfile = "./acme-dns.log" # logfile = "./acme-dns.log"
# format, either "json" or "text" # format, either "json" or "text"
logformat = "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"
``` ```
## Changelog ## HTTPS API
- v0.1 Initial release
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)
## TODO ## TODO
- Logging to a file - Logging to a file
- DNSSEC
- Want to see something implemented, make a feature request! - Want to see something implemented, make a feature request!
## Contributing ## Contributing
acme-dns is open for contributions. acme-dns is open for contributions.
If you have an improvement, please open a Pull Request. If you have an idea for improvement, please open an new issue or feel free to write a PR!
## License ## License
acme-dns is released under the [MIT License](http://www.opensource.org/licenses/MIT). acme-dns is released under the [MIT License](https://www.opensource.org/licenses/MIT).

2
Vagrantfile vendored
View File

@ -1,7 +1,7 @@
# -*- mode: ruby -*- # -*- mode: ruby -*-
# vi: set ft=ruby : # vi: set ft=ruby :
# Vagratnfile for running integration tests with PostgreSQL # Vagrantfile for running integration tests with PostgreSQL
VAGRANTFILE_API_VERSION = "2" VAGRANTFILE_API_VERSION = "2"

14
acme-dns.service Normal file
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

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
api.go
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)
}

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
}

View File

@ -1,45 +1,50 @@
[general] [general]
# dns interface # DNS interface. Note that systemd-resolved may reserve port 53 on 127.0.0.53
listen = ":53" # In this case acme-dns will error out and you will need to define the listening interface
# protocol, "udp", "udp4", "udp6" or "tcp", "tcp4", "tcp6" # for example: listen = "127.0.0.1:53"
protocol = "udp" 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 name to serve the requests off of
domain = "auth.example.org" domain = "auth.example.org"
# zone name server # zone name server
nsname = "ns1.auth.example.org" nsname = "auth.example.org"
# admin email address, where @ is substituted with . # admin email address, where @ is substituted with .
nsadmin = "admin.example.org" nsadmin = "admin.example.org"
# predefined records served in addition to the TXT # predefined records served in addition to the TXT
records = [ records = [
# default A # domain pointing to the public IP of your acme-dns server
"auth.example.org. A 192.168.1.100", "auth.example.org. A 198.51.100.1",
# A # specify that auth.example.org will resolve any *.auth.example.org records
"ns1.auth.example.org. A 192.168.1.100", "auth.example.org. NS auth.example.org.",
"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.",
] ]
# debug messages from CORS etc # debug messages from CORS etc
debug = false debug = false
[database] [database]
# Database engine to use, sqlite3 or postgres # Database engine to use, sqlite or postgres
engine = "sqlite3" engine = "sqlite"
# Connection string, filename for sqlite3 and postgres://$username:$password@$host/$db_name for postgres # 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 = "acme-dns.db"
# connection = "postgres://user:password@localhost/acmedns_db" # connection = "postgres://user:password@localhost/acmedns_db"
[api] [api]
# domain name to listen requests for, mandatory if using tls = "letsencrypt" # listen ip eg. 127.0.0.1
api_domain = "" ip = "0.0.0.0"
# disable registration endpoint
disable_registration = false
# listen port, eg. 443 for default HTTPS # listen port, eg. 443 for default HTTPS
port = "8080" port = "443"
# possible values: "letsencrypt", "cert", "none" # possible values: "letsencrypt", "letsencryptstaging", "cert", "none"
tls = "none" tls = "none"
# only used if tls = "cert" # only used if tls = "cert"
tls_cert_privkey = "/etc/tls/example.org/privkey.pem" tls_cert_privkey = "/etc/tls/example.org/privkey.pem"
tls_cert_fullchain = "/etc/tls/example.org/fullchain.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 # CORS AllowOrigins, wildcards can be used
corsorigins = [ corsorigins = [
"*" "*"
@ -51,10 +56,10 @@ header_name = "X-Forwarded-For"
[logconfig] [logconfig]
# logging level: "error", "warning", "info" or "debug" # logging level: "error", "warning", "info" or "debug"
loglevel = "debug" loglevel = "info"
# possible values: stdout, TODO file & integrations # possible values: stdout, file
logtype = "stdout" logtype = "stdout"
# file path for logfile TODO # file path for logfile
# logfile = "./acme-dns.log" logfile = "./acme-dns.log"
# format, either "json" or "text" # format, either "json" or "text"
logformat = "text" logformat = "json"

212
db.go
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
dns.go
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
}

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
docker-compose.yml Normal file
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
go.mod Normal file
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
go.sum Normal file
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=

91
main.go
View File

@ -1,68 +1,55 @@
//+build !test
package main package main
import ( import (
log "github.com/Sirupsen/logrus" "flag"
"github.com/iris-contrib/middleware/cors" "fmt"
"github.com/kataras/iris"
"os" "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() { func main() {
setUmask()
configPtr := flag.String("c", "/etc/acme-dns/config.cfg", "config file location")
flag.Parse()
// Read global config // Read global config
configTmp := readConfig("config.cfg") var err error
DNSConf = configTmp var logger *zap.Logger
config, usedConfigFile, err := acmedns.ReadConfig(*configPtr, "./config.cfg")
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)
if err != nil { if err != nil {
log.Errorf("Could not open database [%v]", err) fmt.Printf("Error: %s\n", err)
os.Exit(1) os.Exit(1)
} }
DB = newDB logger, err = acmedns.SetupLogging(config)
defer DB.Close() 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 sugar.Infow("Using config file",
startDNS(DNSConf.General.Listen, DNSConf.General.Proto) "file", usedConfigFile)
sugar.Info("Starting up")
// HTTP API db, err := database.Init(&config, sugar)
startHTTPAPI() // Error channel for servers
errChan := make(chan error, 1)
log.Debugf("Shutting down...") api := api.Init(&config, db, sugar, errChan)
} dnsservers := nameserver.InitAndStart(&config, db, sugar, errChan)
go api.Start(dnsservers)
func startHTTPAPI() { if err != nil {
api := iris.New() sugar.Error(err)
api.Config.DisableBanner = true }
crs := cors.New(cors.Options{ for {
AllowedOrigins: DNSConf.API.CorsOrigins, err = <-errChan
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)
if err != nil { 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)
} }
} }

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
pkg/acmedns/acmetxt.go Normal file
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
}

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
pkg/acmedns/cidrslice.go Normal file
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
}

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
pkg/acmedns/config.go Normal file
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
pkg/acmedns/interfaces.go Normal file
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
pkg/acmedns/logging.go Normal file
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
}

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
pkg/acmedns/types.go Normal file
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
pkg/acmedns/util.go Normal file
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
pkg/acmedns/util_test.go Normal file
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
pkg/api/api.go Normal file
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
pkg/api/api_test.go Normal file
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
pkg/api/auth.go Normal file
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)
}

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
}

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
pkg/api/healthcheck.go Normal file
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
pkg/api/register.go Normal file
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
pkg/api/update.go Normal file
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
pkg/api/util.go Normal file
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
}

View File

@ -1,12 +1,15 @@
package main package api
import ( import (
"github.com/satori/go.uuid"
"testing" "testing"
"github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/google/uuid"
) )
func TestGetValidUsername(t *testing.T) { 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 { for i, test := range []struct {
uname string uname string
output uuid.UUID output uuid.UUID
@ -54,7 +57,9 @@ func TestGetValidSubdomain(t *testing.T) {
output bool output bool
}{ }{
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", true}, {"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}, {"", false},
{"&!#!25123!%!'%", false}, {"&!#!25123!%!'%", false},
} { } {
@ -100,7 +105,7 @@ func TestCorrectPassword(t *testing.T) {
false}, false},
{"", "", false}, {"", "", false},
} { } {
ret := correctPassword(test.pw, test.hash) ret := acmedns.CorrectPassword(test.pw, test.hash)
if ret != test.output { if ret != test.output {
t.Errorf("Test %d: Expected return value %t, but got %t", i, test.output, ret) 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) { func TestGetValidCIDRMasks(t *testing.T) {
for i, test := range []struct { for i, test := range []struct {
input cidrslice input acmedns.Cidrslice
output cidrslice output acmedns.Cidrslice
}{ }{
{cidrslice{"10.0.0.1/24"}, cidrslice{"10.0.0.1/24"}}, {acmedns.Cidrslice{"10.0.0.1/24"}, acmedns.Cidrslice{"10.0.0.1/24"}},
{cidrslice{"invalid", "127.0.0.1/32"}, cidrslice{"127.0.0.1/32"}}, {acmedns.Cidrslice{"invalid", "127.0.0.1/32"}, acmedns.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{"2002:c0a8::0/32", "8.8.8.8/32"}, acmedns.Cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}},
} { } {
ret := test.input.ValidEntries() ret := test.input.ValidEntries()
if len(ret) == len(test.output) { if len(ret) == len(test.output) {

361
pkg/database/db.go Normal file
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
}

View File

@ -1,11 +1,15 @@
package main package database
import ( import (
"database/sql" "database/sql"
"database/sql/driver" "database/sql/driver"
"errors" "errors"
"github.com/erikstmartin/go-testdb"
"testing" "testing"
"github.com/erikstmartin/go-testdb"
"go.uber.org/zap"
"github.com/joohoi/acme-dns/pkg/acmedns"
) )
type testResult struct { type testResult struct {
@ -21,42 +25,38 @@ func (r testResult) RowsAffected() (int64, error) {
return r.affectedRows, nil return r.affectedRows, nil
} }
func TestDBInit(t *testing.T) { func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger) {
fakeDB := new(acmedb) c := acmedns.AcmeDnsConfig{}
err := fakeDB.Init("notarealegine", "connectionstring") c.Database.Engine = "sqlite"
if err == nil { c.Database.Connection = ":memory:"
t.Errorf("Was expecting error, didn't get one.") l := zap.NewNop().Sugar()
} return c, l
}
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) { func fakeDB() acmedns.AcmednsDB {
return testResult{1, 0}, errors.New("Prepared query error") conf, logger := fakeConfigAndLogger()
}) db, _ := Init(&conf, logger)
defer testdb.Reset() return db
errorDB := new(acmedb)
err = errorDB.Init("testdb", "")
if err == nil {
t.Errorf("Was expecting DB initiation error but got none")
}
errorDB.Close()
} }
func TestRegisterNoCIDR(t *testing.T) { func TestRegisterNoCIDR(t *testing.T) {
// Register tests // Register tests
_, err := DB.Register(cidrslice{}) DB := fakeDB()
_, err := DB.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Registration failed, got error [%v]", err) t.Errorf("Registration failed, got error [%v]", err)
} }
} }
func TestRegisterMany(t *testing.T) { func TestRegisterMany(t *testing.T) {
DB := fakeDB()
for i, test := range []struct { for i, test := range []struct {
input cidrslice input acmedns.Cidrslice
output 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"}}, {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"}},
{cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, cidrslice{}}, {acmedns.Cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, acmedns.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{"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) user, err := DB.Register(test.input)
if err != nil { if err != nil {
@ -67,18 +67,19 @@ func TestRegisterMany(t *testing.T) {
t.Errorf("Test %d: Got error when fetching username: [%v]", i, err) t.Errorf("Test %d: Got error when fetching username: [%v]", i, err)
} }
if len(user.AllowFrom) != len(test.output) { 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) { 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) { func TestGetByUsername(t *testing.T) {
DB := fakeDB()
// Create reg to refer to // Create reg to refer to
reg, err := DB.Register(cidrslice{}) reg, err := DB.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Registration failed, got error [%v]", err) t.Errorf("Registration failed, got error [%v]", err)
} }
@ -97,13 +98,14 @@ func TestGetByUsername(t *testing.T) {
} }
// regUser password already is a bcrypt hash // 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) t.Errorf("The password [%s] does not match the hash [%s]", reg.Password, regUser.Password)
} }
} }
func TestPrepareErrors(t *testing.T) { func TestPrepareErrors(t *testing.T) {
reg, _ := DB.Register(cidrslice{}) DB := fakeDB()
reg, _ := DB.Register(acmedns.Cidrslice{})
tdb, err := sql.Open("testdb", "") tdb, err := sql.Open("testdb", "")
if err != nil { if err != nil {
t.Errorf("Got error: %v", err) t.Errorf("Got error: %v", err)
@ -118,14 +120,15 @@ func TestPrepareErrors(t *testing.T) {
t.Errorf("Expected error, but didn't get one") t.Errorf("Expected error, but didn't get one")
} }
_, err = DB.GetByDomain(reg.Subdomain) _, err = DB.GetTXTForDomain(reg.Subdomain)
if err == nil { if err == nil {
t.Errorf("Expected error, but didn't get one") t.Errorf("Expected error, but didn't get one")
} }
} }
func TestQueryExecErrors(t *testing.T) { 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) { testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
return testResult{1, 0}, errors.New("Prepared query 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") t.Errorf("Expected error from exec, but got none")
} }
_, err = DB.GetByDomain(reg.Subdomain) _, err = DB.GetTXTForDomain(reg.Subdomain)
if err == nil { if err == nil {
t.Errorf("Expected error from exec in GetByDomain, but got none") t.Errorf("Expected error from exec in GetByDomain, but got none")
} }
_, err = DB.Register(cidrslice{}) _, err = DB.Register(acmedns.Cidrslice{})
if err == nil { if err == nil {
t.Errorf("Expected error from exec in Register, but got none") t.Errorf("Expected error from exec in Register, but got none")
} }
reg.Value = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" reg.Value = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
err = DB.Update(reg) err = DB.Update(reg.ACMETxtPost)
if err == nil { if err == nil {
t.Errorf("Expected error from exec in Update, but got none") 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) { 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) { testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
return testResult{1, 0}, errors.New("Prepared query error") return testResult{1, 0}, errors.New("Prepared query error")
@ -195,15 +199,11 @@ func TestQueryScanErrors(t *testing.T) {
if err == nil { if err == nil {
t.Errorf("Expected error from scan in, but got none") 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) { 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) { testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"} 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") t.Errorf("Expected error from scan in, but got none")
} }
_, err = DB.GetByDomain(reg.Subdomain) _, err = DB.GetTXTForDomain(reg.Subdomain)
if err == nil { if err == nil {
t.Errorf("Expected error from scan in GetByDomain, but got none") t.Errorf("Expected error from scan in GetByDomain, but got none")
} }
} }
func TestGetByDomain(t *testing.T) { func TestGetTXTForDomain(t *testing.T) {
var regDomain = ACMETxt{} DB := fakeDB()
// Create reg to refer to // Create reg to refer to
reg, err := DB.Register(cidrslice{}) reg, err := DB.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Registration failed, got error [%v]", err) 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 { if err != nil {
t.Errorf("Could not get test user, got error [%v]", err) t.Errorf("Could not get test user, got error [%v]", err)
} }
if len(regDomainSlice) == 0 { if len(regDomainSlice) == 0 {
t.Errorf("No rows returned for GetByDomain [%s]", reg.Subdomain) t.Errorf("No rows returned for GetTXTForDomain [%s]", reg.Subdomain)
} else {
regDomain = regDomainSlice[0]
} }
if reg.Username != regDomain.Username { var val1found = false
t.Errorf("GetByUsername username [%q] did not match the original [%q]", regDomain.Username, reg.Username) var val2found = false
for _, v := range regDomainSlice {
if v == txtval1 {
val1found = true
}
if v == txtval2 {
val2found = true
}
} }
if !val1found {
if reg.Subdomain != regDomain.Subdomain { t.Errorf("No TXT value found for val1")
t.Errorf("GetByUsername subdomain [%q] did not match the original [%q]", regDomain.Subdomain, reg.Subdomain)
} }
if !val2found {
// regDomain password already is a bcrypt hash t.Errorf("No TXT value found for val2")
if !correctPassword(reg.Password, regDomain.Password) {
t.Errorf("The password [%s] does not match the hash [%s]", reg.Password, regDomain.Password)
} }
// Not found // Not found
regNotfound, _ := DB.GetByDomain("does-not-exist") regNotfound, _ := DB.GetTXTForDomain("does-not-exist")
if len(regNotfound) > 0 { if len(regNotfound) > 0 {
t.Errorf("No records should be returned.") t.Errorf("No records should be returned.")
} }
} }
func TestUpdate(t *testing.T) { func TestUpdate(t *testing.T) {
DB := fakeDB()
// Create reg to refer to // Create reg to refer to
reg, err := DB.Register(cidrslice{}) reg, err := DB.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Registration failed, got error [%v]", err) t.Errorf("Registration failed, got error [%v]", err)
} }
@ -290,16 +301,8 @@ func TestUpdate(t *testing.T) {
regUser.Password = "nevergonnagiveyouup" regUser.Password = "nevergonnagiveyouup"
regUser.Value = validTXT regUser.Value = validTXT
err = DB.Update(regUser) err = DB.Update(regUser.ACMETxtPost)
if err != nil { if err != nil {
t.Errorf("DB Update failed, got error: [%v]", err) 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
pkg/nameserver/dns_test.go Normal file
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
pkg/nameserver/handler.go Normal file
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
}

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)
}
})
}
}

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
}

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
pkg/nameserver/util.go Normal file
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
}

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
}

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)
}
})
}
}

7
run_tests.sh Executable file
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
test/README.md Normal file
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
test/e2e/Dockerfile.e2e Normal file
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
test/e2e/config.cfg Normal file
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"

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:

View File

@ -0,0 +1,2 @@
requests
dnspython

105
test/e2e/tester.py Normal file
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)

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
umask_unix.go Normal file
View File

@ -0,0 +1,9 @@
//go:build !windows
package main
import "syscall"
func setUmask() {
syscall.Umask(0077)
}

7
umask_windows.go Normal file
View File

@ -0,0 +1,7 @@
//go:build windows
package main
func setUmask() {
// umask is not supported on Windows
}

82
util.go
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
}

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)
}
}
}
}
}

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
vendor/vendor.json vendored
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"
}