Compare commits

...

112 Commits
v0.2 ... 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
3049 changed files with 3808 additions and 1354334 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 }}

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ acme-dns.db
acme-dns.log acme-dns.log
.vagrant .vagrant
coverage.out coverage.out
.idea/
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,13 +0,0 @@
language: go
go:
- 1.9
env:
- "PATH=/home/travis/gopath/bin:$PATH"
before_install:
- go get github.com/golang/lint/golint
- go get github.com/mattn/goveralls
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

View File

@ -1,20 +1,23 @@
FROM golang:1.9.2-alpine AS builder FROM golang:alpine AS builder
LABEL maintainer="joona@kuori.org" LABEL maintainer="joona@kuori.org"
RUN apk add --update gcc musl-dev git RUN apk add --update git
RUN go get github.com/joohoi/acme-dns ENV GOPATH /tmp/buildcache
WORKDIR /go/src/github.com/joohoi/acme-dns RUN git clone https://github.com/joohoi/acme-dns /tmp/acme-dns
RUN CGO_ENABLED=1 go build WORKDIR /tmp/acme-dns
RUN CGO_ENABLED=0 go build
FROM alpine:latest FROM alpine:latest
WORKDIR /root/ WORKDIR /root/
COPY --from=builder /go/src/github.com/joohoi/acme-dns . COPY --from=builder /tmp/acme-dns .
RUN mkdir -p /etc/acme-dns RUN mkdir -p /etc/acme-dns
RUN mkdir -p /var/lib/acme-dns RUN mkdir -p /var/lib/acme-dns
RUN rm -rf ./config.cfg RUN rm -rf ./config.cfg
RUN apk --no-cache add ca-certificates && update-ca-certificates
VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"] VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"]
ENTRYPOINT ["./acme-dns"] ENTRYPOINT ["./acme-dns"]
EXPOSE 53 80 443 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

225
Gopkg.lock generated
View File

@ -1,225 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
name = "github.com/BurntSushi/toml"
packages = ["."]
revision = "b26d9c308763d68093482582cea63d69be07a0f0"
version = "v0.3.0"
[[projects]]
name = "github.com/ajg/form"
packages = ["."]
revision = "cc2954064ec9ea8d93917f0f87456e11d7b881ad"
version = "v1.5"
[[projects]]
name = "github.com/davecgh/go-spew"
packages = ["spew"]
revision = "346938d642f2ec3594ed81d874461961cd0faa76"
version = "v1.1.0"
[[projects]]
branch = "master"
name = "github.com/erikstmartin/go-testdb"
packages = ["."]
revision = "8d10e4a1bae52cd8b81ffdec3445890d6dccab3d"
[[projects]]
name = "github.com/fatih/structs"
packages = ["."]
revision = "a720dfa8df582c51dee1b36feabb906bde1588bd"
version = "v1.0"
[[projects]]
branch = "master"
name = "github.com/gavv/httpexpect"
packages = ["."]
revision = "c44a6d7bb636b17e880a53998a7f7061a56ffacb"
[[projects]]
branch = "master"
name = "github.com/gavv/monotime"
packages = ["."]
revision = "6f8212e8d10df7383609d3c377ca08884d8f3ec0"
[[projects]]
branch = "master"
name = "github.com/google/go-querystring"
packages = ["query"]
revision = "53e6ce116135b80d037921a7fdd5138cf32d7a8a"
[[projects]]
name = "github.com/imkira/go-interpol"
packages = ["."]
revision = "5accad8134979a6ac504d456a6c7f1c53da237ca"
version = "v1.1.0"
[[projects]]
name = "github.com/julienschmidt/httprouter"
packages = ["."]
revision = "8c199fb6259ffc1af525cc3ad52ee60ba8359669"
version = "v1.1"
[[projects]]
name = "github.com/klauspost/compress"
packages = ["flate","gzip","zlib"]
revision = "6c8db69c4b49dd4df1fff66996cf556176d0b9bf"
version = "v1.2.1"
[[projects]]
name = "github.com/klauspost/cpuid"
packages = ["."]
revision = "ae7887de9fa5d2db4eaa8174a7eff2c1ac00f2da"
version = "v1.1"
[[projects]]
name = "github.com/klauspost/crc32"
packages = ["."]
revision = "cb6bfca970f6908083f26f39a79009d608efd5cd"
version = "v1.1"
[[projects]]
branch = "master"
name = "github.com/lib/pq"
packages = [".","oid"]
revision = "27ea5d92de30060e7121ddd543fe14e9a327e0cc"
[[projects]]
name = "github.com/mattn/go-sqlite3"
packages = ["."]
revision = "6c771bb9887719704b210e87e934f08be014bdb1"
version = "v1.6.0"
[[projects]]
name = "github.com/miekg/dns"
packages = ["."]
revision = "5ec25f2a5044291b6c8abf43ed8a201da241e69e"
version = "v1.0.3"
[[projects]]
branch = "master"
name = "github.com/moul/http2curl"
packages = ["."]
revision = "9ac6cf4d929b2fa8fd2d2e6dec5bb0feb4f4911d"
[[projects]]
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
name = "github.com/rs/cors"
packages = ["."]
revision = "7af7a1e09ba336d2ea14b1ce73bf693c6837dbf6"
version = "v1.2"
[[projects]]
name = "github.com/satori/go.uuid"
packages = ["."]
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
version = "v1.2.0"
[[projects]]
branch = "master"
name = "github.com/sergi/go-diff"
packages = ["diffmatchpatch"]
revision = "1744e2970ca51c86172c8190fadad617561ed6e7"
[[projects]]
name = "github.com/sirupsen/logrus"
packages = [".","hooks/test"]
revision = "d682213848ed68c0a260ca37d6dd5ace8423f5ba"
version = "v1.0.4"
[[projects]]
name = "github.com/stretchr/testify"
packages = ["assert","require"]
revision = "b91bfb9ebec76498946beb6af7c0230c7cc7ba6c"
version = "v1.2.0"
[[projects]]
branch = "master"
name = "github.com/valyala/bytebufferpool"
packages = ["."]
revision = "e746df99fe4a3986f4d4f79e13c1e0117ce9c2f7"
[[projects]]
branch = "master"
name = "github.com/valyala/fasthttp"
packages = [".","fasthttputil","stackless"]
revision = "e5f51c11919d4f66400334047b897ef0a94c6f3c"
[[projects]]
branch = "master"
name = "github.com/xeipuuv/gojsonpointer"
packages = ["."]
revision = "6fe8760cad3569743d51ddbb243b26f8456742dc"
[[projects]]
branch = "master"
name = "github.com/xeipuuv/gojsonreference"
packages = ["."]
revision = "e02fc20de94c78484cd5ffb007f8af96be030a45"
[[projects]]
branch = "master"
name = "github.com/xeipuuv/gojsonschema"
packages = ["."]
revision = "511d08a359d14c0dd9c4302af52ee9abb6f93c2a"
[[projects]]
branch = "master"
name = "github.com/yalp/jsonpath"
packages = ["."]
revision = "31a79c7593bb93eb10b163650d4a3e6ca190e4dc"
[[projects]]
name = "github.com/yudai/gojsondiff"
packages = [".","formatter"]
revision = "7b1b7adf999dab73a6eb02669c3d82dbb27a3dd6"
version = "1.0.0"
[[projects]]
branch = "master"
name = "github.com/yudai/golcs"
packages = ["."]
revision = "ecda9a501e8220fae3b4b600c3db4b0ba22cfc68"
[[projects]]
branch = "master"
name = "golang.org/x/crypto"
packages = ["acme","acme/autocert","bcrypt","blowfish","ed25519","ed25519/internal/edwards25519","ssh/terminal"]
revision = "a6600008915114d9c087fad9f03d75087b1a74df"
[[projects]]
branch = "master"
name = "golang.org/x/net"
packages = ["bpf","idna","internal/iana","internal/socket","ipv4","ipv6","publicsuffix"]
revision = "5ccada7d0a7ba9aeb5d3aca8d3501b4c2a509fec"
[[projects]]
branch = "master"
name = "golang.org/x/sys"
packages = ["unix","windows"]
revision = "af50095a40f9041b3b38960738837185c26e9419"
[[projects]]
branch = "master"
name = "golang.org/x/text"
packages = ["collate","collate/build","internal/colltab","internal/gen","internal/tag","internal/triegen","internal/ucd","language","secure/bidirule","transform","unicode/bidi","unicode/cldr","unicode/norm","unicode/rangetable"]
revision = "e19ae1496984b1c655b8044a65c0300a3c878dd3"
[[projects]]
name = "gopkg.in/DATA-DOG/go-sqlmock.v1"
packages = ["."]
revision = "d76b18b42f285b792bf985118980ce9eacea9d10"
version = "v1.3.0"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "991ecc43a6e9bcfe3c39169d7509ee821076b0b75bbf7cb38ec06db3041cd009"
solver-name = "gps-cdcl"
solver-version = 1

View File

@ -1,76 +0,0 @@
# Gopkg.toml example
#
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
# Need to pin fasthttp so it doesn't get the old version from branch names
required = ["github.com/valyala/fasthttp"]
[[constraint]]
name = "github.com/valyala/fasthttp"
branch = "master"
[[constraint]]
name = "github.com/BurntSushi/toml"
version = "0.3.0"
[[constraint]]
branch = "master"
name = "github.com/erikstmartin/go-testdb"
[[constraint]]
branch = "master"
name = "github.com/gavv/httpexpect"
[[constraint]]
name = "github.com/julienschmidt/httprouter"
version = "1.1.0"
[[constraint]]
branch = "master"
name = "github.com/lib/pq"
[[constraint]]
name = "github.com/mattn/go-sqlite3"
version = "1.6.0"
[[constraint]]
name = "github.com/miekg/dns"
version = "1.0.3"
[[constraint]]
name = "github.com/rs/cors"
version = "1.2.0"
[[constraint]]
name = "github.com/satori/go.uuid"
version = "1.2.0"
[[constraint]]
name = "github.com/sirupsen/logrus"
version = "1.0.4"
[[constraint]]
branch = "master"
name = "golang.org/x/crypto"
[[constraint]]
name = "gopkg.in/DATA-DOG/go-sqlmock.v1"
version = "1.3.0"

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

224
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)
@ -22,13 +24,16 @@ So basically it boils down to **accessibility** and **security**
- 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): 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
@ -37,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```
@ -79,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___"
} }
``` ```
@ -95,83 +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.
## Installation ## Installation
1) Install [Go 1.9 or newer](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) Build ACME-DNS: `go build` 3) Move the built acme-dns binary to a directory in your $PATH, for example:
`sudo mv acme-dns /usr/local/bin`
4) Edit config.cfg to suit your needs (see [configuration](#configuration)) 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) 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. 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.
## Using Docker 1) Make sure that you have moved the configuration file to `/etc/acme-dns/config.cfg` so that acme-dns can access it globally.
1) Pull the latest acme-dns Docker image: `docker pull joohoi/acme-dns` 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. 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` 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. 4) Modify the `config.cfg` to suit your needs.
5) Run Docker, this example expects that you have `port = "80"` in your config.cfg: 5) Run Docker, this example expects that you have `port = "80"` in your `config.cfg`:
``` ```
docker run --rm --name acmedns \ docker run --rm --name acmedns \
-p 53:53 \ -p 53:53 \
-p 53:53/udp \
-p 80:80 \ -p 80:80 \
-v /path/to/your/config:/etc/acme-dns:ro \ -v /path/to/your/config:/etc/acme-dns:ro \
-v /path/to/your/data:/var/lib/acme-dns \ -v /path/to/your/data:/var/lib/acme-dns \
-d joohoi/acme-dns -d joohoi/acme-dns
``` ```
## Docker Compose ### Docker Compose
1) Create directories: `config` for the configuration file, and `data` for the sqlite3 database. 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` 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. 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` 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
@ -180,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"
@ -207,15 +294,50 @@ 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.2 Now powered by httprouter, support wildcard certificates, Docker images
- 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
@ -230,4 +352,4 @@ If you have an idea for improvement, please open an new issue or feel free to wr
## 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,81 +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
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 {
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.NewV4()
a.Password = password
a.Subdomain = uuid.NewV4().String()
return a
}

88
api.go
View File

@ -1,88 +0,0 @@
package main
import (
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
)
// 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 webRegisterPost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var regStatus int
var reg []byte
aTXT := ACMETxt{}
bdata, _ := ioutil.ReadAll(r.Body)
if bdata != nil && 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
}
}
// Create new user
nu, err := DB.Register(aTXT.AllowFrom)
if err != nil {
errstr := fmt.Sprintf("%v", err)
reg = jsonError(errstr)
regStatus = http.StatusInternalServerError
log.WithFields(log.Fields{"error": err.Error()}).Debug("Error in registration")
} else {
log.WithFields(log.Fields{"user": nu.Username.String()}).Debug("Created new user")
regStruct := RegResponse{nu.Username.String(), nu.Password, nu.Subdomain + "." + 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")
log.WithFields(log.Fields{"error": "json"}).Debug("Could not marshal JSON")
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(regStatus)
w.Write(reg)
}
func webUpdatePost(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
var updStatus int
var upd []byte
// Get user
a, ok := r.Context().Value(ACMETxtKey).(ACMETxt)
if !ok {
log.WithFields(log.Fields{"error": "context"}).Error("Context error")
}
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")
updStatus = http.StatusInternalServerError
upd = jsonError("db_error")
} else {
log.WithFields(log.Fields{"subdomain": a.Subdomain, "txt": a.Value}).Debug("TXT updated")
updStatus = http.StatusOK
upd = []byte("{\"txt\": \"" + a.Value + "\"}")
}
} else {
log.WithFields(log.Fields{"error": "subdomain", "subdomain": a.Subdomain, "txt": a.Value}).Debug("Bad update data")
updStatus = http.StatusBadRequest
upd = jsonError("bad_subdomain")
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(updStatus)
w.Write(upd)
}

87
auth.go
View File

@ -1,87 +0,0 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/julienschmidt/httprouter"
log "github.com/sirupsen/logrus"
)
type key int
// ACMETxtKey is a context key for ACMETxt struct
const ACMETxtKey key = 0
// Auth middleware for update request
func Auth(update httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
postData := ACMETxt{}
userOK := false
user, err := getUserFromRequest(r)
if err == nil {
if updateAllowedFromIP(r, user) {
dec := json.NewDecoder(r.Body)
err = dec.Decode(&postData)
if err != nil {
log.WithFields(log.Fields{"error": "json_error", "string": err.Error()}).Error("Decode error")
}
if user.Subdomain == postData.Subdomain {
userOK = true
} else {
log.WithFields(log.Fields{"error": "subdomain_mismatch", "name": postData.Subdomain, "expected": user.Subdomain}).Error("Subdomain mismatch")
}
} else {
log.WithFields(log.Fields{"error": "ip_unauthorized"}).Error("Update not allowed from IP")
}
} else {
log.WithFields(log.Fields{"error": err.Error()}).Error("Error while trying to get user")
}
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 getUserFromRequest(r *http.Request) (ACMETxt, error) {
uname := r.Header.Get("X-Api-User")
passwd := r.Header.Get("X-Api-Key")
username, err := getValidUsername(uname)
if err != nil {
return ACMETxt{}, fmt.Errorf("Invalid username: %s: %s", uname, err.Error())
}
if validKey(passwd) {
dbuser, 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(passwd, "$2a$10$8JEFVNYYhLoBysjAxe2yBuXrkDojBQBkVpXEQgyQyjn43SvJ4vL36")
return ACMETxt{}, fmt.Errorf("Invalid username: %s", uname)
}
if correctPassword(passwd, dbuser.Password) {
return dbuser, nil
}
return ACMETxt{}, fmt.Errorf("Invalid password for user %s", uname)
}
return ACMETxt{}, fmt.Errorf("Invalid key for user %s", uname)
}
func updateAllowedFromIP(r *http.Request, user ACMETxt) bool {
if Config.API.UseHeader {
ips := getIPListFromHeader(r.Header.Get(Config.API.HeaderName))
return user.allowedFromList(ips)
}
return user.allowedFrom(r.RemoteAddr)
}

View File

@ -1,48 +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 # 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 = "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"
api_domain = ""
# listen ip eg. 127.0.0.1 # listen ip eg. 127.0.0.1
ip = "0.0.0.0" 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 = "80" 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 = [
"*" "*"
@ -54,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"

106
dns.go
View File

@ -1,106 +0,0 @@
package main
import (
"fmt"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"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.GetTXTForDomain(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) > 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)
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)
}
}
}
}

View File

@ -8,6 +8,7 @@ services:
ports: ports:
- "443:443" - "443:443"
- "53:53" - "53:53"
- "53:53/udp"
- "80:80" - "80:80"
volumes: volumes:
- ./config:/etc/acme-dns:ro - ./config:/etc/acme-dns:ro

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=

127
main.go
View File

@ -1,108 +1,55 @@
//+build !test
package main package main
import ( import (
"crypto/tls" "flag"
stdlog "log" "fmt"
"net/http"
"os" "os"
"github.com/julienschmidt/httprouter" "github.com/joohoi/acme-dns/pkg/acmedns"
"github.com/rs/cors" "github.com/joohoi/acme-dns/pkg/api"
log "github.com/sirupsen/logrus" "github.com/joohoi/acme-dns/pkg/database"
"golang.org/x/crypto/acme/autocert" "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
if fileExists("/etc/acme-dns/config.cfg") { var err error
Config = readConfig("/etc/acme-dns/config.cfg") var logger *zap.Logger
log.WithFields(log.Fields{"file": "/etc/acme-dns/config.cfg"}).Info("Using config file") config, usedConfigFile, err := acmedns.ReadConfig(*configPtr, "./config.cfg")
} else {
log.WithFields(log.Fields{"file": "./config.cfg"}).Info("Using config file")
Config = readConfig("config.cfg")
}
setupLogging(Config.Logconfig.Format, Config.Logconfig.Level)
// Read the default records in
RR.Parse(Config.General)
// Open database
newDB := new(acmedb)
err := newDB.Init(Config.Database.Engine, Config.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)
} else {
log.Info("Connected to database")
} }
DB = newDB logger, err = acmedns.SetupLogging(config)
defer DB.Close() if err != nil {
fmt.Printf("Could not set up logging: %s\n", err)
// DNS server os.Exit(1)
startDNS(Config.General.Listen, Config.General.Proto)
// HTTP API
startHTTPAPI()
log.Debugf("Shutting down...")
}
func startHTTPAPI() {
// Setup http logger
logger := log.New()
logwriter := logger.Writer()
defer logwriter.Close()
api := httprouter.New()
c := cors.New(cors.Options{
AllowedOrigins: Config.API.CorsOrigins,
AllowedMethods: []string{"GET", "POST"},
OptionsPassthrough: false,
Debug: Config.General.Debug,
})
if Config.General.Debug {
// Logwriter for saner log output
c.Log = stdlog.New(logwriter, "", 0)
} }
api.POST("/register", webRegisterPost) // Make sure to flush the zap logger buffer before exiting
api.POST("/update", Auth(webUpdatePost)) defer logger.Sync() //nolint:all
sugar := logger.Sugar()
host := Config.API.IP + ":" + Config.API.Port sugar.Infow("Using config file",
"file", usedConfigFile)
cfg := &tls.Config{ sugar.Info("Starting up")
MinVersion: tls.VersionTLS12, db, err := database.Init(&config, sugar)
// Error channel for servers
errChan := make(chan error, 1)
api := api.Init(&config, db, sugar, errChan)
dnsservers := nameserver.InitAndStart(&config, db, sugar, errChan)
go api.Start(dnsservers)
if err != nil {
sugar.Error(err)
} }
for {
switch Config.API.TLS { err = <-errChan
case "letsencrypt": if err != nil {
m := autocert.Manager{ sugar.Fatal(err)
Cache: autocert.DirCache("api-certs"),
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist(Config.API.Domain),
} }
cfg.GetCertificate = m.GetCertificate
srv := &http.Server{
Addr: host,
Handler: c.Handler(api),
TLSConfig: cfg,
ErrorLog: stdlog.New(logwriter, "", 0),
}
log.WithFields(log.Fields{"host": host, "domain": Config.API.Domain}).Info("Listening HTTPS autocert")
log.Fatal(srv.ListenAndServeTLS("", ""))
case "cert":
srv := &http.Server{
Addr: host,
Handler: c.Handler(api),
TLSConfig: cfg,
ErrorLog: stdlog.New(logwriter, "", 0),
}
log.WithFields(log.Fields{"host": host}).Info("Listening HTTPS")
log.Fatal(srv.ListenAndServeTLS(Config.API.TLSCertFullchain, Config.API.TLSCertPrivkey))
default:
log.WithFields(log.Fields{"host": host}).Info("Listening HTTP")
log.Fatal(http.ListenAndServe(host, c.Handler(api)))
} }
} }

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(Config.General)
flag.Parse()
newDb := new(acmedb)
if *postgres {
Config.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 {
Config.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,
}
Config = 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
}

View File

@ -1,4 +1,4 @@
package main package api
import ( import (
"context" "context"
@ -8,24 +8,38 @@ import (
"net/http/httptest" "net/http/httptest"
"testing" "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/gavv/httpexpect"
"github.com/google/uuid"
"github.com/julienschmidt/httprouter" "github.com/julienschmidt/httprouter"
"github.com/rs/cors" "github.com/rs/cors"
"github.com/satori/go.uuid" "go.uber.org/zap"
"gopkg.in/DATA-DOG/go-sqlmock.v1"
) )
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 // noAuth function to write ACMETxt model to context while not preforming any validation
func noAuth(update httprouter.Handle) httprouter.Handle { func noAuth(update httprouter.Handle) httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
postData := ACMETxt{} postData := acmedns.ACMETxt{}
uname := r.Header.Get("X-Api-User") uname := r.Header.Get("X-Api-User")
passwd := r.Header.Get("X-Api-Key") passwd := r.Header.Get("X-Api-Key")
dec := json.NewDecoder(r.Body) dec := json.NewDecoder(r.Body)
_ = dec.Decode(&postData) _ = dec.Decode(&postData)
// Set user info to the decoded ACMETxt object // Set user info to the decoded ACMETxt object
postData.Username, _ = uuid.FromString(uname) postData.Username, _ = uuid.Parse(uname)
postData.Password = passwd postData.Password = passwd
// Set the ACMETxt struct to context to pull in from update function // Set the ACMETxt struct to context to pull in from update function
ctx := r.Context() ctx := r.Context()
@ -46,41 +60,37 @@ func getExpect(t *testing.T, server *httptest.Server) *httpexpect.Expect {
}) })
} }
func setupRouter(debug bool, noauth bool) http.Handler { func setupRouter(debug bool, noauth bool) (http.Handler, AcmednsAPI, acmedns.AcmednsDB) {
api := httprouter.New() api := httprouter.New()
var dbcfg = dbsettings{ config, logger := fakeConfigAndLogger()
Engine: "sqlite3", config.API.Domain = ""
Connection: ":memory:"} config.API.Port = "8080"
var httpapicfg = httpapi{ config.API.TLS = acmedns.ApiTlsProviderNone
Domain: "", config.API.CorsOrigins = []string{"*"}
Port: "8080", config.API.UseHeader = true
TLS: "none", config.API.HeaderName = "X-Forwarded-For"
CorsOrigins: []string{"*"},
UseHeader: true, db, _ := database.Init(&config, logger)
HeaderName: "X-Forwarded-For", errChan := make(chan error, 1)
} adnsapi := Init(&config, db, logger, errChan)
var dnscfg = DNSConfig{
API: httpapicfg,
Database: dbcfg,
}
Config = dnscfg
c := cors.New(cors.Options{ c := cors.New(cors.Options{
AllowedOrigins: Config.API.CorsOrigins, AllowedOrigins: config.API.CorsOrigins,
AllowedMethods: []string{"GET", "POST"}, AllowedMethods: []string{"GET", "POST"},
OptionsPassthrough: false, OptionsPassthrough: false,
Debug: Config.General.Debug, Debug: config.General.Debug,
}) })
api.POST("/register", webRegisterPost) api.POST("/register", adnsapi.webRegisterPost)
api.GET("/health", adnsapi.healthCheck)
if noauth { if noauth {
api.POST("/update", noAuth(webUpdatePost)) api.POST("/update", noAuth(adnsapi.webUpdatePost))
} else { } else {
api.POST("/update", Auth(webUpdatePost)) api.POST("/update", adnsapi.Auth(adnsapi.webUpdatePost))
} }
return c.Handler(api) return c.Handler(api), adnsapi, db
} }
func TestApiRegister(t *testing.T) { func TestApiRegister(t *testing.T) {
router := setupRouter(false, false) router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
@ -94,9 +104,10 @@ func TestApiRegister(t *testing.T) {
NotContainsKey("error") NotContainsKey("error")
allowfrom := map[string][]interface{}{ allowfrom := map[string][]interface{}{
"allowfrom": []interface{}{"123.123.123.123/32", "allowfrom": {"123.123.123.123/32",
"1010.10.10.10/24", "2001:db8:a0b:12f0::1/32",
"invalid"}, "[::1]/64",
},
} }
response := e.POST("/register"). response := e.POST("/register").
@ -111,11 +122,41 @@ func TestApiRegister(t *testing.T) {
ContainsKey("allowfrom"). ContainsKey("allowfrom").
NotContainsKey("error") NotContainsKey("error")
response.Value("allowfrom").Array().Elements("123.123.123.123/32") 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) { func TestApiRegisterMalformedJSON(t *testing.T) {
router := setupRouter(false, false) router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
@ -142,13 +183,13 @@ func TestApiRegisterMalformedJSON(t *testing.T) {
} }
func TestApiRegisterWithMockDB(t *testing.T) { func TestApiRegisterWithMockDB(t *testing.T) {
router := setupRouter(false, false) router, _, db := setupRouter(false, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
oldDb := DB.GetBackend() oldDb := db.GetBackend()
db, mock, _ := sqlmock.New() mdb, mock, _ := sqlmock.New()
DB.SetBackend(db) db.SetBackend(mdb)
defer db.Close() defer db.Close()
mock.ExpectBegin() mock.ExpectBegin()
mock.ExpectPrepare("INSERT INTO records").WillReturnError(errors.New("error")) mock.ExpectPrepare("INSERT INTO records").WillReturnError(errors.New("error"))
@ -156,11 +197,71 @@ func TestApiRegisterWithMockDB(t *testing.T) {
Status(http.StatusInternalServerError). Status(http.StatusInternalServerError).
JSON().Object(). JSON().Object().
ContainsKey("error") ContainsKey("error")
DB.SetBackend(oldDb) 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) { func TestApiUpdateWithoutCredentials(t *testing.T) {
router := setupRouter(false, false) router, _, _ := setupRouter(false, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
@ -178,11 +279,11 @@ func TestApiUpdateWithCredentials(t *testing.T) {
"subdomain": "", "subdomain": "",
"txt": ""} "txt": ""}
router := setupRouter(false, false) router, _, db := setupRouter(false, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
newUser, err := DB.Register(cidrslice{}) newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Could not create new user, got error [%v]", err) t.Errorf("Could not create new user, got error [%v]", err)
} }
@ -211,13 +312,13 @@ func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8" updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8"
updateJSON["txt"] = validTxtData updateJSON["txt"] = validTxtData
router := setupRouter(false, true) router, _, db := setupRouter(false, true)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
oldDb := DB.GetBackend() oldDb := db.GetBackend()
db, mock, _ := sqlmock.New() mdb, mock, _ := sqlmock.New()
DB.SetBackend(db) db.SetBackend(mdb)
defer db.Close() defer db.Close()
mock.ExpectBegin() mock.ExpectBegin()
mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error")) mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error"))
@ -227,35 +328,31 @@ func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
Status(http.StatusInternalServerError). Status(http.StatusInternalServerError).
JSON().Object(). JSON().Object().
ContainsKey("error") ContainsKey("error")
DB.SetBackend(oldDb) db.SetBackend(oldDb)
} }
func TestApiManyUpdateWithCredentials(t *testing.T) { func TestApiManyUpdateWithCredentials(t *testing.T) {
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
updateJSON := map[string]interface{}{ router, _, db := setupRouter(true, false)
"subdomain": "",
"txt": ""}
router := setupRouter(true, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
// User without defined CIDR masks // User without defined CIDR masks
newUser, err := DB.Register(cidrslice{}) newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Could not create new user, got error [%v]", err) t.Errorf("Could not create new user, got error [%v]", err)
} }
// User with defined allow from - CIDR masks, all invalid // User with defined allow from - CIDR masks, all invalid
// (httpexpect doesn't provide a way to mock remote ip) // (httpexpect doesn't provide a way to mock remote ip)
newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.1/32", "invalid"}) newUserWithCIDR, err := db.Register(acmedns.Cidrslice{"192.168.1.1/32", "invalid"})
if err != nil { if err != nil {
t.Errorf("Could not create new user with CIDR, got error [%v]", err) t.Errorf("Could not create new user with CIDR, got error [%v]", err)
} }
// Another user with valid CIDR mask to match the httpexpect default // Another user with valid CIDR mask to match the httpexpect default
newUserWithValidCIDR, err := DB.Register(cidrslice{"10.1.2.3/32", "invalid"}) newUserWithValidCIDR, err := db.Register(acmedns.Cidrslice{"10.1.2.3/32", "invalid"})
if err != nil { if err != nil {
t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err) t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err)
} }
@ -278,7 +375,7 @@ func TestApiManyUpdateWithCredentials(t *testing.T) {
{newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200}, {newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200},
{newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401}, {newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401},
} { } {
updateJSON = map[string]interface{}{ updateJSON := map[string]interface{}{
"subdomain": test.subdomain, "subdomain": test.subdomain,
"txt": test.txt} "txt": test.txt}
e.POST("/update"). e.POST("/update").
@ -293,34 +390,30 @@ func TestApiManyUpdateWithCredentials(t *testing.T) {
func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) { func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
updateJSON := map[string]interface{}{ router, adnsapi, db := setupRouter(false, false)
"subdomain": "",
"txt": ""}
router := setupRouter(false, false)
server := httptest.NewServer(router) server := httptest.NewServer(router)
defer server.Close() defer server.Close()
e := getExpect(t, server) e := getExpect(t, server)
// Use header checks from default header (X-Forwarded-For) // Use header checks from default header (X-Forwarded-For)
Config.API.UseHeader = true adnsapi.Config.API.UseHeader = true
// User without defined CIDR masks // User without defined CIDR masks
newUser, err := DB.Register(cidrslice{}) newUser, err := db.Register(acmedns.Cidrslice{})
if err != nil { if err != nil {
t.Errorf("Could not create new user, got error [%v]", err) t.Errorf("Could not create new user, got error [%v]", err)
} }
newUserWithCIDR, err := DB.Register(cidrslice{"192.168.1.2/32", "invalid"}) newUserWithCIDR, err := db.Register(acmedns.Cidrslice{"192.168.1.2/32", "invalid"})
if err != nil { if err != nil {
t.Errorf("Could not create new user with CIDR, got error [%v]", err) t.Errorf("Could not create new user with CIDR, got error [%v]", err)
} }
newUserWithIP6CIDR, err := DB.Register(cidrslice{"2002:c0a8::0/32"}) newUserWithIP6CIDR, err := db.Register(acmedns.Cidrslice{"2002:c0a8::0/32"})
if err != nil { if err != nil {
t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err) t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err)
} }
for _, test := range []struct { for _, test := range []struct {
user ACMETxt user acmedns.ACMETxt
headerValue string headerValue string
status int status int
}{ }{
@ -333,7 +426,7 @@ func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
{newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401}, {newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401},
{newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200}, {newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200},
} { } {
updateJSON = map[string]interface{}{ updateJSON := map[string]interface{}{
"subdomain": test.user.Subdomain, "subdomain": test.user.Subdomain,
"txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} "txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
e.POST("/update"). e.POST("/update").
@ -344,5 +437,97 @@ func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
Expect(). Expect().
Status(test.status) Status(test.status)
} }
Config.API.UseHeader = false 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) {

View File

@ -1,21 +1,31 @@
package main package database
import ( import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"regexp" "regexp"
"strconv" "strconv"
"sync"
"time" "time"
_ "github.com/glebarez/go-sqlite"
_ "github.com/lib/pq" _ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
"github.com/satori/go.uuid" "github.com/google/uuid"
log "github.com/sirupsen/logrus" "go.uber.org/zap"
"golang.org/x/crypto/bcrypt" "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. // DBVersion shows the database version this code uses. This is used for update checks.
var DBVersion = 1 var DBVersion = 1
@ -50,16 +60,17 @@ var txtTablePG = `
// getSQLiteStmt replaces all PostgreSQL prepared statement placeholders (eg. $1, $2) with SQLite variant "?" // getSQLiteStmt replaces all PostgreSQL prepared statement placeholders (eg. $1, $2) with SQLite variant "?"
func getSQLiteStmt(s string) string { func getSQLiteStmt(s string) string {
re, _ := regexp.Compile("\\$[0-9]") re, _ := regexp.Compile(`\$[0-9]`)
return re.ReplaceAllString(s, "?") return re.ReplaceAllString(s, "?")
} }
func (d *acmedb) Init(engine string, connection string) error { func Init(config *acmedns.AcmeDnsConfig, logger *zap.SugaredLogger) (acmedns.AcmednsDB, error) {
d.Lock() var d = &acmednsdb{Config: config, Logger: logger}
defer d.Unlock() d.Mutex.Lock()
db, err := sql.Open(engine, connection) defer d.Mutex.Unlock()
db, err := sql.Open(config.Database.Engine, config.Database.Connection)
if err != nil { if err != nil {
return err return d, err
} }
d.DB = db d.DB = db
// Check version first to try to catch old versions without version string // Check version first to try to catch old versions without version string
@ -68,12 +79,12 @@ func (d *acmedb) Init(engine string, connection string) error {
if versionString == "" { if versionString == "" {
versionString = "0" versionString = "0"
} }
_, err = d.DB.Exec(acmeTable) _, _ = d.DB.Exec(acmeTable)
_, err = d.DB.Exec(userTable) _, _ = d.DB.Exec(userTable)
if Config.Database.Engine == "sqlite3" { if config.Database.Engine == "sqlite" {
_, err = d.DB.Exec(txtTable) _, _ = d.DB.Exec(txtTable)
} else { } else {
_, err = d.DB.Exec(txtTablePG) _, _ = d.DB.Exec(txtTablePG)
} }
// If everything is fine, handle db upgrade tasks // If everything is fine, handle db upgrade tasks
if err == nil { if err == nil {
@ -86,10 +97,10 @@ func (d *acmedb) Init(engine string, connection string) error {
_, err = db.Exec(insversion) _, err = db.Exec(insversion)
} }
} }
return err return d, err
} }
func (d *acmedb) checkDBUpgrades(versionString string) error { func (d *acmednsdb) checkDBUpgrades(versionString string) error {
var err error var err error
version, err := strconv.Atoi(versionString) version, err := strconv.Atoi(versionString)
if err != nil { if err != nil {
@ -102,19 +113,20 @@ func (d *acmedb) checkDBUpgrades(versionString string) error {
} }
func (d *acmedb) handleDBUpgrades(version int) error { func (d *acmednsdb) handleDBUpgrades(version int) error {
if version == 0 { if version == 0 {
return d.handleDBUpgradeTo1() return d.handleDBUpgradeTo1()
} }
return nil return nil
} }
func (d *acmedb) handleDBUpgradeTo1() error { func (d *acmednsdb) handleDBUpgradeTo1() error {
var err error var err error
var subdomains []string var subdomains []string
rows, err := d.DB.Query("SELECT Subdomain FROM records") rows, err := d.DB.Query("SELECT Subdomain FROM records")
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Error in DB upgrade") d.Logger.Errorw("Error in DB upgrade",
"error", err.Error())
return err return err
} }
defer rows.Close() defer rows.Close()
@ -122,24 +134,26 @@ func (d *acmedb) handleDBUpgradeTo1() error {
var subdomain string var subdomain string
err = rows.Scan(&subdomain) err = rows.Scan(&subdomain)
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Error in DB upgrade while reading values") d.Logger.Errorw("Error in DB upgrade while reading values",
"error", err.Error())
return err return err
} }
subdomains = append(subdomains, subdomain) subdomains = append(subdomains, subdomain)
} }
err = rows.Err() err = rows.Err()
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Error in DB upgrade while inserting values") d.Logger.Errorw("Error in DB upgrade while inserting values",
"error", err.Error())
return err return err
} }
tx, err := d.DB.Begin() tx, err := d.DB.Begin()
// Rollback if errored, commit if not // Rollback if errored, commit if not
defer func() { defer func() {
if err != nil { if err != nil {
tx.Rollback() _ = tx.Rollback()
return return
} }
tx.Commit() _ = tx.Commit()
}() }()
_, _ = tx.Exec("DELETE FROM txt") _, _ = tx.Exec("DELETE FROM txt")
for _, subdomain := range subdomains { for _, subdomain := range subdomains {
@ -147,13 +161,14 @@ func (d *acmedb) handleDBUpgradeTo1() error {
// Insert two rows for each subdomain to txt table // Insert two rows for each subdomain to txt table
err = d.NewTXTValuesInTransaction(tx, subdomain) err = d.NewTXTValuesInTransaction(tx, subdomain)
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Error in DB upgrade while inserting values") d.Logger.Errorw("Error in DB upgrade while inserting values",
"error", err.Error())
return err return err
} }
} }
} }
// SQLite doesn't support dropping columns // SQLite doesn't support dropping columns
if Config.Database.Engine != "sqlite3" { 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 Value")
_, _ = tx.Exec("ALTER TABLE records DROP COLUMN IF EXISTS LastActive") _, _ = tx.Exec("ALTER TABLE records DROP COLUMN IF EXISTS LastActive")
} }
@ -161,30 +176,30 @@ func (d *acmedb) handleDBUpgradeTo1() error {
return err return err
} }
// Create two rows for subdomain to the txt table // NewTXTValuesInTransaction creates two rows for subdomain to the txt table
func (d *acmedb) NewTXTValuesInTransaction(tx *sql.Tx, subdomain string) error { func (d *acmednsdb) NewTXTValuesInTransaction(tx *sql.Tx, subdomain string) error {
var err error var err error
instr := fmt.Sprintf("INSERT INTO txt (Subdomain, LastUpdate) values('%s', 0)", subdomain) instr := fmt.Sprintf("INSERT INTO txt (Subdomain, LastUpdate) values('%s', 0)", subdomain)
_, err = tx.Exec(instr) _, _ = tx.Exec(instr)
_, err = tx.Exec(instr) _, _ = tx.Exec(instr)
return err return err
} }
func (d *acmedb) Register(afrom cidrslice) (ACMETxt, error) { func (d *acmednsdb) Register(afrom acmedns.Cidrslice) (acmedns.ACMETxt, error) {
d.Lock() d.Mutex.Lock()
defer d.Unlock() defer d.Mutex.Unlock()
var err error var err error
tx, err := d.DB.Begin() tx, err := d.DB.Begin()
// Rollback if errored, commit if not // Rollback if errored, commit if not
defer func() { defer func() {
if err != nil { if err != nil {
tx.Rollback() _ = tx.Rollback()
return return
} }
tx.Commit() _ = tx.Commit()
}() }()
a := newACMETxt() a := acmedns.NewACMETxt()
a.AllowFrom = cidrslice(afrom.ValidEntries()) a.AllowFrom = acmedns.Cidrslice(afrom.ValidEntries())
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.Password), 10) passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.Password), 10)
regSQL := ` regSQL := `
INSERT INTO records( INSERT INTO records(
@ -193,13 +208,14 @@ func (d *acmedb) Register(afrom cidrslice) (ACMETxt, error) {
Subdomain, Subdomain,
AllowFrom) AllowFrom)
values($1, $2, $3, $4)` values($1, $2, $3, $4)`
if Config.Database.Engine == "sqlite3" { if d.Config.Database.Engine == "sqlite" {
regSQL = getSQLiteStmt(regSQL) regSQL = getSQLiteStmt(regSQL)
} }
sm, err := tx.Prepare(regSQL) sm, err := tx.Prepare(regSQL)
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Database error in prepare") d.Logger.Errorw("Database error in prepare",
return a, errors.New("SQL error") "error", err.Error())
return a, fmt.Errorf("failed to prepare registration statement: %w", err)
} }
defer sm.Close() defer sm.Close()
_, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, a.AllowFrom.JSON()) _, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, a.AllowFrom.JSON())
@ -209,53 +225,53 @@ func (d *acmedb) Register(afrom cidrslice) (ACMETxt, error) {
return a, err return a, err
} }
func (d *acmedb) GetByUsername(u uuid.UUID) (ACMETxt, error) { func (d *acmednsdb) GetByUsername(u uuid.UUID) (acmedns.ACMETxt, error) {
d.Lock() d.Mutex.Lock()
defer d.Unlock() defer d.Mutex.Unlock()
var results []ACMETxt var results []acmedns.ACMETxt
getSQL := ` getSQL := `
SELECT Username, Password, Subdomain, AllowFrom SELECT Username, Password, Subdomain, AllowFrom
FROM records FROM records
WHERE Username=$1 LIMIT 1 WHERE Username=$1 LIMIT 1
` `
if Config.Database.Engine == "sqlite3" { if d.Config.Database.Engine == "sqlite" {
getSQL = getSQLiteStmt(getSQL) getSQL = getSQLiteStmt(getSQL)
} }
sm, err := d.DB.Prepare(getSQL) sm, err := d.DB.Prepare(getSQL)
if err != nil { if err != nil {
return ACMETxt{}, err return acmedns.ACMETxt{}, err
} }
defer sm.Close() defer sm.Close()
rows, err := sm.Query(u.String()) rows, err := sm.Query(u.String())
if err != nil { if err != nil {
return ACMETxt{}, err return acmedns.ACMETxt{}, fmt.Errorf("failed to query user: %w", err)
} }
defer rows.Close() defer rows.Close()
// It will only be one row though // It will only be one row though
for rows.Next() { for rows.Next() {
txt, err := getModelFromRow(rows) txt, err := d.getModelFromRow(rows)
if err != nil { if err != nil {
return ACMETxt{}, err return acmedns.ACMETxt{}, err
} }
results = append(results, txt) results = append(results, txt)
} }
if len(results) > 0 { if len(results) > 0 {
return results[0], nil return results[0], nil
} }
return ACMETxt{}, errors.New("no user") return acmedns.ACMETxt{}, fmt.Errorf("user not found: %s", u.String())
} }
func (d *acmedb) GetTXTForDomain(domain string) ([]string, error) { func (d *acmednsdb) GetTXTForDomain(domain string) ([]string, error) {
d.Lock() d.Mutex.Lock()
defer d.Unlock() defer d.Mutex.Unlock()
domain = sanitizeString(domain) domain = acmedns.SanitizeString(domain)
var txts []string var txts []string
getSQL := ` getSQL := `
SELECT Value FROM txt WHERE Subdomain=$1 LIMIT 2 SELECT Value FROM txt WHERE Subdomain=$1 LIMIT 2
` `
if Config.Database.Engine == "sqlite3" { if d.Config.Database.Engine == "sqlite" {
getSQL = getSQLiteStmt(getSQL) getSQL = getSQLiteStmt(getSQL)
} }
@ -281,9 +297,9 @@ func (d *acmedb) GetTXTForDomain(domain string) ([]string, error) {
return txts, nil return txts, nil
} }
func (d *acmedb) Update(a ACMETxt) error { func (d *acmednsdb) Update(a acmedns.ACMETxtPost) error {
d.Lock() d.Mutex.Lock()
defer d.Unlock() defer d.Mutex.Unlock()
var err error var err error
// Data in a is already sanitized // Data in a is already sanitized
timenow := time.Now().Unix() timenow := time.Now().Unix()
@ -293,7 +309,7 @@ func (d *acmedb) Update(a ACMETxt) error {
WHERE rowid=( WHERE rowid=(
SELECT rowid FROM txt WHERE Subdomain=$3 ORDER BY LastUpdate LIMIT 1) SELECT rowid FROM txt WHERE Subdomain=$3 ORDER BY LastUpdate LIMIT 1)
` `
if Config.Database.Engine == "sqlite3" { if d.Config.Database.Engine == "sqlite" {
updSQL = getSQLiteStmt(updSQL) updSQL = getSQLiteStmt(updSQL)
} }
@ -309,8 +325,8 @@ func (d *acmedb) Update(a ACMETxt) error {
return nil return nil
} }
func getModelFromRow(r *sql.Rows) (ACMETxt, error) { func (d *acmednsdb) getModelFromRow(r *sql.Rows) (acmedns.ACMETxt, error) {
txt := ACMETxt{} txt := acmedns.ACMETxt{}
afrom := "" afrom := ""
err := r.Scan( err := r.Scan(
&txt.Username, &txt.Username,
@ -318,26 +334,28 @@ func getModelFromRow(r *sql.Rows) (ACMETxt, error) {
&txt.Subdomain, &txt.Subdomain,
&afrom) &afrom)
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("Row scan error") d.Logger.Errorw("Row scan error",
"error", err.Error())
} }
cslice := cidrslice{} cslice := acmedns.Cidrslice{}
err = json.Unmarshal([]byte(afrom), &cslice) err = json.Unmarshal([]byte(afrom), &cslice)
if err != nil { if err != nil {
log.WithFields(log.Fields{"error": err.Error()}).Error("JSON unmarshall error") d.Logger.Errorw("JSON unmarshall error",
"error", err.Error())
} }
txt.AllowFrom = cslice txt.AllowFrom = cslice
return txt, err return txt, err
} }
func (d *acmedb) Close() { func (d *acmednsdb) Close() {
d.DB.Close() d.DB.Close()
} }
func (d *acmedb) GetBackend() *sql.DB { func (d *acmednsdb) GetBackend() *sql.DB {
return d.DB return d.DB
} }
func (d *acmedb) SetBackend(backend *sql.DB) { func (d *acmednsdb) SetBackend(backend *sql.DB) {
d.DB = backend 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)
@ -125,7 +127,8 @@ func TestPrepareErrors(t *testing.T) {
} }
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")
}) })
@ -156,12 +159,12 @@ func TestQueryExecErrors(t *testing.T) {
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")
@ -198,7 +202,8 @@ func TestQueryScanErrors(t *testing.T) {
} }
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"}
@ -228,8 +233,9 @@ func TestBadDBValues(t *testing.T) {
} }
func TestGetTXTForDomain(t *testing.T) { func TestGetTXTForDomain(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)
} }
@ -238,10 +244,10 @@ func TestGetTXTForDomain(t *testing.T) {
txtval2 := "___validation_token_received_YEAH_the_ca___" txtval2 := "___validation_token_received_YEAH_the_ca___"
reg.Value = txtval1 reg.Value = txtval1
_ = DB.Update(reg) _ = DB.Update(reg.ACMETxtPost)
reg.Value = txtval2 reg.Value = txtval2
_ = DB.Update(reg) _ = DB.Update(reg.ACMETxtPost)
regDomainSlice, err := DB.GetTXTForDomain(reg.Subdomain) regDomainSlice, err := DB.GetTXTForDomain(reg.Subdomain)
if err != nil { if err != nil {
@ -276,8 +282,9 @@ func TestGetTXTForDomain(t *testing.T) {
} }
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)
} }
@ -294,7 +301,7 @@ 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)
} }

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

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,88 +0,0 @@
package main
import (
"database/sql"
"github.com/miekg/dns"
"github.com/satori/go.uuid"
"sync"
)
// Config is global configuration struct
var Config 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"`
IP string
Port string `toml:"port"`
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)
GetTXTForDomain(string) ([]string, 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
}

96
util.go
View File

@ -1,96 +0,0 @@
package main
import (
"crypto/rand"
"fmt"
"math/big"
"os"
"regexp"
"strings"
"github.com/BurntSushi/toml"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
)
func jsonError(message string) []byte {
return []byte(fmt.Sprintf("{\"error\": \"%s\"}", message))
}
func fileExists(fname string) bool {
_, err := os.Stat(fname)
if err != nil {
return false
}
return true
}
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
}

View File

@ -1,5 +0,0 @@
TAGS
tags
.*.swp
tomlcheck/tomlcheck
toml.test

View File

@ -1,15 +0,0 @@
language: go
go:
- 1.1
- 1.2
- 1.3
- 1.4
- 1.5
- 1.6
- tip
install:
- go install ./...
- go get github.com/BurntSushi/toml-test
script:
- export PATH="$PATH:$HOME/gopath/bin"
- make test

View File

@ -1,3 +0,0 @@
Compatible with TOML version
[v0.4.0](https://github.com/toml-lang/toml/blob/v0.4.0/versions/en/toml-v0.4.0.md)

View File

@ -1,14 +0,0 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@ -1,19 +0,0 @@
install:
go install ./...
test: install
go test -v
toml-test toml-test-decoder
toml-test -encoder toml-test-encoder
fmt:
gofmt -w *.go */*.go
colcheck *.go */*.go
tags:
find ./ -name '*.go' -print0 | xargs -0 gotags > TAGS
push:
git push origin master
git push github master

View File

@ -1,218 +0,0 @@
## TOML parser and encoder for Go with reflection
TOML stands for Tom's Obvious, Minimal Language. This Go package provides a
reflection interface similar to Go's standard library `json` and `xml`
packages. This package also supports the `encoding.TextUnmarshaler` and
`encoding.TextMarshaler` interfaces so that you can define custom data
representations. (There is an example of this below.)
Spec: https://github.com/toml-lang/toml
Compatible with TOML version
[v0.4.0](https://github.com/toml-lang/toml/blob/master/versions/en/toml-v0.4.0.md)
Documentation: https://godoc.org/github.com/BurntSushi/toml
Installation:
```bash
go get github.com/BurntSushi/toml
```
Try the toml validator:
```bash
go get github.com/BurntSushi/toml/cmd/tomlv
tomlv some-toml-file.toml
```
[![Build Status](https://travis-ci.org/BurntSushi/toml.svg?branch=master)](https://travis-ci.org/BurntSushi/toml) [![GoDoc](https://godoc.org/github.com/BurntSushi/toml?status.svg)](https://godoc.org/github.com/BurntSushi/toml)
### Testing
This package passes all tests in
[toml-test](https://github.com/BurntSushi/toml-test) for both the decoder
and the encoder.
### Examples
This package works similarly to how the Go standard library handles `XML`
and `JSON`. Namely, data is loaded into Go values via reflection.
For the simplest example, consider some TOML file as just a list of keys
and values:
```toml
Age = 25
Cats = [ "Cauchy", "Plato" ]
Pi = 3.14
Perfection = [ 6, 28, 496, 8128 ]
DOB = 1987-07-05T05:45:00Z
```
Which could be defined in Go as:
```go
type Config struct {
Age int
Cats []string
Pi float64
Perfection []int
DOB time.Time // requires `import time`
}
```
And then decoded with:
```go
var conf Config
if _, err := toml.Decode(tomlData, &conf); err != nil {
// handle error
}
```
You can also use struct tags if your struct field name doesn't map to a TOML
key value directly:
```toml
some_key_NAME = "wat"
```
```go
type TOML struct {
ObscureKey string `toml:"some_key_NAME"`
}
```
### Using the `encoding.TextUnmarshaler` interface
Here's an example that automatically parses duration strings into
`time.Duration` values:
```toml
[[song]]
name = "Thunder Road"
duration = "4m49s"
[[song]]
name = "Stairway to Heaven"
duration = "8m03s"
```
Which can be decoded with:
```go
type song struct {
Name string
Duration duration
}
type songs struct {
Song []song
}
var favorites songs
if _, err := toml.Decode(blob, &favorites); err != nil {
log.Fatal(err)
}
for _, s := range favorites.Song {
fmt.Printf("%s (%s)\n", s.Name, s.Duration)
}
```
And you'll also need a `duration` type that satisfies the
`encoding.TextUnmarshaler` interface:
```go
type duration struct {
time.Duration
}
func (d *duration) UnmarshalText(text []byte) error {
var err error
d.Duration, err = time.ParseDuration(string(text))
return err
}
```
### More complex usage
Here's an example of how to load the example from the official spec page:
```toml
# This is a TOML document. Boom.
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
organization = "GitHub"
bio = "GitHub Cofounder & CEO\nLikes tater tots and beer."
dob = 1979-05-27T07:32:00Z # First class dates? Why not?
[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true
[servers]
# You can indent as you please. Tabs or spaces. TOML don't care.
[servers.alpha]
ip = "10.0.0.1"
dc = "eqdc10"
[servers.beta]
ip = "10.0.0.2"
dc = "eqdc10"
[clients]
data = [ ["gamma", "delta"], [1, 2] ] # just an update to make sure parsers support it
# Line breaks are OK when inside arrays
hosts = [
"alpha",
"omega"
]
```
And the corresponding Go types are:
```go
type tomlConfig struct {
Title string
Owner ownerInfo
DB database `toml:"database"`
Servers map[string]server
Clients clients
}
type ownerInfo struct {
Name string
Org string `toml:"organization"`
Bio string
DOB time.Time
}
type database struct {
Server string
Ports []int
ConnMax int `toml:"connection_max"`
Enabled bool
}
type server struct {
IP string
DC string
}
type clients struct {
Data [][]interface{}
Hosts []string
}
```
Note that a case insensitive match will be tried if an exact match can't be
found.
A working example of the above can be found in `_examples/example.{go,toml}`.

View File

@ -1,61 +0,0 @@
package main
import (
"fmt"
"time"
"github.com/BurntSushi/toml"
)
type tomlConfig struct {
Title string
Owner ownerInfo
DB database `toml:"database"`
Servers map[string]server
Clients clients
}
type ownerInfo struct {
Name string
Org string `toml:"organization"`
Bio string
DOB time.Time
}
type database struct {
Server string
Ports []int
ConnMax int `toml:"connection_max"`
Enabled bool
}
type server struct {
IP string
DC string
}
type clients struct {
Data [][]interface{}
Hosts []string
}
func main() {
var config tomlConfig
if _, err := toml.DecodeFile("example.toml", &config); err != nil {
fmt.Println(err)
return
}
fmt.Printf("Title: %s\n", config.Title)
fmt.Printf("Owner: %s (%s, %s), Born: %s\n",
config.Owner.Name, config.Owner.Org, config.Owner.Bio,
config.Owner.DOB)
fmt.Printf("Database: %s %v (Max conn. %d), Enabled? %v\n",
config.DB.Server, config.DB.Ports, config.DB.ConnMax,
config.DB.Enabled)
for serverName, server := range config.Servers {
fmt.Printf("Server: %s (%s, %s)\n", serverName, server.IP, server.DC)
}
fmt.Printf("Client data: %v\n", config.Clients.Data)
fmt.Printf("Client hosts: %v\n", config.Clients.Hosts)
}

View File

@ -1,35 +0,0 @@
# This is a TOML document. Boom.
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
organization = "GitHub"
bio = "GitHub Cofounder & CEO\nLikes tater tots and beer."
dob = 1979-05-27T07:32:00Z # First class dates? Why not?
[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true
[servers]
# You can indent as you please. Tabs or spaces. TOML don't care.
[servers.alpha]
ip = "10.0.0.1"
dc = "eqdc10"
[servers.beta]
ip = "10.0.0.2"
dc = "eqdc10"
[clients]
data = [ ["gamma", "delta"], [1, 2] ] # just an update to make sure parsers support it
# Line breaks are OK when inside arrays
hosts = [
"alpha",
"omega"
]

View File

@ -1,22 +0,0 @@
# Test file for TOML
# Only this one tries to emulate a TOML file written by a user of the kind of parser writers probably hate
# This part you'll really hate
[the]
test_string = "You'll hate me after this - #" # " Annoying, isn't it?
[the.hard]
test_array = [ "] ", " # "] # ] There you go, parse this!
test_array2 = [ "Test #11 ]proved that", "Experiment #9 was a success" ]
# You didn't think it'd as easy as chucking out the last #, did you?
another_test_string = " Same thing, but with a string #"
harder_test_string = " And when \"'s are in the string, along with # \"" # "and comments are there too"
# Things will get harder
[the.hard.bit#]
what? = "You don't think some user won't do that?"
multi_line_array = [
"]",
# ] Oh yes I did
]

View File

@ -1,4 +0,0 @@
# [x] you
# [x.y] don't
# [x.y.z] need these
[x.y.z.w] # for this to work

View File

@ -1,6 +0,0 @@
# DO NOT WANT
[fruit]
type = "apple"
[fruit.type]
apple = "yes"

View File

@ -1,35 +0,0 @@
# This is an INVALID TOML document. Boom.
# Can you spot the error without help?
title = "TOML Example"
[owner]
name = "Tom Preston-Werner"
organization = "GitHub"
bio = "GitHub Cofounder & CEO\nLikes tater tots and beer."
dob = 1979-05-27T7:32:00Z # First class dates? Why not?
[database]
server = "192.168.1.1"
ports = [ 8001, 8001, 8002 ]
connection_max = 5000
enabled = true
[servers]
# You can indent as you please. Tabs or spaces. TOML don't care.
[servers.alpha]
ip = "10.0.0.1"
dc = "eqdc10"
[servers.beta]
ip = "10.0.0.2"
dc = "eqdc10"
[clients]
data = [ ["gamma", "delta"], [1, 2] ] # just an update to make sure parsers support it
# Line breaks are OK when inside arrays
hosts = [
"alpha",
"omega"
]

View File

@ -1,5 +0,0 @@
Age = 25
Cats = [ "Cauchy", "Plato" ]
Pi = 3.14
Perfection = [ 6, 28, 496, 8128 ]
DOB = 1987-07-05T05:45:00Z

View File

@ -1 +0,0 @@
some_key_NAME = "wat"

View File

@ -1,14 +0,0 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@ -1,13 +0,0 @@
# Implements the TOML test suite interface
This is an implementation of the interface expected by
[toml-test](https://github.com/BurntSushi/toml-test) for my
[toml parser written in Go](https://github.com/BurntSushi/toml).
In particular, it maps TOML data on `stdin` to a JSON format on `stdout`.
Compatible with TOML version
[v0.4.0](https://github.com/toml-lang/toml/blob/master/versions/en/toml-v0.4.0.md)
Compatible with `toml-test` version
[v0.2.0](https://github.com/BurntSushi/toml-test/tree/v0.2.0)

View File

@ -1,90 +0,0 @@
// Command toml-test-decoder satisfies the toml-test interface for testing
// TOML decoders. Namely, it accepts TOML on stdin and outputs JSON on stdout.
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"path"
"time"
"github.com/BurntSushi/toml"
)
func init() {
log.SetFlags(0)
flag.Usage = usage
flag.Parse()
}
func usage() {
log.Printf("Usage: %s < toml-file\n", path.Base(os.Args[0]))
flag.PrintDefaults()
os.Exit(1)
}
func main() {
if flag.NArg() != 0 {
flag.Usage()
}
var tmp interface{}
if _, err := toml.DecodeReader(os.Stdin, &tmp); err != nil {
log.Fatalf("Error decoding TOML: %s", err)
}
typedTmp := translate(tmp)
if err := json.NewEncoder(os.Stdout).Encode(typedTmp); err != nil {
log.Fatalf("Error encoding JSON: %s", err)
}
}
func translate(tomlData interface{}) interface{} {
switch orig := tomlData.(type) {
case map[string]interface{}:
typed := make(map[string]interface{}, len(orig))
for k, v := range orig {
typed[k] = translate(v)
}
return typed
case []map[string]interface{}:
typed := make([]map[string]interface{}, len(orig))
for i, v := range orig {
typed[i] = translate(v).(map[string]interface{})
}
return typed
case []interface{}:
typed := make([]interface{}, len(orig))
for i, v := range orig {
typed[i] = translate(v)
}
// We don't really need to tag arrays, but let's be future proof.
// (If TOML ever supports tuples, we'll need this.)
return tag("array", typed)
case time.Time:
return tag("datetime", orig.Format("2006-01-02T15:04:05Z"))
case bool:
return tag("bool", fmt.Sprintf("%v", orig))
case int64:
return tag("integer", fmt.Sprintf("%d", orig))
case float64:
return tag("float", fmt.Sprintf("%v", orig))
case string:
return tag("string", orig)
}
panic(fmt.Sprintf("Unknown type: %T", tomlData))
}
func tag(typeName string, data interface{}) map[string]interface{} {
return map[string]interface{}{
"type": typeName,
"value": data,
}
}

View File

@ -1,14 +0,0 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@ -1,13 +0,0 @@
# Implements the TOML test suite interface for TOML encoders
This is an implementation of the interface expected by
[toml-test](https://github.com/BurntSushi/toml-test) for the
[TOML encoder](https://github.com/BurntSushi/toml).
In particular, it maps JSON data on `stdin` to a TOML format on `stdout`.
Compatible with TOML version
[v0.4.0](https://github.com/toml-lang/toml/blob/master/versions/en/toml-v0.4.0.md)
Compatible with `toml-test` version
[v0.2.0](https://github.com/BurntSushi/toml-test/tree/v0.2.0)

View File

@ -1,131 +0,0 @@
// Command toml-test-encoder satisfies the toml-test interface for testing
// TOML encoders. Namely, it accepts JSON on stdin and outputs TOML on stdout.
package main
import (
"encoding/json"
"flag"
"log"
"os"
"path"
"strconv"
"time"
"github.com/BurntSushi/toml"
)
func init() {
log.SetFlags(0)
flag.Usage = usage
flag.Parse()
}
func usage() {
log.Printf("Usage: %s < json-file\n", path.Base(os.Args[0]))
flag.PrintDefaults()
os.Exit(1)
}
func main() {
if flag.NArg() != 0 {
flag.Usage()
}
var tmp interface{}
if err := json.NewDecoder(os.Stdin).Decode(&tmp); err != nil {
log.Fatalf("Error decoding JSON: %s", err)
}
tomlData := translate(tmp)
if err := toml.NewEncoder(os.Stdout).Encode(tomlData); err != nil {
log.Fatalf("Error encoding TOML: %s", err)
}
}
func translate(typedJson interface{}) interface{} {
switch v := typedJson.(type) {
case map[string]interface{}:
if len(v) == 2 && in("type", v) && in("value", v) {
return untag(v)
}
m := make(map[string]interface{}, len(v))
for k, v2 := range v {
m[k] = translate(v2)
}
return m
case []interface{}:
tabArray := make([]map[string]interface{}, len(v))
for i := range v {
if m, ok := translate(v[i]).(map[string]interface{}); ok {
tabArray[i] = m
} else {
log.Fatalf("JSON arrays may only contain objects. This " +
"corresponds to only tables being allowed in " +
"TOML table arrays.")
}
}
return tabArray
}
log.Fatalf("Unrecognized JSON format '%T'.", typedJson)
panic("unreachable")
}
func untag(typed map[string]interface{}) interface{} {
t := typed["type"].(string)
v := typed["value"]
switch t {
case "string":
return v.(string)
case "integer":
v := v.(string)
n, err := strconv.Atoi(v)
if err != nil {
log.Fatalf("Could not parse '%s' as integer: %s", v, err)
}
return n
case "float":
v := v.(string)
f, err := strconv.ParseFloat(v, 64)
if err != nil {
log.Fatalf("Could not parse '%s' as float64: %s", v, err)
}
return f
case "datetime":
v := v.(string)
t, err := time.Parse("2006-01-02T15:04:05Z", v)
if err != nil {
log.Fatalf("Could not parse '%s' as a datetime: %s", v, err)
}
return t
case "bool":
v := v.(string)
switch v {
case "true":
return true
case "false":
return false
}
log.Fatalf("Could not parse '%s' as a boolean.", v)
case "array":
v := v.([]interface{})
array := make([]interface{}, len(v))
for i := range v {
if m, ok := v[i].(map[string]interface{}); ok {
array[i] = untag(m)
} else {
log.Fatalf("Arrays may only contain other arrays or "+
"primitive values, but found a '%T'.", m)
}
}
return array
}
log.Fatalf("Unrecognized tag type '%s'.", t)
panic("unreachable")
}
func in(key string, m map[string]interface{}) bool {
_, ok := m[key]
return ok
}

View File

@ -1,14 +0,0 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified
copies of this license document, and changing it is allowed as long
as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO.

View File

@ -1,21 +0,0 @@
# TOML Validator
If Go is installed, it's simple to try it out:
```bash
go get github.com/BurntSushi/toml/cmd/tomlv
tomlv some-toml-file.toml
```
You can see the types of every key in a TOML file with:
```bash
tomlv -types some-toml-file.toml
```
At the moment, only one error message is reported at a time. Error messages
include line numbers. No output means that the files given are valid TOML, or
there is a bug in `tomlv`.
Compatible with TOML version
[v0.4.0](https://github.com/toml-lang/toml/blob/master/versions/en/toml-v0.4.0.md)

View File

@ -1,61 +0,0 @@
// Command tomlv validates TOML documents and prints each key's type.
package main
import (
"flag"
"fmt"
"log"
"os"
"path"
"strings"
"text/tabwriter"
"github.com/BurntSushi/toml"
)
var (
flagTypes = false
)
func init() {
log.SetFlags(0)
flag.BoolVar(&flagTypes, "types", flagTypes,
"When set, the types of every defined key will be shown.")
flag.Usage = usage
flag.Parse()
}
func usage() {
log.Printf("Usage: %s toml-file [ toml-file ... ]\n",
path.Base(os.Args[0]))
flag.PrintDefaults()
os.Exit(1)
}
func main() {
if flag.NArg() < 1 {
flag.Usage()
}
for _, f := range flag.Args() {
var tmp interface{}
md, err := toml.DecodeFile(f, &tmp)
if err != nil {
log.Fatalf("Error in '%s': %s", f, err)
}
if flagTypes {
printTypes(md)
}
}
}
func printTypes(md toml.MetaData) {
tabw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
for _, key := range md.Keys() {
fmt.Fprintf(tabw, "%s%s\t%s\n",
strings.Repeat(" ", len(key)-1), key, md.Type(key...))
}
tabw.Flush()
}

View File

@ -1,509 +0,0 @@
package toml
import (
"fmt"
"io"
"io/ioutil"
"math"
"reflect"
"strings"
"time"
)
func e(format string, args ...interface{}) error {
return fmt.Errorf("toml: "+format, args...)
}
// Unmarshaler is the interface implemented by objects that can unmarshal a
// TOML description of themselves.
type Unmarshaler interface {
UnmarshalTOML(interface{}) error
}
// Unmarshal decodes the contents of `p` in TOML format into a pointer `v`.
func Unmarshal(p []byte, v interface{}) error {
_, err := Decode(string(p), v)
return err
}
// Primitive is a TOML value that hasn't been decoded into a Go value.
// When using the various `Decode*` functions, the type `Primitive` may
// be given to any value, and its decoding will be delayed.
//
// A `Primitive` value can be decoded using the `PrimitiveDecode` function.
//
// The underlying representation of a `Primitive` value is subject to change.
// Do not rely on it.
//
// N.B. Primitive values are still parsed, so using them will only avoid
// the overhead of reflection. They can be useful when you don't know the
// exact type of TOML data until run time.
type Primitive struct {
undecoded interface{}
context Key
}
// DEPRECATED!
//
// Use MetaData.PrimitiveDecode instead.
func PrimitiveDecode(primValue Primitive, v interface{}) error {
md := MetaData{decoded: make(map[string]bool)}
return md.unify(primValue.undecoded, rvalue(v))
}
// PrimitiveDecode is just like the other `Decode*` functions, except it
// decodes a TOML value that has already been parsed. Valid primitive values
// can *only* be obtained from values filled by the decoder functions,
// including this method. (i.e., `v` may contain more `Primitive`
// values.)
//
// Meta data for primitive values is included in the meta data returned by
// the `Decode*` functions with one exception: keys returned by the Undecoded
// method will only reflect keys that were decoded. Namely, any keys hidden
// behind a Primitive will be considered undecoded. Executing this method will
// update the undecoded keys in the meta data. (See the example.)
func (md *MetaData) PrimitiveDecode(primValue Primitive, v interface{}) error {
md.context = primValue.context
defer func() { md.context = nil }()
return md.unify(primValue.undecoded, rvalue(v))
}
// Decode will decode the contents of `data` in TOML format into a pointer
// `v`.
//
// TOML hashes correspond to Go structs or maps. (Dealer's choice. They can be
// used interchangeably.)
//
// TOML arrays of tables correspond to either a slice of structs or a slice
// of maps.
//
// TOML datetimes correspond to Go `time.Time` values.
//
// All other TOML types (float, string, int, bool and array) correspond
// to the obvious Go types.
//
// An exception to the above rules is if a type implements the
// encoding.TextUnmarshaler interface. In this case, any primitive TOML value
// (floats, strings, integers, booleans and datetimes) will be converted to
// a byte string and given to the value's UnmarshalText method. See the
// Unmarshaler example for a demonstration with time duration strings.
//
// Key mapping
//
// TOML keys can map to either keys in a Go map or field names in a Go
// struct. The special `toml` struct tag may be used to map TOML keys to
// struct fields that don't match the key name exactly. (See the example.)
// A case insensitive match to struct names will be tried if an exact match
// can't be found.
//
// The mapping between TOML values and Go values is loose. That is, there
// may exist TOML values that cannot be placed into your representation, and
// there may be parts of your representation that do not correspond to
// TOML values. This loose mapping can be made stricter by using the IsDefined
// and/or Undecoded methods on the MetaData returned.
//
// This decoder will not handle cyclic types. If a cyclic type is passed,
// `Decode` will not terminate.
func Decode(data string, v interface{}) (MetaData, error) {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr {
return MetaData{}, e("Decode of non-pointer %s", reflect.TypeOf(v))
}
if rv.IsNil() {
return MetaData{}, e("Decode of nil %s", reflect.TypeOf(v))
}
p, err := parse(data)
if err != nil {
return MetaData{}, err
}
md := MetaData{
p.mapping, p.types, p.ordered,
make(map[string]bool, len(p.ordered)), nil,
}
return md, md.unify(p.mapping, indirect(rv))
}
// DecodeFile is just like Decode, except it will automatically read the
// contents of the file at `fpath` and decode it for you.
func DecodeFile(fpath string, v interface{}) (MetaData, error) {
bs, err := ioutil.ReadFile(fpath)
if err != nil {
return MetaData{}, err
}
return Decode(string(bs), v)
}
// DecodeReader is just like Decode, except it will consume all bytes
// from the reader and decode it for you.
func DecodeReader(r io.Reader, v interface{}) (MetaData, error) {
bs, err := ioutil.ReadAll(r)
if err != nil {
return MetaData{}, err
}
return Decode(string(bs), v)
}
// unify performs a sort of type unification based on the structure of `rv`,
// which is the client representation.
//
// Any type mismatch produces an error. Finding a type that we don't know
// how to handle produces an unsupported type error.
func (md *MetaData) unify(data interface{}, rv reflect.Value) error {
// Special case. Look for a `Primitive` value.
if rv.Type() == reflect.TypeOf((*Primitive)(nil)).Elem() {
// Save the undecoded data and the key context into the primitive
// value.
context := make(Key, len(md.context))
copy(context, md.context)
rv.Set(reflect.ValueOf(Primitive{
undecoded: data,
context: context,
}))
return nil
}
// Special case. Unmarshaler Interface support.
if rv.CanAddr() {
if v, ok := rv.Addr().Interface().(Unmarshaler); ok {
return v.UnmarshalTOML(data)
}
}
// Special case. Handle time.Time values specifically.
// TODO: Remove this code when we decide to drop support for Go 1.1.
// This isn't necessary in Go 1.2 because time.Time satisfies the encoding
// interfaces.
if rv.Type().AssignableTo(rvalue(time.Time{}).Type()) {
return md.unifyDatetime(data, rv)
}
// Special case. Look for a value satisfying the TextUnmarshaler interface.
if v, ok := rv.Interface().(TextUnmarshaler); ok {
return md.unifyText(data, v)
}
// BUG(burntsushi)
// The behavior here is incorrect whenever a Go type satisfies the
// encoding.TextUnmarshaler interface but also corresponds to a TOML
// hash or array. In particular, the unmarshaler should only be applied
// to primitive TOML values. But at this point, it will be applied to
// all kinds of values and produce an incorrect error whenever those values
// are hashes or arrays (including arrays of tables).
k := rv.Kind()
// laziness
if k >= reflect.Int && k <= reflect.Uint64 {
return md.unifyInt(data, rv)
}
switch k {
case reflect.Ptr:
elem := reflect.New(rv.Type().Elem())
err := md.unify(data, reflect.Indirect(elem))
if err != nil {
return err
}
rv.Set(elem)
return nil
case reflect.Struct:
return md.unifyStruct(data, rv)
case reflect.Map:
return md.unifyMap(data, rv)
case reflect.Array:
return md.unifyArray(data, rv)
case reflect.Slice:
return md.unifySlice(data, rv)
case reflect.String:
return md.unifyString(data, rv)
case reflect.Bool:
return md.unifyBool(data, rv)
case reflect.Interface:
// we only support empty interfaces.
if rv.NumMethod() > 0 {
return e("unsupported type %s", rv.Type())
}
return md.unifyAnything(data, rv)
case reflect.Float32:
fallthrough
case reflect.Float64:
return md.unifyFloat64(data, rv)
}
return e("unsupported type %s", rv.Kind())
}
func (md *MetaData) unifyStruct(mapping interface{}, rv reflect.Value) error {
tmap, ok := mapping.(map[string]interface{})
if !ok {
if mapping == nil {
return nil
}
return e("type mismatch for %s: expected table but found %T",
rv.Type().String(), mapping)
}
for key, datum := range tmap {
var f *field
fields := cachedTypeFields(rv.Type())
for i := range fields {
ff := &fields[i]
if ff.name == key {
f = ff
break
}
if f == nil && strings.EqualFold(ff.name, key) {
f = ff
}
}
if f != nil {
subv := rv
for _, i := range f.index {
subv = indirect(subv.Field(i))
}
if isUnifiable(subv) {
md.decoded[md.context.add(key).String()] = true
md.context = append(md.context, key)
if err := md.unify(datum, subv); err != nil {
return err
}
md.context = md.context[0 : len(md.context)-1]
} else if f.name != "" {
// Bad user! No soup for you!
return e("cannot write unexported field %s.%s",
rv.Type().String(), f.name)
}
}
}
return nil
}
func (md *MetaData) unifyMap(mapping interface{}, rv reflect.Value) error {
tmap, ok := mapping.(map[string]interface{})
if !ok {
if tmap == nil {
return nil
}
return badtype("map", mapping)
}
if rv.IsNil() {
rv.Set(reflect.MakeMap(rv.Type()))
}
for k, v := range tmap {
md.decoded[md.context.add(k).String()] = true
md.context = append(md.context, k)
rvkey := indirect(reflect.New(rv.Type().Key()))
rvval := reflect.Indirect(reflect.New(rv.Type().Elem()))
if err := md.unify(v, rvval); err != nil {
return err
}
md.context = md.context[0 : len(md.context)-1]
rvkey.SetString(k)
rv.SetMapIndex(rvkey, rvval)
}
return nil
}
func (md *MetaData) unifyArray(data interface{}, rv reflect.Value) error {
datav := reflect.ValueOf(data)
if datav.Kind() != reflect.Slice {
if !datav.IsValid() {
return nil
}
return badtype("slice", data)
}
sliceLen := datav.Len()
if sliceLen != rv.Len() {
return e("expected array length %d; got TOML array of length %d",
rv.Len(), sliceLen)
}
return md.unifySliceArray(datav, rv)
}
func (md *MetaData) unifySlice(data interface{}, rv reflect.Value) error {
datav := reflect.ValueOf(data)
if datav.Kind() != reflect.Slice {
if !datav.IsValid() {
return nil
}
return badtype("slice", data)
}
n := datav.Len()
if rv.IsNil() || rv.Cap() < n {
rv.Set(reflect.MakeSlice(rv.Type(), n, n))
}
rv.SetLen(n)
return md.unifySliceArray(datav, rv)
}
func (md *MetaData) unifySliceArray(data, rv reflect.Value) error {
sliceLen := data.Len()
for i := 0; i < sliceLen; i++ {
v := data.Index(i).Interface()
sliceval := indirect(rv.Index(i))
if err := md.unify(v, sliceval); err != nil {
return err
}
}
return nil
}
func (md *MetaData) unifyDatetime(data interface{}, rv reflect.Value) error {
if _, ok := data.(time.Time); ok {
rv.Set(reflect.ValueOf(data))
return nil
}
return badtype("time.Time", data)
}
func (md *MetaData) unifyString(data interface{}, rv reflect.Value) error {
if s, ok := data.(string); ok {
rv.SetString(s)
return nil
}
return badtype("string", data)
}
func (md *MetaData) unifyFloat64(data interface{}, rv reflect.Value) error {
if num, ok := data.(float64); ok {
switch rv.Kind() {
case reflect.Float32:
fallthrough
case reflect.Float64:
rv.SetFloat(num)
default:
panic("bug")
}
return nil
}
return badtype("float", data)
}
func (md *MetaData) unifyInt(data interface{}, rv reflect.Value) error {
if num, ok := data.(int64); ok {
if rv.Kind() >= reflect.Int && rv.Kind() <= reflect.Int64 {
switch rv.Kind() {
case reflect.Int, reflect.Int64:
// No bounds checking necessary.
case reflect.Int8:
if num < math.MinInt8 || num > math.MaxInt8 {
return e("value %d is out of range for int8", num)
}
case reflect.Int16:
if num < math.MinInt16 || num > math.MaxInt16 {
return e("value %d is out of range for int16", num)
}
case reflect.Int32:
if num < math.MinInt32 || num > math.MaxInt32 {
return e("value %d is out of range for int32", num)
}
}
rv.SetInt(num)
} else if rv.Kind() >= reflect.Uint && rv.Kind() <= reflect.Uint64 {
unum := uint64(num)
switch rv.Kind() {
case reflect.Uint, reflect.Uint64:
// No bounds checking necessary.
case reflect.Uint8:
if num < 0 || unum > math.MaxUint8 {
return e("value %d is out of range for uint8", num)
}
case reflect.Uint16:
if num < 0 || unum > math.MaxUint16 {
return e("value %d is out of range for uint16", num)
}
case reflect.Uint32:
if num < 0 || unum > math.MaxUint32 {
return e("value %d is out of range for uint32", num)
}
}
rv.SetUint(unum)
} else {
panic("unreachable")
}
return nil
}
return badtype("integer", data)
}
func (md *MetaData) unifyBool(data interface{}, rv reflect.Value) error {
if b, ok := data.(bool); ok {
rv.SetBool(b)
return nil
}
return badtype("boolean", data)
}
func (md *MetaData) unifyAnything(data interface{}, rv reflect.Value) error {
rv.Set(reflect.ValueOf(data))
return nil
}
func (md *MetaData) unifyText(data interface{}, v TextUnmarshaler) error {
var s string
switch sdata := data.(type) {
case TextMarshaler:
text, err := sdata.MarshalText()
if err != nil {
return err
}
s = string(text)
case fmt.Stringer:
s = sdata.String()
case string:
s = sdata
case bool:
s = fmt.Sprintf("%v", sdata)
case int64:
s = fmt.Sprintf("%d", sdata)
case float64:
s = fmt.Sprintf("%f", sdata)
default:
return badtype("primitive (string-like)", data)
}
if err := v.UnmarshalText([]byte(s)); err != nil {
return err
}
return nil
}
// rvalue returns a reflect.Value of `v`. All pointers are resolved.
func rvalue(v interface{}) reflect.Value {
return indirect(reflect.ValueOf(v))
}
// indirect returns the value pointed to by a pointer.
// Pointers are followed until the value is not a pointer.
// New values are allocated for each nil pointer.
//
// An exception to this rule is if the value satisfies an interface of
// interest to us (like encoding.TextUnmarshaler).
func indirect(v reflect.Value) reflect.Value {
if v.Kind() != reflect.Ptr {
if v.CanSet() {
pv := v.Addr()
if _, ok := pv.Interface().(TextUnmarshaler); ok {
return pv
}
}
return v
}
if v.IsNil() {
v.Set(reflect.New(v.Type().Elem()))
}
return indirect(reflect.Indirect(v))
}
func isUnifiable(rv reflect.Value) bool {
if rv.CanSet() {
return true
}
if _, ok := rv.Interface().(TextUnmarshaler); ok {
return true
}
return false
}
func badtype(expected string, data interface{}) error {
return e("cannot load TOML value of type %T into a Go %s", data, expected)
}

View File

@ -1,121 +0,0 @@
package toml
import "strings"
// MetaData allows access to meta information about TOML data that may not
// be inferrable via reflection. In particular, whether a key has been defined
// and the TOML type of a key.
type MetaData struct {
mapping map[string]interface{}
types map[string]tomlType
keys []Key
decoded map[string]bool
context Key // Used only during decoding.
}
// IsDefined returns true if the key given exists in the TOML data. The key
// should be specified hierarchially. e.g.,
//
// // access the TOML key 'a.b.c'
// IsDefined("a", "b", "c")
//
// IsDefined will return false if an empty key given. Keys are case sensitive.
func (md *MetaData) IsDefined(key ...string) bool {
if len(key) == 0 {
return false
}
var hash map[string]interface{}
var ok bool
var hashOrVal interface{} = md.mapping
for _, k := range key {
if hash, ok = hashOrVal.(map[string]interface{}); !ok {
return false
}
if hashOrVal, ok = hash[k]; !ok {
return false
}
}
return true
}
// Type returns a string representation of the type of the key specified.
//
// Type will return the empty string if given an empty key or a key that
// does not exist. Keys are case sensitive.
func (md *MetaData) Type(key ...string) string {
fullkey := strings.Join(key, ".")
if typ, ok := md.types[fullkey]; ok {
return typ.typeString()
}
return ""
}
// Key is the type of any TOML key, including key groups. Use (MetaData).Keys
// to get values of this type.
type Key []string
func (k Key) String() string {
return strings.Join(k, ".")
}
func (k Key) maybeQuotedAll() string {
var ss []string
for i := range k {
ss = append(ss, k.maybeQuoted(i))
}
return strings.Join(ss, ".")
}
func (k Key) maybeQuoted(i int) string {
quote := false
for _, c := range k[i] {
if !isBareKeyChar(c) {
quote = true
break
}
}
if quote {
return "\"" + strings.Replace(k[i], "\"", "\\\"", -1) + "\""
}
return k[i]
}
func (k Key) add(piece string) Key {
newKey := make(Key, len(k)+1)
copy(newKey, k)
newKey[len(k)] = piece
return newKey
}
// Keys returns a slice of every key in the TOML data, including key groups.
// Each key is itself a slice, where the first element is the top of the
// hierarchy and the last is the most specific.
//
// The list will have the same order as the keys appeared in the TOML data.
//
// All keys returned are non-empty.
func (md *MetaData) Keys() []Key {
return md.keys
}
// Undecoded returns all keys that have not been decoded in the order in which
// they appear in the original TOML document.
//
// This includes keys that haven't been decoded because of a Primitive value.
// Once the Primitive value is decoded, the keys will be considered decoded.
//
// Also note that decoding into an empty interface will result in no decoding,
// and so no keys will be considered decoded.
//
// In this sense, the Undecoded keys correspond to keys in the TOML document
// that do not have a concrete type in your representation.
func (md *MetaData) Undecoded() []Key {
undecoded := make([]Key, 0, len(md.keys))
for _, key := range md.keys {
if !md.decoded[key.String()] {
undecoded = append(undecoded, key)
}
}
return undecoded
}

File diff suppressed because it is too large Load Diff

View File

@ -1,27 +0,0 @@
/*
Package toml provides facilities for decoding and encoding TOML configuration
files via reflection. There is also support for delaying decoding with
the Primitive type, and querying the set of keys in a TOML document with the
MetaData type.
The specification implemented: https://github.com/toml-lang/toml
The sub-command github.com/BurntSushi/toml/cmd/tomlv can be used to verify
whether a file is a valid TOML document. It can also be used to print the
type of each key in a TOML document.
Testing
There are two important types of tests used for this package. The first is
contained inside '*_test.go' files and uses the standard Go unit testing
framework. These tests are primarily devoted to holistically testing the
decoder and encoder.
The second type of testing is used to verify the implementation's adherence
to the TOML specification. These tests have been factored into their own
project: https://github.com/BurntSushi/toml-test
The reason the tests are in a separate project is so that they can be used by
any implementation of TOML. Namely, it is language agnostic.
*/
package toml

View File

@ -1,568 +0,0 @@
package toml
import (
"bufio"
"errors"
"fmt"
"io"
"reflect"
"sort"
"strconv"
"strings"
"time"
)
type tomlEncodeError struct{ error }
var (
errArrayMixedElementTypes = errors.New(
"toml: cannot encode array with mixed element types")
errArrayNilElement = errors.New(
"toml: cannot encode array with nil element")
errNonString = errors.New(
"toml: cannot encode a map with non-string key type")
errAnonNonStruct = errors.New(
"toml: cannot encode an anonymous field that is not a struct")
errArrayNoTable = errors.New(
"toml: TOML array element cannot contain a table")
errNoKey = errors.New(
"toml: top-level values must be Go maps or structs")
errAnything = errors.New("") // used in testing
)
var quotedReplacer = strings.NewReplacer(
"\t", "\\t",
"\n", "\\n",
"\r", "\\r",
"\"", "\\\"",
"\\", "\\\\",
)
// Encoder controls the encoding of Go values to a TOML document to some
// io.Writer.
//
// The indentation level can be controlled with the Indent field.
type Encoder struct {
// A single indentation level. By default it is two spaces.
Indent string
// hasWritten is whether we have written any output to w yet.
hasWritten bool
w *bufio.Writer
}
// NewEncoder returns a TOML encoder that encodes Go values to the io.Writer
// given. By default, a single indentation level is 2 spaces.
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{
w: bufio.NewWriter(w),
Indent: " ",
}
}
// Encode writes a TOML representation of the Go value to the underlying
// io.Writer. If the value given cannot be encoded to a valid TOML document,
// then an error is returned.
//
// The mapping between Go values and TOML values should be precisely the same
// as for the Decode* functions. Similarly, the TextMarshaler interface is
// supported by encoding the resulting bytes as strings. (If you want to write
// arbitrary binary data then you will need to use something like base64 since
// TOML does not have any binary types.)
//
// When encoding TOML hashes (i.e., Go maps or structs), keys without any
// sub-hashes are encoded first.
//
// If a Go map is encoded, then its keys are sorted alphabetically for
// deterministic output. More control over this behavior may be provided if
// there is demand for it.
//
// Encoding Go values without a corresponding TOML representation---like map
// types with non-string keys---will cause an error to be returned. Similarly
// for mixed arrays/slices, arrays/slices with nil elements, embedded
// non-struct types and nested slices containing maps or structs.
// (e.g., [][]map[string]string is not allowed but []map[string]string is OK
// and so is []map[string][]string.)
func (enc *Encoder) Encode(v interface{}) error {
rv := eindirect(reflect.ValueOf(v))
if err := enc.safeEncode(Key([]string{}), rv); err != nil {
return err
}
return enc.w.Flush()
}
func (enc *Encoder) safeEncode(key Key, rv reflect.Value) (err error) {
defer func() {
if r := recover(); r != nil {
if terr, ok := r.(tomlEncodeError); ok {
err = terr.error
return
}
panic(r)
}
}()
enc.encode(key, rv)
return nil
}
func (enc *Encoder) encode(key Key, rv reflect.Value) {
// Special case. Time needs to be in ISO8601 format.
// Special case. If we can marshal the type to text, then we used that.
// Basically, this prevents the encoder for handling these types as
// generic structs (or whatever the underlying type of a TextMarshaler is).
switch rv.Interface().(type) {
case time.Time, TextMarshaler:
enc.keyEqElement(key, rv)
return
}
k := rv.Kind()
switch k {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32,
reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32,
reflect.Uint64,
reflect.Float32, reflect.Float64, reflect.String, reflect.Bool:
enc.keyEqElement(key, rv)
case reflect.Array, reflect.Slice:
if typeEqual(tomlArrayHash, tomlTypeOfGo(rv)) {
enc.eArrayOfTables(key, rv)
} else {
enc.keyEqElement(key, rv)
}
case reflect.Interface:
if rv.IsNil() {
return
}
enc.encode(key, rv.Elem())
case reflect.Map:
if rv.IsNil() {
return
}
enc.eTable(key, rv)
case reflect.Ptr:
if rv.IsNil() {
return
}
enc.encode(key, rv.Elem())
case reflect.Struct:
enc.eTable(key, rv)
default:
panic(e("unsupported type for key '%s': %s", key, k))
}
}
// eElement encodes any value that can be an array element (primitives and
// arrays).
func (enc *Encoder) eElement(rv reflect.Value) {
switch v := rv.Interface().(type) {
case time.Time:
// Special case time.Time as a primitive. Has to come before
// TextMarshaler below because time.Time implements
// encoding.TextMarshaler, but we need to always use UTC.
enc.wf(v.UTC().Format("2006-01-02T15:04:05Z"))
return
case TextMarshaler:
// Special case. Use text marshaler if it's available for this value.
if s, err := v.MarshalText(); err != nil {
encPanic(err)
} else {
enc.writeQuoted(string(s))
}
return
}
switch rv.Kind() {
case reflect.Bool:
enc.wf(strconv.FormatBool(rv.Bool()))
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32,
reflect.Int64:
enc.wf(strconv.FormatInt(rv.Int(), 10))
case reflect.Uint, reflect.Uint8, reflect.Uint16,
reflect.Uint32, reflect.Uint64:
enc.wf(strconv.FormatUint(rv.Uint(), 10))
case reflect.Float32:
enc.wf(floatAddDecimal(strconv.FormatFloat(rv.Float(), 'f', -1, 32)))
case reflect.Float64:
enc.wf(floatAddDecimal(strconv.FormatFloat(rv.Float(), 'f', -1, 64)))
case reflect.Array, reflect.Slice:
enc.eArrayOrSliceElement(rv)
case reflect.Interface:
enc.eElement(rv.Elem())
case reflect.String:
enc.writeQuoted(rv.String())
default:
panic(e("unexpected primitive type: %s", rv.Kind()))
}
}
// By the TOML spec, all floats must have a decimal with at least one
// number on either side.
func floatAddDecimal(fstr string) string {
if !strings.Contains(fstr, ".") {
return fstr + ".0"
}
return fstr
}
func (enc *Encoder) writeQuoted(s string) {
enc.wf("\"%s\"", quotedReplacer.Replace(s))
}
func (enc *Encoder) eArrayOrSliceElement(rv reflect.Value) {
length := rv.Len()
enc.wf("[")
for i := 0; i < length; i++ {
elem := rv.Index(i)
enc.eElement(elem)
if i != length-1 {
enc.wf(", ")
}
}
enc.wf("]")
}
func (enc *Encoder) eArrayOfTables(key Key, rv reflect.Value) {
if len(key) == 0 {
encPanic(errNoKey)
}
for i := 0; i < rv.Len(); i++ {
trv := rv.Index(i)
if isNil(trv) {
continue
}
panicIfInvalidKey(key)
enc.newline()
enc.wf("%s[[%s]]", enc.indentStr(key), key.maybeQuotedAll())
enc.newline()
enc.eMapOrStruct(key, trv)
}
}
func (enc *Encoder) eTable(key Key, rv reflect.Value) {
panicIfInvalidKey(key)
if len(key) == 1 {
// Output an extra newline between top-level tables.
// (The newline isn't written if nothing else has been written though.)
enc.newline()
}
if len(key) > 0 {
enc.wf("%s[%s]", enc.indentStr(key), key.maybeQuotedAll())
enc.newline()
}
enc.eMapOrStruct(key, rv)
}
func (enc *Encoder) eMapOrStruct(key Key, rv reflect.Value) {
switch rv := eindirect(rv); rv.Kind() {
case reflect.Map:
enc.eMap(key, rv)
case reflect.Struct:
enc.eStruct(key, rv)
default:
panic("eTable: unhandled reflect.Value Kind: " + rv.Kind().String())
}
}
func (enc *Encoder) eMap(key Key, rv reflect.Value) {
rt := rv.Type()
if rt.Key().Kind() != reflect.String {
encPanic(errNonString)
}
// Sort keys so that we have deterministic output. And write keys directly
// underneath this key first, before writing sub-structs or sub-maps.
var mapKeysDirect, mapKeysSub []string
for _, mapKey := range rv.MapKeys() {
k := mapKey.String()
if typeIsHash(tomlTypeOfGo(rv.MapIndex(mapKey))) {
mapKeysSub = append(mapKeysSub, k)
} else {
mapKeysDirect = append(mapKeysDirect, k)
}
}
var writeMapKeys = func(mapKeys []string) {
sort.Strings(mapKeys)
for _, mapKey := range mapKeys {
mrv := rv.MapIndex(reflect.ValueOf(mapKey))
if isNil(mrv) {
// Don't write anything for nil fields.
continue
}
enc.encode(key.add(mapKey), mrv)
}
}
writeMapKeys(mapKeysDirect)
writeMapKeys(mapKeysSub)
}
func (enc *Encoder) eStruct(key Key, rv reflect.Value) {
// Write keys for fields directly under this key first, because if we write
// a field that creates a new table, then all keys under it will be in that
// table (not the one we're writing here).
rt := rv.Type()
var fieldsDirect, fieldsSub [][]int
var addFields func(rt reflect.Type, rv reflect.Value, start []int)
addFields = func(rt reflect.Type, rv reflect.Value, start []int) {
for i := 0; i < rt.NumField(); i++ {
f := rt.Field(i)
// skip unexported fields
if f.PkgPath != "" && !f.Anonymous {
continue
}
frv := rv.Field(i)
if f.Anonymous {
t := f.Type
switch t.Kind() {
case reflect.Struct:
// Treat anonymous struct fields with
// tag names as though they are not
// anonymous, like encoding/json does.
if getOptions(f.Tag).name == "" {
addFields(t, frv, f.Index)
continue
}
case reflect.Ptr:
if t.Elem().Kind() == reflect.Struct &&
getOptions(f.Tag).name == "" {
if !frv.IsNil() {
addFields(t.Elem(), frv.Elem(), f.Index)
}
continue
}
// Fall through to the normal field encoding logic below
// for non-struct anonymous fields.
}
}
if typeIsHash(tomlTypeOfGo(frv)) {
fieldsSub = append(fieldsSub, append(start, f.Index...))
} else {
fieldsDirect = append(fieldsDirect, append(start, f.Index...))
}
}
}
addFields(rt, rv, nil)
var writeFields = func(fields [][]int) {
for _, fieldIndex := range fields {
sft := rt.FieldByIndex(fieldIndex)
sf := rv.FieldByIndex(fieldIndex)
if isNil(sf) {
// Don't write anything for nil fields.
continue
}
opts := getOptions(sft.Tag)
if opts.skip {
continue
}
keyName := sft.Name
if opts.name != "" {
keyName = opts.name
}
if opts.omitempty && isEmpty(sf) {
continue
}
if opts.omitzero && isZero(sf) {
continue
}
enc.encode(key.add(keyName), sf)
}
}
writeFields(fieldsDirect)
writeFields(fieldsSub)
}
// tomlTypeName returns the TOML type name of the Go value's type. It is
// used to determine whether the types of array elements are mixed (which is
// forbidden). If the Go value is nil, then it is illegal for it to be an array
// element, and valueIsNil is returned as true.
// Returns the TOML type of a Go value. The type may be `nil`, which means
// no concrete TOML type could be found.
func tomlTypeOfGo(rv reflect.Value) tomlType {
if isNil(rv) || !rv.IsValid() {
return nil
}
switch rv.Kind() {
case reflect.Bool:
return tomlBool
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32,
reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32,
reflect.Uint64:
return tomlInteger
case reflect.Float32, reflect.Float64:
return tomlFloat
case reflect.Array, reflect.Slice:
if typeEqual(tomlHash, tomlArrayType(rv)) {
return tomlArrayHash
}
return tomlArray
case reflect.Ptr, reflect.Interface:
return tomlTypeOfGo(rv.Elem())
case reflect.String:
return tomlString
case reflect.Map:
return tomlHash
case reflect.Struct:
switch rv.Interface().(type) {
case time.Time:
return tomlDatetime
case TextMarshaler:
return tomlString
default:
return tomlHash
}
default:
panic("unexpected reflect.Kind: " + rv.Kind().String())
}
}
// tomlArrayType returns the element type of a TOML array. The type returned
// may be nil if it cannot be determined (e.g., a nil slice or a zero length
// slize). This function may also panic if it finds a type that cannot be
// expressed in TOML (such as nil elements, heterogeneous arrays or directly
// nested arrays of tables).
func tomlArrayType(rv reflect.Value) tomlType {
if isNil(rv) || !rv.IsValid() || rv.Len() == 0 {
return nil
}
firstType := tomlTypeOfGo(rv.Index(0))
if firstType == nil {
encPanic(errArrayNilElement)
}
rvlen := rv.Len()
for i := 1; i < rvlen; i++ {
elem := rv.Index(i)
switch elemType := tomlTypeOfGo(elem); {
case elemType == nil:
encPanic(errArrayNilElement)
case !typeEqual(firstType, elemType):
encPanic(errArrayMixedElementTypes)
}
}
// If we have a nested array, then we must make sure that the nested
// array contains ONLY primitives.
// This checks arbitrarily nested arrays.
if typeEqual(firstType, tomlArray) || typeEqual(firstType, tomlArrayHash) {
nest := tomlArrayType(eindirect(rv.Index(0)))
if typeEqual(nest, tomlHash) || typeEqual(nest, tomlArrayHash) {
encPanic(errArrayNoTable)
}
}
return firstType
}
type tagOptions struct {
skip bool // "-"
name string
omitempty bool
omitzero bool
}
func getOptions(tag reflect.StructTag) tagOptions {
t := tag.Get("toml")
if t == "-" {
return tagOptions{skip: true}
}
var opts tagOptions
parts := strings.Split(t, ",")
opts.name = parts[0]
for _, s := range parts[1:] {
switch s {
case "omitempty":
opts.omitempty = true
case "omitzero":
opts.omitzero = true
}
}
return opts
}
func isZero(rv reflect.Value) bool {
switch rv.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return rv.Int() == 0
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return rv.Uint() == 0
case reflect.Float32, reflect.Float64:
return rv.Float() == 0.0
}
return false
}
func isEmpty(rv reflect.Value) bool {
switch rv.Kind() {
case reflect.Array, reflect.Slice, reflect.Map, reflect.String:
return rv.Len() == 0
case reflect.Bool:
return !rv.Bool()
}
return false
}
func (enc *Encoder) newline() {
if enc.hasWritten {
enc.wf("\n")
}
}
func (enc *Encoder) keyEqElement(key Key, val reflect.Value) {
if len(key) == 0 {
encPanic(errNoKey)
}
panicIfInvalidKey(key)
enc.wf("%s%s = ", enc.indentStr(key), key.maybeQuoted(len(key)-1))
enc.eElement(val)
enc.newline()
}
func (enc *Encoder) wf(format string, v ...interface{}) {
if _, err := fmt.Fprintf(enc.w, format, v...); err != nil {
encPanic(err)
}
enc.hasWritten = true
}
func (enc *Encoder) indentStr(key Key) string {
return strings.Repeat(enc.Indent, len(key)-1)
}
func encPanic(err error) {
panic(tomlEncodeError{err})
}
func eindirect(v reflect.Value) reflect.Value {
switch v.Kind() {
case reflect.Ptr, reflect.Interface:
return eindirect(v.Elem())
default:
return v
}
}
func isNil(rv reflect.Value) bool {
switch rv.Kind() {
case reflect.Interface, reflect.Map, reflect.Ptr, reflect.Slice:
return rv.IsNil()
default:
return false
}
}
func panicIfInvalidKey(key Key) {
for _, k := range key {
if len(k) == 0 {
encPanic(e("Key '%s' is not a valid table name. Key names "+
"cannot be empty.", key.maybeQuotedAll()))
}
}
}
func isValidKeyName(s string) bool {
return len(s) != 0
}

Some files were not shown because too many files have changed in this diff Show More