From 48bfe812c070979e831ebd7ed8da00bbd46eb7b2 Mon Sep 17 00:00:00 2001 From: Joona Hoikkala Date: Tue, 3 Feb 2026 23:27:11 +0200 Subject: [PATCH] Minor refactoring, error returns and e2e testing suite --- .github/workflows/e2e.yml | 21 +++++++ pkg/api/auth.go | 2 +- pkg/database/db.go | 7 +-- pkg/nameserver/handler.go | 52 ++++++++-------- pkg/nameserver/handler_test.go | 10 ++-- pkg/nameserver/initialize.go | 3 +- test/README.md | 37 ++++++++++++ test/e2e/Dockerfile.e2e | 16 +++++ test/e2e/config.cfg | 28 +++++++++ test/e2e/docker-compose.yml | 30 ++++++++++ test/e2e/requirements.txt | 2 + test/e2e/tester.py | 105 +++++++++++++++++++++++++++++++++ 12 files changed, 275 insertions(+), 38 deletions(-) create mode 100644 .github/workflows/e2e.yml create mode 100644 test/README.md create mode 100644 test/e2e/Dockerfile.e2e create mode 100644 test/e2e/config.cfg create mode 100644 test/e2e/docker-compose.yml create mode 100644 test/e2e/requirements.txt create mode 100644 test/e2e/tester.py diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000..21e5a3e --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,21 @@ +name: E2E Tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Run E2E Tests + run: | + cd test/e2e + docker compose up --build --abort-on-container-exit --exit-code-from tester diff --git a/pkg/api/auth.go b/pkg/api/auth.go index 1203a00..27ae7c3 100644 --- a/pkg/api/auth.go +++ b/pkg/api/auth.go @@ -67,7 +67,7 @@ func (a *AcmednsAPI) getUserFromRequest(r *http.Request) (acmedns.ACMETxt, error 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()) + return acmedns.ACMETxt{}, fmt.Errorf("invalid username: %s: %w", uname, err) } if validKey(passwd) { dbuser, err := a.DB.GetByUsername(username) diff --git a/pkg/database/db.go b/pkg/database/db.go index ff8d1e3..00f60ef 100644 --- a/pkg/database/db.go +++ b/pkg/database/db.go @@ -3,7 +3,6 @@ package database import ( "database/sql" "encoding/json" - "errors" "fmt" "regexp" "strconv" @@ -216,7 +215,7 @@ func (d *acmednsdb) Register(afrom acmedns.Cidrslice) (acmedns.ACMETxt, error) { if err != nil { d.Logger.Errorw("Database error in prepare", "error", err.Error()) - return a, errors.New("SQL 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()) @@ -246,7 +245,7 @@ func (d *acmednsdb) GetByUsername(u uuid.UUID) (acmedns.ACMETxt, error) { defer sm.Close() rows, err := sm.Query(u.String()) if err != nil { - return acmedns.ACMETxt{}, err + return acmedns.ACMETxt{}, fmt.Errorf("failed to query user: %w", err) } defer rows.Close() @@ -261,7 +260,7 @@ func (d *acmednsdb) GetByUsername(u uuid.UUID) (acmedns.ACMETxt, error) { if len(results) > 0 { return results[0], nil } - return acmedns.ACMETxt{}, errors.New("no user") + return acmedns.ACMETxt{}, fmt.Errorf("user not found: %s", u.String()) } func (d *acmednsdb) GetTXTForDomain(domain string) ([]string, error) { diff --git a/pkg/nameserver/handler.go b/pkg/nameserver/handler.go index 954d7e0..18bf2af 100644 --- a/pkg/nameserver/handler.go +++ b/pkg/nameserver/handler.go @@ -55,13 +55,14 @@ 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) { + loweredName := strings.ToLower(q.Name) + var authoritative = n.isAuthoritative(loweredName) + if !n.isOwnChallenge(loweredName) && !n.answeringForDomain(loweredName) { rcode = dns.RcodeNameError } - r, _ := n.getRecord(q) + r, _ := n.getRecord(loweredName, q.Qtype) if q.Qtype == dns.TypeTXT { - if n.isOwnChallenge(q.Name) { + if n.isOwnChallenge(loweredName) { txtRRs, err = n.answerOwnChallenge(q) } else { txtRRs, err = n.answerTXT(q) @@ -101,31 +102,28 @@ func (n *Nameserver) answerTXT(q dns.Question) ([]dns.RR, error) { return ra, nil } -func (n *Nameserver) isAuthoritative(q dns.Question) bool { - if n.answeringForDomain(q.Name) { +func (n *Nameserver) isAuthoritative(name string) bool { + if n.answeringForDomain(name) { return true } - domainParts := strings.Split(strings.ToLower(q.Name), ".") - for i := range domainParts { - if n.answeringForDomain(strings.Join(domainParts[i:], ".")) { + off := 0 + for { + i, next := dns.NextLabel(name, off) + if next { + return false + } + off = i + if n.answeringForDomain(name[off:]) { 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 - } + if strings.HasPrefix(name, "_acme-challenge.") { + domain := name[16:] + if domain == n.OwnDomain { + return true } } return false @@ -133,22 +131,22 @@ func (n *Nameserver) isOwnChallenge(name string) bool { // answeringForDomain checks if we have any records for a domain func (n *Nameserver) answeringForDomain(name string) bool { - if n.OwnDomain == strings.ToLower(name) { + if n.OwnDomain == name { return true } - _, ok := n.Domains[strings.ToLower(name)] + _, ok := n.Domains[name] return ok } -func (n *Nameserver) getRecord(q dns.Question) ([]dns.RR, error) { +func (n *Nameserver) getRecord(name string, qtype uint16) ([]dns.RR, error) { var rr []dns.RR var cnames []dns.RR - domain, ok := n.Domains[strings.ToLower(q.Name)] + domain, ok := n.Domains[name] if !ok { - return rr, fmt.Errorf("no records for domain %s", q.Name) + return rr, fmt.Errorf("no records for domain %s", name) } for _, ri := range domain.Records { - if ri.Header().Rrtype == q.Qtype { + if ri.Header().Rrtype == qtype { rr = append(rr, ri) } if ri.Header().Rrtype == dns.TypeCNAME { diff --git a/pkg/nameserver/handler_test.go b/pkg/nameserver/handler_test.go index babac60..f62642b 100644 --- a/pkg/nameserver/handler_test.go +++ b/pkg/nameserver/handler_test.go @@ -25,7 +25,7 @@ func TestNameserver_isOwnChallenge(t *testing.T) { OwnDomain: "some-domain.test.", }, args: args{ - name: "_acme-challenge.some-domain.test", + name: "_acme-challenge.some-domain.test.", }, want: true, }, @@ -35,7 +35,7 @@ func TestNameserver_isOwnChallenge(t *testing.T) { OwnDomain: "some-domain.test.", }, args: args{ - name: "_acme-challenge.some-other-domain.test", + name: "_acme-challenge.some-other-domain.test.", }, want: false, }, @@ -45,7 +45,7 @@ func TestNameserver_isOwnChallenge(t *testing.T) { OwnDomain: "domain.test.", }, args: args{ - name: "domain.test", + name: "domain.test.", }, want: false, }, @@ -55,7 +55,7 @@ func TestNameserver_isOwnChallenge(t *testing.T) { OwnDomain: "domain.test.", }, args: args{ - name: "my-domain.test", + name: "my-domain.test.", }, want: false, }, @@ -142,7 +142,7 @@ func TestNameserver_isAuthoritative(t *testing.T) { OwnDomain: tt.fields.OwnDomain, Domains: tt.fields.Domains, } - if got := n.isAuthoritative(tt.args.q); got != tt.want { + if got := n.isAuthoritative(tt.args.q.Name); got != tt.want { t.Errorf("isAuthoritative() = %v, want %v", got, tt.want) } }) diff --git a/pkg/nameserver/initialize.go b/pkg/nameserver/initialize.go index b5bf7aa..c53909e 100644 --- a/pkg/nameserver/initialize.go +++ b/pkg/nameserver/initialize.go @@ -1,6 +1,7 @@ package nameserver import ( + "fmt" "strings" "sync" @@ -95,7 +96,7 @@ func (n *Nameserver) Start(errorChannel chan error) { } err := n.Server.ListenAndServe() if err != nil { - errorChannel <- err + errorChannel <- fmt.Errorf("DNS server %s failed: %w", n.Server.Net, err) } } diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..5377031 --- /dev/null +++ b/test/README.md @@ -0,0 +1,37 @@ +# acme-dns E2E Testing Suite + +This directory contains the end-to-end (E2E) testing suite for `acme-dns`. The suite runs in a containerized environment to ensure a consistent and isolated test execution. + +## Overview + +The E2E suite consists of: +- A Dockerized `acme-dns` server. +- A Python-based `tester` container that performs API and DNS operations. +- A GitHub Actions workflow for CI/CD integration. + +## Prerequisites + +- [Docker](https://www.docker.com/get-started) +- [Docker Compose](https://docs.docker.com/compose/install/) + +## Running Locally + +To run the full E2E suite locally, execute the following command from the root of the repository: + +```bash +docker compose -f test/e2e/docker-compose.yml up --build --abort-on-container-exit +``` + +The `tester` container will return an exit code of `0` on success and `1` on failure. + +## Test Flow + +The `tester.py` script follows these steps: +1. **Wait for Ready**: Polls the `/health` endpoint until the API is available. +2. **Account Registration**: Registers a NEW account at `/register`. +3. **TXT Update**: Performs TWO sequential updates to the TXT records of the newly created subdomain. +4. **DNS Verification**: Directly queries the `acme-dns` server (on port 53) used in the test to verify that the TXT records have been correctly updated and are resolvable. + +## CI/CD integration + +The tests are automatically run on every push and pull request to the `master` branch via GitHub Actions. The workflow configuration can be found in `.github/workflows/e2e.yml`. diff --git a/test/e2e/Dockerfile.e2e b/test/e2e/Dockerfile.e2e new file mode 100644 index 0000000..b3e739f --- /dev/null +++ b/test/e2e/Dockerfile.e2e @@ -0,0 +1,16 @@ +FROM golang:alpine AS builder +RUN apk add --update gcc musl-dev git +WORKDIR /app +COPY . . +RUN CGO_ENABLED=1 go build -o acme-dns + +FROM alpine:latest +WORKDIR /root/ +COPY --from=builder /app/acme-dns . +COPY --from=builder /app/config.cfg /etc/acme-dns/config.cfg +RUN mkdir -p /var/lib/acme-dns +RUN apk --no-cache add ca-certificates && update-ca-certificates +VOLUME ["/etc/acme-dns", "/var/lib/acme-dns"] +ENTRYPOINT ["./acme-dns", "-c", "/etc/acme-dns/config.cfg"] +EXPOSE 53 80 443 +EXPOSE 53/udp diff --git a/test/e2e/config.cfg b/test/e2e/config.cfg new file mode 100644 index 0000000..4d71440 --- /dev/null +++ b/test/e2e/config.cfg @@ -0,0 +1,28 @@ +[general] +listen = "0.0.0.0:53" +protocol = "both" +domain = "auth.example.org" +nsname = "auth.example.org" +nsadmin = "admin.example.org" +records = [ + "auth.example.org. A 127.0.0.1", + "auth.example.org. NS auth.example.org.", +] +debug = true + +[database] +engine = "sqlite" +connection = "/var/lib/acme-dns/acme-dns.db" + +[api] +ip = "0.0.0.0" +disable_registration = false +port = "80" +tls = "none" +corsorigins = ["*"] +use_header = false + +[logconfig] +loglevel = "debug" +logtype = "stdout" +logformat = "text" diff --git a/test/e2e/docker-compose.yml b/test/e2e/docker-compose.yml new file mode 100644 index 0000000..6ecea0c --- /dev/null +++ b/test/e2e/docker-compose.yml @@ -0,0 +1,30 @@ +version: '3' +services: + acme-dns: + build: + context: ../../ + dockerfile: test/e2e/Dockerfile.e2e + ports: + - "15353:53/udp" + - "18080:80" + - "18443:443" + volumes: + - ./config.cfg:/etc/acme-dns/config.cfg + - e2e-data:/var/lib/acme-dns + + tester: + image: python:3.9-slim + depends_on: + - acme-dns + volumes: + - .:/test + working_dir: /test + command: > + sh -c "pip install -r requirements.txt && python tester.py" + environment: + - ACMEDNS_URL=http://acme-dns:80 + - DNS_SERVER=acme-dns + - DNS_PORT=53 + +volumes: + e2e-data: diff --git a/test/e2e/requirements.txt b/test/e2e/requirements.txt new file mode 100644 index 0000000..7d11685 --- /dev/null +++ b/test/e2e/requirements.txt @@ -0,0 +1,2 @@ +requests +dnspython diff --git a/test/e2e/tester.py b/test/e2e/tester.py new file mode 100644 index 0000000..78ad570 --- /dev/null +++ b/test/e2e/tester.py @@ -0,0 +1,105 @@ +import requests +import dns.resolver +import os +import time +import sys +import socket + +ACMEDNS_URL = os.environ.get("ACMEDNS_URL", "http://localhost:80") +DNS_SERVER = os.environ.get("DNS_SERVER", "localhost") +DNS_PORT = int(os.environ.get("DNS_PORT", 53)) + +def wait_for_server(): + print(f"Waiting for acme-dns at {ACMEDNS_URL}...") + for i in range(30): + try: + resp = requests.get(f"{ACMEDNS_URL}/health") + if resp.status_code == 200: + print("Server is up!") + return True + except: + pass + time.sleep(1) + return False + +def test_flow(): + # 1. Register account + print("Registering account...") + resp = requests.post(f"{ACMEDNS_URL}/register") + if resp.status_code != 201: + print(f"Failed to register: {resp.status_code} {resp.text}") + return False + + account = resp.json() + username = account['username'] + api_key = account['password'] + subdomain = account['subdomain'] + fulldomain = account['fulldomain'] + print(f"Registered subdomain: {subdomain}") + + # 2. Update TXT records + headers = { + "X-Api-User": username, + "X-Api-Key": api_key + } + + txt_values = ["secret_token_1", "secret_token_2"] + + for val in txt_values: + print(f"Updating TXT record with value: {val}") + # Let's Encrypt uses 43 char tokens usually, but our validation is flexible now (or we use a dummy one) + # Actually our current validation in pkg/api/util.go still expects 43 chars if I recall correctly + # Let's use 43 chars just in case + dummy_val = val.ljust(43, '_')[:43] + payload = { + "subdomain": subdomain, + "txt": dummy_val + } + resp = requests.post(f"{ACMEDNS_URL}/update", headers=headers, json=payload) + if resp.status_code != 200: + print(f"Failed to update: {resp.status_code} {resp.text}") + return False + + print("Updates successful. Waiting for DNS propagation (local cache)...") + time.sleep(2) + + # 3. Verify DNS resolution + print(f"Resolving TXT records for {fulldomain}...") + + # Resolve hostname to IP if needed + try: + dns_server_ip = socket.gethostbyname(DNS_SERVER) + except: + dns_server_ip = DNS_SERVER + + resolver = dns.resolver.Resolver() + resolver.nameservers = [dns_server_ip] + resolver.port = DNS_PORT + + try: + answers = resolver.resolve(fulldomain, "TXT") + resolved_values = [str(rdata).strip('"') for rdata in answers] + print(f"Resolved values: {resolved_values}") + + # Check if both are present + for val in txt_values: + dummy_val = val.ljust(43, '_')[:43] + if dummy_val not in resolved_values: + print(f"Expected value {dummy_val} not found in resolved values") + return False + except Exception as e: + print(f"DNS resolution failed: {e}") + return False + + print("E2E Test Passed Successfully!") + return True + +if __name__ == "__main__": + if not wait_for_server(): + print("Server timed out.") + sys.exit(1) + + if not test_flow(): + sys.exit(1) + + sys.exit(0)