Files
acme-dns/pkg/database/db.go
Joona Hoikkala 5a7bc230b8 Refactoring (#325)
* Refactor core

* Re-added tests

* Small fixes

* Add tests for acmetxt cidrslice and util funcs

* Remove the last dangling reference to old logging package

* Refactoring (#327)

* chore: enable more linters and fix linter issues

* ci: enable linter checks on all branches and disable recurring checks

recurring linter checks don't make that much sense. The code & linter checks should not change on their own over night ;)

* chore: update packages

* Revert "chore: update packages"

This reverts commit 30250bf28c4b39e9e5b3af012a4e28ab036bf9af.

* chore: manually upgrade some packages

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

* Refactoring - improving coverage (#371)

* Increase code coverage in acmedns

* More testing of ReadConfig() and its fallback mechanism

* Found that if someone put a '"' double quote into the filename that we configure zap to log to, it would cause the the JSON created to be invalid. I have replaced the JSON string with proper config

* Better handling of config options for api.TLS - we now error on an invalid value instead of silently failing.

added a basic test for api.setupTLS() (to increase test coverage)

* testing nameserver isOwnChallenge and isAuthoritative methods

* add a unit test for nameserver answerOwnChallenge

* fix linting errors

* bump go and golangci-lint versions in github actions

* Update golangci-lint.yml

Bumping github-actions workflow versions to accommodate some changes in upstream golanci-lint

* Bump Golang version to 1.23 (currently the oldest supported version)

Bump golanglint-ci to 2.0.2 and migrate the config file.

This should resolve the math/rand/v2 issue

* bump golanglint-ci action version

* Fixing up new golanglint-ci warnings and errors

---------

Co-authored-by: Joona Hoikkala <5235109+joohoi@users.noreply.github.com>

* Minor refactoring, error returns and e2e testing suite

* Add a few tests

* Fix linter and umask setting

* Update github actions

* Refine concurrency configuration for GitHub actions

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

---------

Co-authored-by: Florian Ritterhoff <32478819+fritterhoff@users.noreply.github.com>
Co-authored-by: Jason Playne <jason@jasonplayne.com>
2026-02-05 16:04:15 +02:00

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
}