mirror of
https://github.com/acme-dns/acme-dns.git
synced 2026-02-22 09:35:35 -07:00
* 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>
362 lines
8.2 KiB
Go
362 lines
8.2 KiB
Go
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
|
|
}
|