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>
This commit is contained in:
co-authored by
Florian Ritterhoff
Jason Playne
parent
b7a0a8a7bc
commit
5a7bc230b8
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/joohoi/acme-dns/pkg/acmedns"
|
||||
"github.com/joohoi/acme-dns/pkg/database"
|
||||
"github.com/joohoi/acme-dns/pkg/nameserver"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/gavv/httpexpect"
|
||||
"github.com/google/uuid"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/rs/cors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger) {
|
||||
c := acmedns.AcmeDnsConfig{}
|
||||
c.Database.Engine = "sqlite"
|
||||
c.Database.Connection = ":memory:"
|
||||
l := zap.NewNop().Sugar()
|
||||
return c, l
|
||||
}
|
||||
|
||||
// noAuth function to write ACMETxt model to context while not preforming any validation
|
||||
func noAuth(update httprouter.Handle) httprouter.Handle {
|
||||
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
|
||||
postData := acmedns.ACMETxt{}
|
||||
uname := r.Header.Get("X-Api-User")
|
||||
passwd := r.Header.Get("X-Api-Key")
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
_ = dec.Decode(&postData)
|
||||
// Set user info to the decoded ACMETxt object
|
||||
postData.Username, _ = uuid.Parse(uname)
|
||||
postData.Password = passwd
|
||||
// Set the ACMETxt struct to context to pull in from update function
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, ACMETxtKey, postData)
|
||||
r = r.WithContext(ctx)
|
||||
update(w, r, p)
|
||||
}
|
||||
}
|
||||
|
||||
func getExpect(t *testing.T, server *httptest.Server) *httpexpect.Expect {
|
||||
return httpexpect.WithConfig(httpexpect.Config{
|
||||
BaseURL: server.URL,
|
||||
Reporter: httpexpect.NewAssertReporter(t),
|
||||
Printers: []httpexpect.Printer{
|
||||
httpexpect.NewCurlPrinter(t),
|
||||
httpexpect.NewDebugPrinter(t, true),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func setupRouter(debug bool, noauth bool) (http.Handler, AcmednsAPI, acmedns.AcmednsDB) {
|
||||
api := httprouter.New()
|
||||
config, logger := fakeConfigAndLogger()
|
||||
config.API.Domain = ""
|
||||
config.API.Port = "8080"
|
||||
config.API.TLS = acmedns.ApiTlsProviderNone
|
||||
config.API.CorsOrigins = []string{"*"}
|
||||
config.API.UseHeader = true
|
||||
config.API.HeaderName = "X-Forwarded-For"
|
||||
|
||||
db, _ := database.Init(&config, logger)
|
||||
errChan := make(chan error, 1)
|
||||
adnsapi := Init(&config, db, logger, errChan)
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: config.API.CorsOrigins,
|
||||
AllowedMethods: []string{"GET", "POST"},
|
||||
OptionsPassthrough: false,
|
||||
Debug: config.General.Debug,
|
||||
})
|
||||
api.POST("/register", adnsapi.webRegisterPost)
|
||||
api.GET("/health", adnsapi.healthCheck)
|
||||
if noauth {
|
||||
api.POST("/update", noAuth(adnsapi.webUpdatePost))
|
||||
} else {
|
||||
api.POST("/update", adnsapi.Auth(adnsapi.webUpdatePost))
|
||||
}
|
||||
return c.Handler(api), adnsapi, db
|
||||
}
|
||||
|
||||
func TestApiRegister(t *testing.T) {
|
||||
router, _, _ := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
e.POST("/register").Expect().
|
||||
Status(http.StatusCreated).
|
||||
JSON().Object().
|
||||
ContainsKey("fulldomain").
|
||||
ContainsKey("subdomain").
|
||||
ContainsKey("username").
|
||||
ContainsKey("password").
|
||||
NotContainsKey("error")
|
||||
|
||||
allowfrom := map[string][]interface{}{
|
||||
"allowfrom": {"123.123.123.123/32",
|
||||
"2001:db8:a0b:12f0::1/32",
|
||||
"[::1]/64",
|
||||
},
|
||||
}
|
||||
|
||||
response := e.POST("/register").
|
||||
WithJSON(allowfrom).
|
||||
Expect().
|
||||
Status(http.StatusCreated).
|
||||
JSON().Object().
|
||||
ContainsKey("fulldomain").
|
||||
ContainsKey("subdomain").
|
||||
ContainsKey("username").
|
||||
ContainsKey("password").
|
||||
ContainsKey("allowfrom").
|
||||
NotContainsKey("error")
|
||||
|
||||
response.Value("allowfrom").Array().Elements("123.123.123.123/32", "2001:db8:a0b:12f0::1/32", "::1/64")
|
||||
}
|
||||
|
||||
func TestApiRegisterBadAllowFrom(t *testing.T) {
|
||||
router, _, _ := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
invalidVals := []string{
|
||||
"invalid",
|
||||
"1.2.3.4/33",
|
||||
"1.2/24",
|
||||
"1.2.3.4",
|
||||
"12345:db8:a0b:12f0::1/32",
|
||||
"1234::123::123::1/32",
|
||||
}
|
||||
|
||||
for _, v := range invalidVals {
|
||||
|
||||
allowfrom := map[string][]interface{}{
|
||||
"allowfrom": {v}}
|
||||
|
||||
response := e.POST("/register").
|
||||
WithJSON(allowfrom).
|
||||
Expect().
|
||||
Status(http.StatusBadRequest).
|
||||
JSON().Object().
|
||||
ContainsKey("error")
|
||||
|
||||
response.Value("error").Equal("invalid_allowfrom_cidr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiRegisterMalformedJSON(t *testing.T) {
|
||||
router, _, _ := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
|
||||
malPayloads := []string{
|
||||
"{\"allowfrom': '1.1.1.1/32'}",
|
||||
"\"allowfrom\": \"1.1.1.1/32\"",
|
||||
"{\"allowfrom\": \"[1.1.1.1/32]\"",
|
||||
"\"allowfrom\": \"1.1.1.1/32\"}",
|
||||
"{allowfrom: \"1.2.3.4\"}",
|
||||
"{allowfrom: [1.2.3.4]}",
|
||||
"whatever that's not a json payload",
|
||||
}
|
||||
for _, test := range malPayloads {
|
||||
e.POST("/register").
|
||||
WithBytes([]byte(test)).
|
||||
Expect().
|
||||
Status(http.StatusBadRequest).
|
||||
JSON().Object().
|
||||
ContainsKey("error").
|
||||
NotContainsKey("subdomain").
|
||||
NotContainsKey("username")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiRegisterWithMockDB(t *testing.T) {
|
||||
router, _, db := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
oldDb := db.GetBackend()
|
||||
mdb, mock, _ := sqlmock.New()
|
||||
db.SetBackend(mdb)
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectPrepare("INSERT INTO records").WillReturnError(errors.New("error"))
|
||||
e.POST("/register").Expect().
|
||||
Status(http.StatusInternalServerError).
|
||||
JSON().Object().
|
||||
ContainsKey("error")
|
||||
db.SetBackend(oldDb)
|
||||
}
|
||||
|
||||
func TestApiUpdateWithInvalidSubdomain(t *testing.T) {
|
||||
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
updateJSON := map[string]interface{}{
|
||||
"subdomain": "",
|
||||
"txt": ""}
|
||||
|
||||
router, _, db := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
newUser, err := db.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user, got error [%v]", err)
|
||||
}
|
||||
// Invalid subdomain data
|
||||
updateJSON["subdomain"] = "example.com"
|
||||
updateJSON["txt"] = validTxtData
|
||||
e.POST("/update").
|
||||
WithJSON(updateJSON).
|
||||
WithHeader("X-Api-User", newUser.Username.String()).
|
||||
WithHeader("X-Api-Key", newUser.Password).
|
||||
Expect().
|
||||
Status(http.StatusUnauthorized).
|
||||
JSON().Object().
|
||||
ContainsKey("error").
|
||||
NotContainsKey("txt").
|
||||
ValueEqual("error", "forbidden")
|
||||
}
|
||||
|
||||
func TestApiUpdateWithInvalidTxt(t *testing.T) {
|
||||
invalidTXTData := "idk m8 bbl lmao"
|
||||
|
||||
updateJSON := map[string]interface{}{
|
||||
"subdomain": "",
|
||||
"txt": ""}
|
||||
|
||||
router, _, db := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
newUser, err := db.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user, got error [%v]", err)
|
||||
}
|
||||
updateJSON["subdomain"] = newUser.Subdomain
|
||||
// Invalid txt data
|
||||
updateJSON["txt"] = invalidTXTData
|
||||
e.POST("/update").
|
||||
WithJSON(updateJSON).
|
||||
WithHeader("X-Api-User", newUser.Username.String()).
|
||||
WithHeader("X-Api-Key", newUser.Password).
|
||||
Expect().
|
||||
Status(http.StatusBadRequest).
|
||||
JSON().Object().
|
||||
ContainsKey("error").
|
||||
NotContainsKey("txt").
|
||||
ValueEqual("error", "bad_txt")
|
||||
}
|
||||
|
||||
func TestApiUpdateWithoutCredentials(t *testing.T) {
|
||||
router, _, _ := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
e.POST("/update").Expect().
|
||||
Status(http.StatusUnauthorized).
|
||||
JSON().Object().
|
||||
ContainsKey("error").
|
||||
NotContainsKey("txt")
|
||||
}
|
||||
|
||||
func TestApiUpdateWithCredentials(t *testing.T) {
|
||||
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
updateJSON := map[string]interface{}{
|
||||
"subdomain": "",
|
||||
"txt": ""}
|
||||
|
||||
router, _, db := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
newUser, err := db.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user, got error [%v]", err)
|
||||
}
|
||||
// Valid data
|
||||
updateJSON["subdomain"] = newUser.Subdomain
|
||||
updateJSON["txt"] = validTxtData
|
||||
e.POST("/update").
|
||||
WithJSON(updateJSON).
|
||||
WithHeader("X-Api-User", newUser.Username.String()).
|
||||
WithHeader("X-Api-Key", newUser.Password).
|
||||
Expect().
|
||||
Status(http.StatusOK).
|
||||
JSON().Object().
|
||||
ContainsKey("txt").
|
||||
NotContainsKey("error").
|
||||
ValueEqual("txt", validTxtData)
|
||||
}
|
||||
|
||||
func TestApiUpdateWithCredentialsMockDB(t *testing.T) {
|
||||
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
updateJSON := map[string]interface{}{
|
||||
"subdomain": "",
|
||||
"txt": ""}
|
||||
|
||||
// Valid data
|
||||
updateJSON["subdomain"] = "a097455b-52cc-4569-90c8-7a4b97c6eba8"
|
||||
updateJSON["txt"] = validTxtData
|
||||
|
||||
router, _, db := setupRouter(false, true)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
oldDb := db.GetBackend()
|
||||
mdb, mock, _ := sqlmock.New()
|
||||
db.SetBackend(mdb)
|
||||
defer db.Close()
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectPrepare("UPDATE records").WillReturnError(errors.New("error"))
|
||||
e.POST("/update").
|
||||
WithJSON(updateJSON).
|
||||
Expect().
|
||||
Status(http.StatusInternalServerError).
|
||||
JSON().Object().
|
||||
ContainsKey("error")
|
||||
db.SetBackend(oldDb)
|
||||
}
|
||||
|
||||
func TestApiManyUpdateWithCredentials(t *testing.T) {
|
||||
validTxtData := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
router, _, db := setupRouter(true, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
// User without defined CIDR masks
|
||||
newUser, err := db.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user, got error [%v]", err)
|
||||
}
|
||||
|
||||
// User with defined allow from - CIDR masks, all invalid
|
||||
// (httpexpect doesn't provide a way to mock remote ip)
|
||||
newUserWithCIDR, err := db.Register(acmedns.Cidrslice{"192.168.1.1/32", "invalid"})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
|
||||
}
|
||||
|
||||
// Another user with valid CIDR mask to match the httpexpect default
|
||||
newUserWithValidCIDR, err := db.Register(acmedns.Cidrslice{"10.1.2.3/32", "invalid"})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user with a valid CIDR, got error [%v]", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
user string
|
||||
pass string
|
||||
subdomain string
|
||||
txt interface{}
|
||||
status int
|
||||
}{
|
||||
{"non-uuid-user", "tooshortpass", "non-uuid-subdomain", validTxtData, 401},
|
||||
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "tooshortpass", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
|
||||
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", "LongEnoughPassButNoUserExists___________", "bb97455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
|
||||
{newUser.Username.String(), newUser.Password, "a097455b-52cc-4569-90c8-7a4b97c6eba8", validTxtData, 401},
|
||||
{newUser.Username.String(), newUser.Password, newUser.Subdomain, "tooshortfortxt", 400},
|
||||
{newUser.Username.String(), newUser.Password, newUser.Subdomain, 1234567890, 400},
|
||||
{newUser.Username.String(), newUser.Password, newUser.Subdomain, validTxtData, 200},
|
||||
{newUserWithCIDR.Username.String(), newUserWithCIDR.Password, newUserWithCIDR.Subdomain, validTxtData, 401},
|
||||
{newUserWithValidCIDR.Username.String(), newUserWithValidCIDR.Password, newUserWithValidCIDR.Subdomain, validTxtData, 200},
|
||||
{newUser.Username.String(), "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", newUser.Subdomain, validTxtData, 401},
|
||||
} {
|
||||
updateJSON := map[string]interface{}{
|
||||
"subdomain": test.subdomain,
|
||||
"txt": test.txt}
|
||||
e.POST("/update").
|
||||
WithJSON(updateJSON).
|
||||
WithHeader("X-Api-User", test.user).
|
||||
WithHeader("X-Api-Key", test.pass).
|
||||
WithHeader("X-Forwarded-For", "10.1.2.3").
|
||||
Expect().
|
||||
Status(test.status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApiManyUpdateWithIpCheckHeaders(t *testing.T) {
|
||||
|
||||
router, adnsapi, db := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
// Use header checks from default header (X-Forwarded-For)
|
||||
adnsapi.Config.API.UseHeader = true
|
||||
// User without defined CIDR masks
|
||||
newUser, err := db.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user, got error [%v]", err)
|
||||
}
|
||||
|
||||
newUserWithCIDR, err := db.Register(acmedns.Cidrslice{"192.168.1.2/32", "invalid"})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create new user with CIDR, got error [%v]", err)
|
||||
}
|
||||
|
||||
newUserWithIP6CIDR, err := db.Register(acmedns.Cidrslice{"2002:c0a8::0/32"})
|
||||
if err != nil {
|
||||
t.Errorf("Could not create a new user with IP6 CIDR, got error [%v]", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
user acmedns.ACMETxt
|
||||
headerValue string
|
||||
status int
|
||||
}{
|
||||
{newUser, "whatever goes", 200},
|
||||
{newUser, "10.0.0.1, 1.2.3.4 ,3.4.5.6", 200},
|
||||
{newUserWithCIDR, "127.0.0.1", 401},
|
||||
{newUserWithCIDR, "10.0.0.1, 10.0.0.2, 192.168.1.3", 401},
|
||||
{newUserWithCIDR, "10.1.1.1 ,192.168.1.2, 8.8.8.8", 200},
|
||||
{newUserWithIP6CIDR, "2002:c0a8:b4dc:0d3::0", 200},
|
||||
{newUserWithIP6CIDR, "2002:c0a7:0ff::0", 401},
|
||||
{newUserWithIP6CIDR, "2002:c0a8:d3ad:b33f:c0ff:33b4:dc0d:3b4d", 200},
|
||||
} {
|
||||
updateJSON := map[string]interface{}{
|
||||
"subdomain": test.user.Subdomain,
|
||||
"txt": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
|
||||
e.POST("/update").
|
||||
WithJSON(updateJSON).
|
||||
WithHeader("X-Api-User", test.user.Username.String()).
|
||||
WithHeader("X-Api-Key", test.user.Password).
|
||||
WithHeader("X-Forwarded-For", test.headerValue).
|
||||
Expect().
|
||||
Status(test.status)
|
||||
}
|
||||
adnsapi.Config.API.UseHeader = false
|
||||
}
|
||||
|
||||
func TestApiHealthCheck(t *testing.T) {
|
||||
router, _, _ := setupRouter(false, false)
|
||||
server := httptest.NewServer(router)
|
||||
defer server.Close()
|
||||
e := getExpect(t, server)
|
||||
e.GET("/health").Expect().Status(http.StatusOK)
|
||||
}
|
||||
|
||||
func TestGetIPListFromHeader(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
input string
|
||||
output []string
|
||||
}{
|
||||
{"1.1.1.1, 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
|
||||
{" 1.1.1.1 , 2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
|
||||
{",1.1.1.1 ,2.2.2.2", []string{"1.1.1.1", "2.2.2.2"}},
|
||||
} {
|
||||
res := getIPListFromHeader(test.input)
|
||||
if len(res) != len(test.output) {
|
||||
t.Errorf("Test %d: Expected [%d] items in return list, but got [%d]", i, len(test.output), len(res))
|
||||
} else {
|
||||
|
||||
for j, vv := range test.output {
|
||||
if res[j] != vv {
|
||||
t.Errorf("Test %d: Expected return value [%v] but got [%v]", j, test.output, res)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAllowedFromIP(t *testing.T) {
|
||||
_, adnsapi, _ := setupRouter(false, false)
|
||||
adnsapi.Config.API.UseHeader = false
|
||||
userWithAllow := acmedns.NewACMETxt()
|
||||
userWithAllow.AllowFrom = acmedns.Cidrslice{"192.168.1.2/32", "[::1]/128"}
|
||||
userWithoutAllow := acmedns.NewACMETxt()
|
||||
|
||||
for i, test := range []struct {
|
||||
remoteaddr string
|
||||
expected bool
|
||||
}{
|
||||
{"192.168.1.2:1234", true},
|
||||
{"192.168.1.1:1234", false},
|
||||
{"invalid", false},
|
||||
{"[::1]:4567", true},
|
||||
} {
|
||||
newreq, _ := http.NewRequest("GET", "/whatever", nil)
|
||||
newreq.RemoteAddr = test.remoteaddr
|
||||
ret := adnsapi.updateAllowedFromIP(newreq, userWithAllow)
|
||||
if test.expected != ret {
|
||||
t.Errorf("Test %d: Unexpected result for user with allowForm set", i)
|
||||
}
|
||||
|
||||
if !adnsapi.updateAllowedFromIP(newreq, userWithoutAllow) {
|
||||
t.Errorf("Test %d: Unexpected result for user without allowForm set", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupTLS(t *testing.T) {
|
||||
_, svr, _ := setupRouter(false, false)
|
||||
|
||||
for _, test := range []struct {
|
||||
apiTls string
|
||||
expectedCA string
|
||||
}{
|
||||
{
|
||||
apiTls: acmedns.ApiTlsProviderLetsEncrypt,
|
||||
expectedCA: certmagic.LetsEncryptProductionCA,
|
||||
},
|
||||
{
|
||||
apiTls: acmedns.ApiTlsProviderLetsEncryptStaging,
|
||||
expectedCA: certmagic.LetsEncryptStagingCA,
|
||||
},
|
||||
} {
|
||||
svr.Config.API.TLS = test.apiTls
|
||||
ns := &nameserver.Nameserver{}
|
||||
magic := svr.setupTLS([]acmedns.AcmednsNS{ns})
|
||||
|
||||
if test.expectedCA != certmagic.DefaultACME.CA {
|
||||
t.Errorf("failed to configure default ACME CA. got %s, want %s", certmagic.DefaultACME.CA, test.expectedCA)
|
||||
}
|
||||
|
||||
if magic.DefaultServerName != svr.Config.General.Domain {
|
||||
t.Errorf("failed to set the correct doman. got: %s, want %s", magic.DefaultServerName, svr.Config.General.Domain)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+103
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/joohoi/acme-dns/pkg/acmedns"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestGetValidUsername(t *testing.T) {
|
||||
v1, _ := uuid.Parse("a097455b-52cc-4569-90c8-7a4b97c6eba8")
|
||||
for i, test := range []struct {
|
||||
uname string
|
||||
output uuid.UUID
|
||||
shouldErr bool
|
||||
}{
|
||||
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", v1, false},
|
||||
{"a-97455b-52cc-4569-90c8-7a4b97c6eba8", uuid.UUID{}, true},
|
||||
{"", uuid.UUID{}, true},
|
||||
{"&!#!25123!%!'%", uuid.UUID{}, true},
|
||||
} {
|
||||
ret, err := getValidUsername(test.uname)
|
||||
if test.shouldErr && err == nil {
|
||||
t.Errorf("Test %d: Expected error, but there was none", i)
|
||||
}
|
||||
if !test.shouldErr && err != nil {
|
||||
t.Errorf("Test %d: Expected no error, but got [%v]", i, err)
|
||||
}
|
||||
if ret != test.output {
|
||||
t.Errorf("Test %d: Expected return value %v, but got %v", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidKey(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
key string
|
||||
output bool
|
||||
}{
|
||||
{"", false},
|
||||
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
||||
{"aaaaaaaa-aaa-aaaaaa-aaaaaaaa-aaa_aacaaaa", true},
|
||||
{"aaaaaaaa-aaa-aaaaaa#aaaaaaaa-aaa_aacaaaa", false},
|
||||
{"aaaaaaaa-aaa-aaaaaa-aaaaaaaa-aaa_aacaaaaa", false},
|
||||
} {
|
||||
ret := validKey(test.key)
|
||||
if ret != test.output {
|
||||
t.Errorf("Test %d: Expected return value %t, but got %t", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValidSubdomain(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
subdomain string
|
||||
output bool
|
||||
}{
|
||||
{"a097455b-52cc-4569-90c8-7a4b97c6eba8", true},
|
||||
{"a-97455b-52cc-4569-90c8-7a4b97c6eba8", true},
|
||||
{"foo.example.com", false},
|
||||
{"foo-example-com", true},
|
||||
{"", false},
|
||||
{"&!#!25123!%!'%", false},
|
||||
} {
|
||||
ret := validSubdomain(test.subdomain)
|
||||
if ret != test.output {
|
||||
t.Errorf("Test %d: Expected return value %t, but got %t", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidTXT(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
txt string
|
||||
output bool
|
||||
}{
|
||||
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
||||
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
|
||||
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaa#aaaaaaaaaaaaaa", false},
|
||||
{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
|
||||
{"", false},
|
||||
} {
|
||||
ret := validTXT(test.txt)
|
||||
if ret != test.output {
|
||||
t.Errorf("Test %d: Expected return value %t, but got %t", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrectPassword(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
pw string
|
||||
hash string
|
||||
output bool
|
||||
}{
|
||||
{"PUrNTjU24JYNEOCeS2JcjaJGv1sinT80oV9--dpX",
|
||||
"$2a$10$ldVoGU5yrdlbPzuPUbUfleVovGjaRelP9tql0IltVUJk778gf.2tu",
|
||||
true},
|
||||
{"PUrNTjU24JYNEOCeS2JcjaJGv1sinT80oV9--dpX",
|
||||
"$2a$10$ldVoGU5yrdlbPzuPUbUfleVovGjaRelP9tql0IltVUJk778gf.2t",
|
||||
false},
|
||||
{"PUrNTjU24JYNEOCeS2JcjaJGv1sinT80oV9--dp",
|
||||
"$2a$10$ldVoGU5yrdlbPzuPUbUfleVovGjaRelP9tql0IltVUJk778gf.2tu",
|
||||
false},
|
||||
{"", "", false},
|
||||
} {
|
||||
ret := acmedns.CorrectPassword(test.pw, test.hash)
|
||||
if ret != test.output {
|
||||
t.Errorf("Test %d: Expected return value %t, but got %t", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetValidCIDRMasks(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
input acmedns.Cidrslice
|
||||
output acmedns.Cidrslice
|
||||
}{
|
||||
{acmedns.Cidrslice{"10.0.0.1/24"}, acmedns.Cidrslice{"10.0.0.1/24"}},
|
||||
{acmedns.Cidrslice{"invalid", "127.0.0.1/32"}, acmedns.Cidrslice{"127.0.0.1/32"}},
|
||||
{acmedns.Cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}, acmedns.Cidrslice{"2002:c0a8::0/32", "8.8.8.8/32"}},
|
||||
} {
|
||||
ret := test.input.ValidEntries()
|
||||
if len(ret) == len(test.output) {
|
||||
for i, v := range ret {
|
||||
if v != test.output[i] {
|
||||
t.Errorf("Test %d: Expected %q but got %q", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
t.Errorf("Test %d: Expected %q but got %q", i, test.output, ret)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/joohoi/acme-dns/pkg/acmedns"
|
||||
)
|
||||
|
||||
type acmednsdb struct {
|
||||
DB *sql.DB
|
||||
Mutex sync.Mutex
|
||||
Logger *zap.SugaredLogger
|
||||
Config *acmedns.AcmeDnsConfig
|
||||
}
|
||||
|
||||
// DBVersion shows the database version this code uses. This is used for update checks.
|
||||
var DBVersion = 1
|
||||
|
||||
var acmeTable = `
|
||||
CREATE TABLE IF NOT EXISTS acmedns(
|
||||
Name TEXT,
|
||||
Value TEXT
|
||||
);`
|
||||
|
||||
var userTable = `
|
||||
CREATE TABLE IF NOT EXISTS records(
|
||||
Username TEXT UNIQUE NOT NULL PRIMARY KEY,
|
||||
Password TEXT UNIQUE NOT NULL,
|
||||
Subdomain TEXT UNIQUE NOT NULL,
|
||||
AllowFrom TEXT
|
||||
);`
|
||||
|
||||
var txtTable = `
|
||||
CREATE TABLE IF NOT EXISTS txt(
|
||||
Subdomain TEXT NOT NULL,
|
||||
Value TEXT NOT NULL DEFAULT '',
|
||||
LastUpdate INT
|
||||
);`
|
||||
|
||||
var txtTablePG = `
|
||||
CREATE TABLE IF NOT EXISTS txt(
|
||||
rowid SERIAL,
|
||||
Subdomain TEXT NOT NULL,
|
||||
Value TEXT NOT NULL DEFAULT '',
|
||||
LastUpdate INT
|
||||
);`
|
||||
|
||||
// getSQLiteStmt replaces all PostgreSQL prepared statement placeholders (eg. $1, $2) with SQLite variant "?"
|
||||
func getSQLiteStmt(s string) string {
|
||||
re, _ := regexp.Compile(`\$[0-9]`)
|
||||
return re.ReplaceAllString(s, "?")
|
||||
}
|
||||
|
||||
func Init(config *acmedns.AcmeDnsConfig, logger *zap.SugaredLogger) (acmedns.AcmednsDB, error) {
|
||||
var d = &acmednsdb{Config: config, Logger: logger}
|
||||
d.Mutex.Lock()
|
||||
defer d.Mutex.Unlock()
|
||||
db, err := sql.Open(config.Database.Engine, config.Database.Connection)
|
||||
if err != nil {
|
||||
return d, err
|
||||
}
|
||||
d.DB = db
|
||||
// Check version first to try to catch old versions without version string
|
||||
var versionString string
|
||||
_ = d.DB.QueryRow("SELECT Value FROM acmedns WHERE Name='db_version'").Scan(&versionString)
|
||||
if versionString == "" {
|
||||
versionString = "0"
|
||||
}
|
||||
_, _ = d.DB.Exec(acmeTable)
|
||||
_, _ = d.DB.Exec(userTable)
|
||||
if config.Database.Engine == "sqlite" {
|
||||
_, _ = d.DB.Exec(txtTable)
|
||||
} else {
|
||||
_, _ = d.DB.Exec(txtTablePG)
|
||||
}
|
||||
// If everything is fine, handle db upgrade tasks
|
||||
if err == nil {
|
||||
err = d.checkDBUpgrades(versionString)
|
||||
}
|
||||
if err == nil {
|
||||
if versionString == "0" {
|
||||
// No errors so we should now be in version 1
|
||||
insversion := fmt.Sprintf("INSERT INTO acmedns (Name, Value) values('db_version', '%d')", DBVersion)
|
||||
_, err = db.Exec(insversion)
|
||||
}
|
||||
}
|
||||
return d, err
|
||||
}
|
||||
|
||||
func (d *acmednsdb) checkDBUpgrades(versionString string) error {
|
||||
var err error
|
||||
version, err := strconv.Atoi(versionString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if version != DBVersion {
|
||||
return d.handleDBUpgrades(version)
|
||||
}
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (d *acmednsdb) handleDBUpgrades(version int) error {
|
||||
if version == 0 {
|
||||
return d.handleDBUpgradeTo1()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *acmednsdb) handleDBUpgradeTo1() error {
|
||||
var err error
|
||||
var subdomains []string
|
||||
rows, err := d.DB.Query("SELECT Subdomain FROM records")
|
||||
if err != nil {
|
||||
d.Logger.Errorw("Error in DB upgrade",
|
||||
"error", err.Error())
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var subdomain string
|
||||
err = rows.Scan(&subdomain)
|
||||
if err != nil {
|
||||
d.Logger.Errorw("Error in DB upgrade while reading values",
|
||||
"error", err.Error())
|
||||
return err
|
||||
}
|
||||
subdomains = append(subdomains, subdomain)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
d.Logger.Errorw("Error in DB upgrade while inserting values",
|
||||
"error", err.Error())
|
||||
return err
|
||||
}
|
||||
tx, err := d.DB.Begin()
|
||||
// Rollback if errored, commit if not
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return
|
||||
}
|
||||
_ = tx.Commit()
|
||||
}()
|
||||
_, _ = tx.Exec("DELETE FROM txt")
|
||||
for _, subdomain := range subdomains {
|
||||
if subdomain != "" {
|
||||
// Insert two rows for each subdomain to txt table
|
||||
err = d.NewTXTValuesInTransaction(tx, subdomain)
|
||||
if err != nil {
|
||||
d.Logger.Errorw("Error in DB upgrade while inserting values",
|
||||
"error", err.Error())
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// SQLite doesn't support dropping columns
|
||||
if d.Config.Database.Engine != "sqlite" {
|
||||
_, _ = tx.Exec("ALTER TABLE records DROP COLUMN IF EXISTS Value")
|
||||
_, _ = tx.Exec("ALTER TABLE records DROP COLUMN IF EXISTS LastActive")
|
||||
}
|
||||
_, err = tx.Exec("UPDATE acmedns SET Value='1' WHERE Name='db_version'")
|
||||
return err
|
||||
}
|
||||
|
||||
// NewTXTValuesInTransaction creates two rows for subdomain to the txt table
|
||||
func (d *acmednsdb) NewTXTValuesInTransaction(tx *sql.Tx, subdomain string) error {
|
||||
var err error
|
||||
instr := fmt.Sprintf("INSERT INTO txt (Subdomain, LastUpdate) values('%s', 0)", subdomain)
|
||||
_, _ = tx.Exec(instr)
|
||||
_, _ = tx.Exec(instr)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *acmednsdb) Register(afrom acmedns.Cidrslice) (acmedns.ACMETxt, error) {
|
||||
d.Mutex.Lock()
|
||||
defer d.Mutex.Unlock()
|
||||
var err error
|
||||
tx, err := d.DB.Begin()
|
||||
// Rollback if errored, commit if not
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
return
|
||||
}
|
||||
_ = tx.Commit()
|
||||
}()
|
||||
a := acmedns.NewACMETxt()
|
||||
a.AllowFrom = acmedns.Cidrslice(afrom.ValidEntries())
|
||||
passwordHash, err := bcrypt.GenerateFromPassword([]byte(a.Password), 10)
|
||||
regSQL := `
|
||||
INSERT INTO records(
|
||||
Username,
|
||||
Password,
|
||||
Subdomain,
|
||||
AllowFrom)
|
||||
values($1, $2, $3, $4)`
|
||||
if d.Config.Database.Engine == "sqlite" {
|
||||
regSQL = getSQLiteStmt(regSQL)
|
||||
}
|
||||
sm, err := tx.Prepare(regSQL)
|
||||
if err != nil {
|
||||
d.Logger.Errorw("Database error in prepare",
|
||||
"error", err.Error())
|
||||
return a, fmt.Errorf("failed to prepare registration statement: %w", err)
|
||||
}
|
||||
defer sm.Close()
|
||||
_, err = sm.Exec(a.Username.String(), passwordHash, a.Subdomain, a.AllowFrom.JSON())
|
||||
if err == nil {
|
||||
err = d.NewTXTValuesInTransaction(tx, a.Subdomain)
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (d *acmednsdb) GetByUsername(u uuid.UUID) (acmedns.ACMETxt, error) {
|
||||
d.Mutex.Lock()
|
||||
defer d.Mutex.Unlock()
|
||||
var results []acmedns.ACMETxt
|
||||
getSQL := `
|
||||
SELECT Username, Password, Subdomain, AllowFrom
|
||||
FROM records
|
||||
WHERE Username=$1 LIMIT 1
|
||||
`
|
||||
if d.Config.Database.Engine == "sqlite" {
|
||||
getSQL = getSQLiteStmt(getSQL)
|
||||
}
|
||||
|
||||
sm, err := d.DB.Prepare(getSQL)
|
||||
if err != nil {
|
||||
return acmedns.ACMETxt{}, err
|
||||
}
|
||||
defer sm.Close()
|
||||
rows, err := sm.Query(u.String())
|
||||
if err != nil {
|
||||
return acmedns.ACMETxt{}, fmt.Errorf("failed to query user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
// It will only be one row though
|
||||
for rows.Next() {
|
||||
txt, err := d.getModelFromRow(rows)
|
||||
if err != nil {
|
||||
return acmedns.ACMETxt{}, err
|
||||
}
|
||||
results = append(results, txt)
|
||||
}
|
||||
if len(results) > 0 {
|
||||
return results[0], nil
|
||||
}
|
||||
return acmedns.ACMETxt{}, fmt.Errorf("user not found: %s", u.String())
|
||||
}
|
||||
|
||||
func (d *acmednsdb) GetTXTForDomain(domain string) ([]string, error) {
|
||||
d.Mutex.Lock()
|
||||
defer d.Mutex.Unlock()
|
||||
domain = acmedns.SanitizeString(domain)
|
||||
var txts []string
|
||||
getSQL := `
|
||||
SELECT Value FROM txt WHERE Subdomain=$1 LIMIT 2
|
||||
`
|
||||
if d.Config.Database.Engine == "sqlite" {
|
||||
getSQL = getSQLiteStmt(getSQL)
|
||||
}
|
||||
|
||||
sm, err := d.DB.Prepare(getSQL)
|
||||
if err != nil {
|
||||
return txts, err
|
||||
}
|
||||
defer sm.Close()
|
||||
rows, err := sm.Query(domain)
|
||||
if err != nil {
|
||||
return txts, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var rtxt string
|
||||
err = rows.Scan(&rtxt)
|
||||
if err != nil {
|
||||
return txts, err
|
||||
}
|
||||
txts = append(txts, rtxt)
|
||||
}
|
||||
return txts, nil
|
||||
}
|
||||
|
||||
func (d *acmednsdb) Update(a acmedns.ACMETxtPost) error {
|
||||
d.Mutex.Lock()
|
||||
defer d.Mutex.Unlock()
|
||||
var err error
|
||||
// Data in a is already sanitized
|
||||
timenow := time.Now().Unix()
|
||||
|
||||
updSQL := `
|
||||
UPDATE txt SET Value=$1, LastUpdate=$2
|
||||
WHERE rowid=(
|
||||
SELECT rowid FROM txt WHERE Subdomain=$3 ORDER BY LastUpdate LIMIT 1)
|
||||
`
|
||||
if d.Config.Database.Engine == "sqlite" {
|
||||
updSQL = getSQLiteStmt(updSQL)
|
||||
}
|
||||
|
||||
sm, err := d.DB.Prepare(updSQL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sm.Close()
|
||||
_, err = sm.Exec(a.Value, timenow, a.Subdomain)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *acmednsdb) getModelFromRow(r *sql.Rows) (acmedns.ACMETxt, error) {
|
||||
txt := acmedns.ACMETxt{}
|
||||
afrom := ""
|
||||
err := r.Scan(
|
||||
&txt.Username,
|
||||
&txt.Password,
|
||||
&txt.Subdomain,
|
||||
&afrom)
|
||||
if err != nil {
|
||||
d.Logger.Errorw("Row scan error",
|
||||
"error", err.Error())
|
||||
}
|
||||
|
||||
cslice := acmedns.Cidrslice{}
|
||||
err = json.Unmarshal([]byte(afrom), &cslice)
|
||||
if err != nil {
|
||||
d.Logger.Errorw("JSON unmarshall error",
|
||||
"error", err.Error())
|
||||
}
|
||||
txt.AllowFrom = cslice
|
||||
return txt, err
|
||||
}
|
||||
|
||||
func (d *acmednsdb) Close() {
|
||||
d.DB.Close()
|
||||
}
|
||||
|
||||
func (d *acmednsdb) GetBackend() *sql.DB {
|
||||
return d.DB
|
||||
}
|
||||
|
||||
func (d *acmednsdb) SetBackend(backend *sql.DB) {
|
||||
d.DB = backend
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/erikstmartin/go-testdb"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/joohoi/acme-dns/pkg/acmedns"
|
||||
)
|
||||
|
||||
type testResult struct {
|
||||
lastID int64
|
||||
affectedRows int64
|
||||
}
|
||||
|
||||
func (r testResult) LastInsertId() (int64, error) {
|
||||
return r.lastID, nil
|
||||
}
|
||||
|
||||
func (r testResult) RowsAffected() (int64, error) {
|
||||
return r.affectedRows, nil
|
||||
}
|
||||
|
||||
func fakeConfigAndLogger() (acmedns.AcmeDnsConfig, *zap.SugaredLogger) {
|
||||
c := acmedns.AcmeDnsConfig{}
|
||||
c.Database.Engine = "sqlite"
|
||||
c.Database.Connection = ":memory:"
|
||||
l := zap.NewNop().Sugar()
|
||||
return c, l
|
||||
}
|
||||
|
||||
func fakeDB() acmedns.AcmednsDB {
|
||||
conf, logger := fakeConfigAndLogger()
|
||||
db, _ := Init(&conf, logger)
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRegisterNoCIDR(t *testing.T) {
|
||||
// Register tests
|
||||
DB := fakeDB()
|
||||
_, err := DB.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Registration failed, got error [%v]", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMany(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
for i, test := range []struct {
|
||||
input acmedns.Cidrslice
|
||||
output acmedns.Cidrslice
|
||||
}{
|
||||
{acmedns.Cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}, acmedns.Cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}},
|
||||
{acmedns.Cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, acmedns.Cidrslice{}},
|
||||
{acmedns.Cidrslice{"7.6.5.4/32", "invalid", "1.0.0.1/2"}, acmedns.Cidrslice{"7.6.5.4/32", "1.0.0.1/2"}},
|
||||
} {
|
||||
user, err := DB.Register(test.input)
|
||||
if err != nil {
|
||||
t.Errorf("Test %d: Got error from register method: [%v]", i, err)
|
||||
}
|
||||
res, err := DB.GetByUsername(user.Username)
|
||||
if err != nil {
|
||||
t.Errorf("Test %d: Got error when fetching username: [%v]", i, err)
|
||||
}
|
||||
if len(user.AllowFrom) != len(test.output) {
|
||||
t.Errorf("Test %d: Expected to receive struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(user.AllowFrom))
|
||||
}
|
||||
if len(res.AllowFrom) != len(test.output) {
|
||||
t.Errorf("Test %d: Expected to receive struct with [%d] entries in AllowFrom, but got [%d] records", i, len(test.output), len(res.AllowFrom))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetByUsername(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
// Create reg to refer to
|
||||
reg, err := DB.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Registration failed, got error [%v]", err)
|
||||
}
|
||||
|
||||
regUser, err := DB.GetByUsername(reg.Username)
|
||||
if err != nil {
|
||||
t.Errorf("Could not get test user, got error [%v]", err)
|
||||
}
|
||||
|
||||
if reg.Username != regUser.Username {
|
||||
t.Errorf("GetByUsername username [%q] did not match the original [%q]", regUser.Username, reg.Username)
|
||||
}
|
||||
|
||||
if reg.Subdomain != regUser.Subdomain {
|
||||
t.Errorf("GetByUsername subdomain [%q] did not match the original [%q]", regUser.Subdomain, reg.Subdomain)
|
||||
}
|
||||
|
||||
// regUser password already is a bcrypt hash
|
||||
if !acmedns.CorrectPassword(reg.Password, regUser.Password) {
|
||||
t.Errorf("The password [%s] does not match the hash [%s]", reg.Password, regUser.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareErrors(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
reg, _ := DB.Register(acmedns.Cidrslice{})
|
||||
tdb, err := sql.Open("testdb", "")
|
||||
if err != nil {
|
||||
t.Errorf("Got error: %v", err)
|
||||
}
|
||||
oldDb := DB.GetBackend()
|
||||
DB.SetBackend(tdb)
|
||||
defer DB.SetBackend(oldDb)
|
||||
defer testdb.Reset()
|
||||
|
||||
_, err = DB.GetByUsername(reg.Username)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but didn't get one")
|
||||
}
|
||||
|
||||
_, err = DB.GetTXTForDomain(reg.Subdomain)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error, but didn't get one")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryExecErrors(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
reg, _ := DB.Register(acmedns.Cidrslice{})
|
||||
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
|
||||
return testResult{1, 0}, errors.New("Prepared query error")
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
_, err = DB.GetByUsername(reg.Username)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from exec, but got none")
|
||||
}
|
||||
|
||||
_, err = DB.GetTXTForDomain(reg.Subdomain)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from exec in GetByDomain, but got none")
|
||||
}
|
||||
|
||||
_, err = DB.Register(acmedns.Cidrslice{})
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from exec in Register, but got none")
|
||||
}
|
||||
reg.Value = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
err = DB.Update(reg.ACMETxtPost)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from exec in Update, but got none")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestQueryScanErrors(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
reg, _ := DB.Register(acmedns.Cidrslice{})
|
||||
|
||||
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
|
||||
return testResult{1, 0}, errors.New("Prepared query error")
|
||||
})
|
||||
|
||||
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
|
||||
columns := []string{"Only one"}
|
||||
resultrows := "this value"
|
||||
return testdb.RowsFromCSVString(columns, resultrows), nil
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
_, err = DB.GetByUsername(reg.Username)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from scan in, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBadDBValues(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
reg, _ := DB.Register(acmedns.Cidrslice{})
|
||||
|
||||
testdb.SetQueryWithArgsFunc(func(query string, args []driver.Value) (result driver.Rows, err error) {
|
||||
columns := []string{"Username", "Password", "Subdomain", "Value", "LastActive"}
|
||||
resultrows := "invalid,invalid,invalid,invalid,"
|
||||
return testdb.RowsFromCSVString(columns, resultrows), nil
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
_, err = DB.GetByUsername(reg.Username)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from scan in, but got none")
|
||||
}
|
||||
|
||||
_, err = DB.GetTXTForDomain(reg.Subdomain)
|
||||
if err == nil {
|
||||
t.Errorf("Expected error from scan in GetByDomain, but got none")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTXTForDomain(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
// Create reg to refer to
|
||||
reg, err := DB.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Registration failed, got error [%v]", err)
|
||||
}
|
||||
|
||||
txtval1 := "___validation_token_received_from_the_ca___"
|
||||
txtval2 := "___validation_token_received_YEAH_the_ca___"
|
||||
|
||||
reg.Value = txtval1
|
||||
_ = DB.Update(reg.ACMETxtPost)
|
||||
|
||||
reg.Value = txtval2
|
||||
_ = DB.Update(reg.ACMETxtPost)
|
||||
|
||||
regDomainSlice, err := DB.GetTXTForDomain(reg.Subdomain)
|
||||
if err != nil {
|
||||
t.Errorf("Could not get test user, got error [%v]", err)
|
||||
}
|
||||
if len(regDomainSlice) == 0 {
|
||||
t.Errorf("No rows returned for GetTXTForDomain [%s]", reg.Subdomain)
|
||||
}
|
||||
|
||||
var val1found = false
|
||||
var val2found = false
|
||||
for _, v := range regDomainSlice {
|
||||
if v == txtval1 {
|
||||
val1found = true
|
||||
}
|
||||
if v == txtval2 {
|
||||
val2found = true
|
||||
}
|
||||
}
|
||||
if !val1found {
|
||||
t.Errorf("No TXT value found for val1")
|
||||
}
|
||||
if !val2found {
|
||||
t.Errorf("No TXT value found for val2")
|
||||
}
|
||||
|
||||
// Not found
|
||||
regNotfound, _ := DB.GetTXTForDomain("does-not-exist")
|
||||
if len(regNotfound) > 0 {
|
||||
t.Errorf("No records should be returned.")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate(t *testing.T) {
|
||||
DB := fakeDB()
|
||||
// Create reg to refer to
|
||||
reg, err := DB.Register(acmedns.Cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Registration failed, got error [%v]", err)
|
||||
}
|
||||
|
||||
regUser, err := DB.GetByUsername(reg.Username)
|
||||
if err != nil {
|
||||
t.Errorf("Could not get test user, got error [%v]", err)
|
||||
}
|
||||
|
||||
// Set new values (only TXT should be updated) (matches by username and subdomain)
|
||||
|
||||
validTXT := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
|
||||
regUser.Password = "nevergonnagiveyouup"
|
||||
regUser.Value = validTXT
|
||||
|
||||
err = DB.Update(regUser.ACMETxtPost)
|
||||
if err != nil {
|
||||
t.Errorf("DB Update failed, got error: [%v]", err)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user