Refactor core
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
package acmedns
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"net"
|
||||
)
|
||||
|
||||
// Check if IP belongs to an allowed net
|
||||
func (a ACMETxt) AllowedFrom(ip string) bool {
|
||||
remoteIP := net.ParseIP(ip)
|
||||
// Range not limited
|
||||
if len(a.AllowFrom.ValidEntries()) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, v := range a.AllowFrom.ValidEntries() {
|
||||
_, vnet, _ := net.ParseCIDR(v)
|
||||
if vnet.Contains(remoteIP) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Go through list (most likely from headers) to check for the IP.
|
||||
// Reason for this is that some setups use reverse proxy in front of acme-dns
|
||||
func (a ACMETxt) AllowedFromList(ips []string) bool {
|
||||
if len(ips) == 0 {
|
||||
// If no IP provided, check if no whitelist present (everyone has access)
|
||||
return a.AllowedFrom("")
|
||||
}
|
||||
for _, v := range ips {
|
||||
if a.AllowedFrom(v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func NewACMETxt() ACMETxt {
|
||||
var a = ACMETxt{}
|
||||
password := generatePassword(40)
|
||||
a.Username = uuid.New()
|
||||
a.Password = password
|
||||
a.Subdomain = uuid.New().String()
|
||||
return a
|
||||
}
|
||||
@@ -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,48 @@
|
||||
package acmedns
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
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 ReadConfig(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"
|
||||
}
|
||||
|
||||
return conf, nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
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)
|
||||
ParseRecords()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package acmedns
|
||||
|
||||
import "github.com/google/uuid"
|
||||
|
||||
type Account struct {
|
||||
Username string
|
||||
Password string
|
||||
Subdomain string
|
||||
}
|
||||
|
||||
// DNSConfig 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,31 @@
|
||||
package acmedns
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"math/big"
|
||||
"regexp"
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
+148
@@ -0,0 +1,148 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"net/http"
|
||||
|
||||
"github.com/acme-dns/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
|
||||
}
|
||||
//legolog.Logger = stderrorlog
|
||||
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 "letsencryptstaging":
|
||||
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,
|
||||
}
|
||||
a.Logger.Infow("Listening HTTPS",
|
||||
"host", host,
|
||||
"domain", a.Config.General.Domain)
|
||||
err = srv.ListenAndServeTLS("", "")
|
||||
case "letsencrypt":
|
||||
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,
|
||||
}
|
||||
a.Logger.Infow("Listening HTTPS",
|
||||
"host", host,
|
||||
"domain", a.Config.General.Domain)
|
||||
err = srv.ListenAndServeTLS("", "")
|
||||
case "cert":
|
||||
srv := &http.Server{
|
||||
Addr: host,
|
||||
Handler: c.Handler(api),
|
||||
TLSConfig: cfg,
|
||||
ErrorLog: stderrorlog,
|
||||
}
|
||||
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 == "letsencrypt" {
|
||||
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
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/acme-dns/acme-dns/pkg/acmedns"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
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: %s", uname, err.Error())
|
||||
}
|
||||
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)
|
||||
correctPassword(passwd, "$2a$10$8JEFVNYYhLoBysjAxe2yBuXrkDojBQBkVpXEQgyQyjn43SvJ4vL36")
|
||||
|
||||
return acmedns.ACMETxt{}, fmt.Errorf("invalid username: %s", uname)
|
||||
}
|
||||
if 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,39 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/acme-dns/acme-dns/pkg/acmedns"
|
||||
|
||||
"github.com/mholt/acmez/acme"
|
||||
)
|
||||
|
||||
// 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,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,75 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/acme-dns/acme-dns/pkg/acmedns"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
)
|
||||
|
||||
// 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/acme-dns/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,65 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/acme-dns/acme-dns/pkg/acmedns"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
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 correctPassword(pw string, hash string) bool {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(pw)); err == nil {
|
||||
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,360 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/acme-dns/acme-dns/pkg/acmedns"
|
||||
_ "github.com/glebarez/go-sqlite"
|
||||
"github.com/google/uuid"
|
||||
_ "github.com/lib/pq"
|
||||
"go.uber.org/zap"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
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, errors.New("SQL error")
|
||||
}
|
||||
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{}, 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{}, errors.New("no user")
|
||||
}
|
||||
|
||||
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,301 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"github.com/erikstmartin/go-testdb"
|
||||
"testing"
|
||||
)
|
||||
|
||||
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 TestDBInit(t *testing.T) {
|
||||
fakeDB := new(acmedb)
|
||||
err := fakeDB.Init("notarealegine", "connectionstring")
|
||||
if err == nil {
|
||||
t.Errorf("Was expecting error, didn't get one.")
|
||||
}
|
||||
|
||||
testdb.SetExecWithArgsFunc(func(query string, args []driver.Value) (result driver.Result, err error) {
|
||||
return testResult{1, 0}, errors.New("Prepared query error")
|
||||
})
|
||||
defer testdb.Reset()
|
||||
|
||||
errorDB := new(acmedb)
|
||||
err = errorDB.Init("testdb", "")
|
||||
if err == nil {
|
||||
t.Errorf("Was expecting DB initiation error but got none")
|
||||
}
|
||||
errorDB.Close()
|
||||
}
|
||||
|
||||
func TestRegisterNoCIDR(t *testing.T) {
|
||||
// Register tests
|
||||
_, err := DB.Register(cidrslice{})
|
||||
if err != nil {
|
||||
t.Errorf("Registration failed, got error [%v]", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterMany(t *testing.T) {
|
||||
for i, test := range []struct {
|
||||
input cidrslice
|
||||
output cidrslice
|
||||
}{
|
||||
{cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}, cidrslice{"127.0.0.1/8", "8.8.8.8/32", "1.0.0.1/1"}},
|
||||
{cidrslice{"1.1.1./32", "1922.168.42.42/8", "1.1.1.1/33", "1.2.3.4/"}, cidrslice{}},
|
||||
{cidrslice{"7.6.5.4/32", "invalid", "1.0.0.1/2"}, cidrslice{"7.6.5.4/32", "1.0.0.1/2"}},
|
||||
} {
|
||||
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) {
|
||||
// Create reg to refer to
|
||||
reg, err := DB.Register(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 !correctPassword(reg.Password, regUser.Password) {
|
||||
t.Errorf("The password [%s] does not match the hash [%s]", reg.Password, regUser.Password)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareErrors(t *testing.T) {
|
||||
reg, _ := DB.Register(cidrslice{})
|
||||
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) {
|
||||
reg, _ := DB.Register(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(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) {
|
||||
reg, _ := DB.Register(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) {
|
||||
reg, _ := DB.Register(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) {
|
||||
// Create reg to refer to
|
||||
reg, err := DB.Register(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) {
|
||||
// Create reg to refer to
|
||||
reg, err := DB.Register(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,162 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/miekg/dns"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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.MsgHdr.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.MsgHdr.Rcode = rc
|
||||
m.Answer = append(m.Answer, rr...)
|
||||
}
|
||||
}
|
||||
m.MsgHdr.Authoritative = authoritative
|
||||
if authoritative {
|
||||
if m.MsgHdr.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
|
||||
var authoritative = n.isAuthoritative(q)
|
||||
if !n.isOwnChallenge(q.Name) && !n.answeringForDomain(q.Name) {
|
||||
rcode = dns.RcodeNameError
|
||||
}
|
||||
r, _ := n.getRecord(q)
|
||||
if q.Qtype == dns.TypeTXT {
|
||||
if n.isOwnChallenge(q.Name) {
|
||||
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(q dns.Question) bool {
|
||||
if n.answeringForDomain(q.Name) {
|
||||
return true
|
||||
}
|
||||
domainParts := strings.Split(strings.ToLower(q.Name), ".")
|
||||
for i := range domainParts {
|
||||
if n.answeringForDomain(strings.Join(domainParts[i:], ".")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isOwnChallenge checks if the query is for the domain of this acme-dns instance. Used for answering its own ACME challenges
|
||||
func (n *Nameserver) isOwnChallenge(name string) bool {
|
||||
domainParts := strings.SplitN(name, ".", 2)
|
||||
if len(domainParts) == 2 {
|
||||
if strings.ToLower(domainParts[0]) == "_acme-challenge" {
|
||||
domain := strings.ToLower(domainParts[1])
|
||||
if !strings.HasSuffix(domain, ".") {
|
||||
domain = domain + "."
|
||||
}
|
||||
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 == strings.ToLower(name) {
|
||||
return true
|
||||
}
|
||||
_, ok := n.Domains[strings.ToLower(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (n *Nameserver) getRecord(q dns.Question) ([]dns.RR, error) {
|
||||
var rr []dns.RR
|
||||
var cnames []dns.RR
|
||||
domain, ok := n.Domains[strings.ToLower(q.Name)]
|
||||
if !ok {
|
||||
return rr, fmt.Errorf("no records for domain %s", q.Name)
|
||||
}
|
||||
for _, ri := range domain.Records {
|
||||
if ri.Header().Rrtype == q.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,86 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/acme-dns/acme-dns/pkg/acmedns"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 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
|
||||
SOA dns.RR
|
||||
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)
|
||||
|
||||
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()
|
||||
go dnsServerUDP.Start(errChan)
|
||||
go dnsServerTCP.Start(errChan)
|
||||
} else {
|
||||
dnsServer := NewDNSServer(config, db, logger, config.General.Proto)
|
||||
dnsservers = append(dnsservers, dnsServer)
|
||||
dnsServer.ParseRecords()
|
||||
go dnsServer.Start(errChan)
|
||||
}
|
||||
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.DB = db
|
||||
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)
|
||||
err := n.Server.ListenAndServe()
|
||||
if err != nil {
|
||||
errorChannel <- err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package nameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/miekg/dns"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 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,16 @@
|
||||
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.personalAuthKey = key
|
||||
}
|
||||
|
||||
// answerOwnChallenge answers to ACME challenge for acme-dns own certificate
|
||||
func (n *Nameserver) answerOwnChallenge(q dns.Question) ([]dns.RR, error) {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user