first commit

This commit is contained in:
2026-07-09 22:16:19 +02:00
commit 869749bb01
34 changed files with 3204 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
LICENSE
dockerfile
makefile
.vars
.cursor
*.md
*.sh

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
.env
.vars
data
*.sql
.claude
coredns-powerdns-api-wrapper

13
CLAUDE.md Normal file
View File

@@ -0,0 +1,13 @@
# PowerDNS API Simulator — project rules
Read PLAN.md before doing anything. It is the spec; docs/SEMANTICS.md (produced in Phase 0) is the data-shape contract.
Invariants that hold in every session and every subagent:
- Configuration is environment variables ONLY. No config files. `config.yaml` and its loader are being removed, not maintained.
- Never run acme.sh against the Let's Encrypt production CA. `--staging` always.
- The live MySQL schema is the source of truth for the CoreDNS side, not plugin documentation. Verify against the running DB.
- Dockerfile and Makefile already exist: follow their style, amend only when required, never rewrite.
- The binary starts as root and drops to PUID/PGID before serving. Do not add a `USER` directive to the Dockerfile.
- DNS verification goes through `dig @1.2.3.4` (authoritative, no cache). Remember CoreDNS `zone_update_interval` before declaring a propagation failure.
- Follow existing repository style. Direct, minimal output. No speculative features beyond PLAN.md's scope; the Explicit non-goals section is binding.

338
LICENSE Normal file
View File

@@ -0,0 +1,338 @@
GNU GENERAL PUBLIC LICENSE
Version 2, June 1991
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
<https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The licenses for most software are designed to take away your
freedom to share and change it. By contrast, the GNU General Public
License is intended to guarantee your freedom to share and change free
software--to make sure the software is free for all its users. This
General Public License applies to most of the Free Software
Foundation's software and to any other program whose authors commit to
using it. (Some other Free Software Foundation software is covered by
the GNU Lesser General Public License instead.) You can apply it to
your programs, too.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
this service if you wish), that you receive source code or can get it
if you want it, that you can change the software or use pieces of it
in new free programs; and that you know you can do these things.
To protect your rights, we need to make restrictions that forbid
anyone to deny you these rights or to ask you to surrender the rights.
These restrictions translate to certain responsibilities for you if you
distribute copies of the software, or if you modify it.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must give the recipients all the rights that
you have. You must make sure that they, too, receive or can get the
source code. And you must show them these terms so they know their
rights.
We protect your rights with two steps: (1) copyright the software, and
(2) offer you this license which gives you legal permission to copy,
distribute and/or modify the software.
Also, for each author's protection and ours, we want to make certain
that everyone understands that there is no warranty for this free
software. If the software is modified by someone else and passed on, we
want its recipients to know that what they have is not the original, so
that any problems introduced by others will not reflect on the original
authors' reputations.
Finally, any free program is threatened constantly by software
patents. We wish to avoid the danger that redistributors of a free
program will individually obtain patent licenses, in effect making the
program proprietary. To prevent this, we have made it clear that any
patent must be licensed for everyone's free use or not licensed at all.
The precise terms and conditions for copying, distribution and
modification follow.
GNU GENERAL PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. This License applies to any program or other work which contains
a notice placed by the copyright holder saying it may be distributed
under the terms of this General Public License. The "Program", below,
refers to any such program or work, and a "work based on the Program"
means either the Program or any derivative work under copyright law:
that is to say, a work containing the Program or a portion of it,
either verbatim or with modifications and/or translated into another
language. (Hereinafter, translation is included without limitation in
the term "modification".) Each licensee is addressed as "you".
Activities other than copying, distribution and modification are not
covered by this License; they are outside its scope. The act of
running the Program is not restricted, and the output from the Program
is covered only if its contents constitute a work based on the
Program (independent of having been made by running the Program).
Whether that is true depends on what the Program does.
1. You may copy and distribute verbatim copies of the Program's
source code as you receive it, in any medium, provided that you
conspicuously and appropriately publish on each copy an appropriate
copyright notice and disclaimer of warranty; keep intact all the
notices that refer to this License and to the absence of any warranty;
and give any other recipients of the Program a copy of this License
along with the Program.
You may charge a fee for the physical act of transferring a copy, and
you may at your option offer warranty protection in exchange for a fee.
2. You may modify your copy or copies of the Program or any portion
of it, thus forming a work based on the Program, and copy and
distribute such modifications or work under the terms of Section 1
above, provided that you also meet all of these conditions:
a) You must cause the modified files to carry prominent notices
stating that you changed the files and the date of any change.
b) You must cause any work that you distribute or publish, that in
whole or in part contains or is derived from the Program or any
part thereof, to be licensed as a whole at no charge to all third
parties under the terms of this License.
c) If the modified program normally reads commands interactively
when run, you must cause it, when started running for such
interactive use in the most ordinary way, to print or display an
announcement including an appropriate copyright notice and a
notice that there is no warranty (or else, saying that you provide
a warranty) and that users may redistribute the program under
these conditions, and telling the user how to view a copy of this
License. (Exception: if the Program itself is interactive but
does not normally print such an announcement, your work based on
the Program is not required to print an announcement.)
These requirements apply to the modified work as a whole. If
identifiable sections of that work are not derived from the Program,
and can be reasonably considered independent and separate works in
themselves, then this License, and its terms, do not apply to those
sections when you distribute them as separate works. But when you
distribute the same sections as part of a whole which is a work based
on the Program, the distribution of the whole must be on the terms of
this License, whose permissions for other licensees extend to the
entire whole, and thus to each and every part regardless of who wrote it.
Thus, it is not the intent of this section to claim rights or contest
your rights to work written entirely by you; rather, the intent is to
exercise the right to control the distribution of derivative or
collective works based on the Program.
In addition, mere aggregation of another work not based on the Program
with the Program (or with a work based on the Program) on a volume of
a storage or distribution medium does not bring the other work under
the scope of this License.
3. You may copy and distribute the Program (or a work based on it,
under Section 2) in object code or executable form under the terms of
Sections 1 and 2 above provided that you also do one of the following:
a) Accompany it with the complete corresponding machine-readable
source code, which must be distributed under the terms of Sections
1 and 2 above on a medium customarily used for software interchange; or,
b) Accompany it with a written offer, valid for at least three
years, to give any third party, for a charge no more than your
cost of physically performing source distribution, a complete
machine-readable copy of the corresponding source code, to be
distributed under the terms of Sections 1 and 2 above on a medium
customarily used for software interchange; or,
c) Accompany it with the information you received as to the offer
to distribute corresponding source code. (This alternative is
allowed only for noncommercial distribution and only if you
received the program in object code or executable form with such
an offer, in accord with Subsection b above.)
The source code for a work means the preferred form of the work for
making modifications to it. For an executable work, complete source
code means all the source code for all modules it contains, plus any
associated interface definition files, plus the scripts used to
control compilation and installation of the executable. However, as a
special exception, the source code distributed need not include
anything that is normally distributed (in either source or binary
form) with the major components (compiler, kernel, and so on) of the
operating system on which the executable runs, unless that component
itself accompanies the executable.
If distribution of executable or object code is made by offering
access to copy from a designated place, then offering equivalent
access to copy the source code from the same place counts as
distribution of the source code, even though third parties are not
compelled to copy the source along with the object code.
4. You may not copy, modify, sublicense, or distribute the Program
except as expressly provided under this License. Any attempt
otherwise to copy, modify, sublicense or distribute the Program is
void, and will automatically terminate your rights under this License.
However, parties who have received copies, or rights, from you under
this License will not have their licenses terminated so long as such
parties remain in full compliance.
5. You are not required to accept this License, since you have not
signed it. However, nothing else grants you permission to modify or
distribute the Program or its derivative works. These actions are
prohibited by law if you do not accept this License. Therefore, by
modifying or distributing the Program (or any work based on the
Program), you indicate your acceptance of this License to do so, and
all its terms and conditions for copying, distributing or modifying
the Program or works based on it.
6. Each time you redistribute the Program (or any work based on the
Program), the recipient automatically receives a license from the
original licensor to copy, distribute or modify the Program subject to
these terms and conditions. You may not impose any further
restrictions on the recipients' exercise of the rights granted herein.
You are not responsible for enforcing compliance by third parties to
this License.
7. If, as a consequence of a court judgment or allegation of patent
infringement or for any other reason (not limited to patent issues),
conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot
distribute so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you
may not distribute the Program at all. For example, if a patent
license would not permit royalty-free redistribution of the Program by
all those who receive copies directly or indirectly through you, then
the only way you could satisfy both it and this License would be to
refrain entirely from distribution of the Program.
If any portion of this section is held invalid or unenforceable under
any particular circumstance, the balance of the section is intended to
apply and the section as a whole is intended to apply in other
circumstances.
It is not the purpose of this section to induce you to infringe any
patents or other property right claims or to contest validity of any
such claims; this section has the sole purpose of protecting the
integrity of the free software distribution system, which is
implemented by public license practices. Many people have made
generous contributions to the wide range of software distributed
through that system in reliance on consistent application of that
system; it is up to the author/donor to decide if he or she is willing
to distribute software through any other system and a licensee cannot
impose that choice.
This section is intended to make thoroughly clear what is believed to
be a consequence of the rest of this License.
8. If the distribution and/or use of the Program is restricted in
certain countries either by patents or by copyrighted interfaces, the
original copyright holder who places the Program under this License
may add an explicit geographical distribution limitation excluding
those countries, so that distribution is permitted only in or among
countries not thus excluded. In such case, this License incorporates
the limitation as if written in the body of this License.
9. The Free Software Foundation may publish revised and/or new versions
of the General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the Program
specifies a version number of this License which applies to it and "any
later version", you have the option of following the terms and conditions
either of that version or of any later version published by the Free
Software Foundation. If the Program does not specify a version number of
this License, you may choose any version ever published by the Free Software
Foundation.
10. If you wish to incorporate parts of the Program into other free
programs whose distribution conditions are different, write to the author
to ask for permission. For software which is copyrighted by the Free
Software Foundation, write to the Free Software Foundation; we sometimes
make exceptions for this. Our decision will be guided by the two goals
of preserving the free status of all derivatives of our free software and
of promoting the sharing and reuse of software generally.
NO WARRANTY
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
REPAIR OR CORRECTION.
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
convey the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program is interactive, make it output a short notice like this
when it starts in an interactive mode:
Gnomovision version 69, Copyright (C) year name of author
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, the commands you use may
be called something other than `show w' and `show c'; they could even be
mouse-clicks or menu items--whatever suits your program.
You should also get your employer (if you work as a programmer) or your
school, if any, to sign a "copyright disclaimer" for the program, if
necessary. Here is a sample; alter the names:
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
`Gnomovision' (which makes passes at compilers) written by James Hacker.
<signature of Moe Ghoul>, 1 April 1989
Moe Ghoul, President of Vice
This General Public License does not permit incorporating your program into
proprietary programs. If your program is a subroutine library, you may
consider it more useful to permit linking proprietary applications with the
library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License.

150
PLAN.md Normal file
View File

@@ -0,0 +1,150 @@
# PLAN.md — PowerDNS API Simulator for CoreDNS (MySQL backend)
> **Status: DELIVERED 2026-07-10.** This document was updated after completion
> to reflect the system as actually built and verified. Deviations from the
> original plan are marked **[as-built]** inline. Phase-gate evidence lives in
> `docs/EVIDENCE.md`; the empirical data-shape contract in `docs/SEMANTICS.md`.
## Goal
Implement a Go web service that exposes a **1:1 replica of the PowerDNS Authoritative HTTP API** (the subset used by ACME clients) and translates those calls into MySQL queries against the database that CoreDNS reads (mysql plugin). The sole consumer is **acme.sh's `dns_pdns` provider** for Let's Encrypt DNS-01 validation. The program must be production-ready and fully tested end-to-end before the work is considered done.
The translation contract in one sentence: **speak PowerDNS at the HTTP edge, read/write CoreDNS rows at the DB edge — in both directions.** A GET must reassemble CoreDNS rows into pdns rrsets; a PATCH must decompose pdns rrsets into CoreDNS rows.
The repository was already initialized with a minimal Go web server; it was extended, not rewritten (WebServer type, gorilla/mux `Routes`, `/version`, graceful shutdown all kept).
## Non-negotiable constraints
- **No config.yaml.** All YAML config code, struct tags, and the YAML dependency are removed. All configuration comes exclusively from **environment variables** (Docker-friendly). **[as-built]** Confirmed: `grep -ri yaml` over the repo finds nothing config-related (`update_child_repos.sh`, the last stray reference, has been deleted).
- **Exact PowerDNS API compatibility** for the endpoints acme.sh touches: same paths, same JSON shapes, same status codes, same headers. The PowerDNS API docs and the `dns_pdns.sh` source are authoritative. **[as-built]** `dns_pdns.sh` was read from the `neilpang/acme.sh` image (`/acmebin/dnsapi/dns_pdns.sh`); its scraping regexes require rrset JSON field order `name`, `type`, `ttl`, `records` (ordered Go structs, never maps) and `changetype: DELETE` bodies **without** `ttl`/`records` — both honored.
- **API key auth** via `X-API-Key` header, value from `PDNS_API_KEY`. Missing/wrong key returns `401` with body `{"error":"Unauthorized"}`.
- **Never hit the Let's Encrypt production CA.** Staging only, in every phase.
- Done means: full acme.sh workflow (staging CA) succeeds and a certificate is emitted. **[as-built]** Achieved twice: once against `go`-built binary, once against the container image.
## Configuration (environment variables)
Implemented in `config.go` exactly as specified; fail-fast errors name the missing variable.
| Variable | Purpose | Default |
|---|---|---|
| `PDNS_API_KEY` | Shared secret for `X-API-Key` | *(required, no default)* |
| `LISTEN_ADDR` | HTTP listen address | `:3000` |
| `MYSQL_DSN` | Go MySQL DSN (`user:pass@tcp(host:3306)/dbname?parseTime=true`) | *(required)* |
| `TABLE_PREFIX` | Table name prefix, mirroring the Corefile `table_prefix` directive (e.g. `coredns_`) | `coredns_` |
| `PDNS_SERVER_ID` | Server id in API paths | `localhost` |
| `DEFAULT_TTL` | TTL when acme.sh doesn't send one | `120` |
| `LOG_LEVEL` | `debug` / `info` / `warn` / `error` (validated) | `info` |
| `PUID` | UID to drop privileges to after startup | *(required)* |
| `PGID` | GID to drop privileges to after startup | *(required)* |
**[as-built]** `PUID=0` or `PGID=0` is rejected at config load ("refusing to serve as root"). `DEFAULT_TTL` must be a positive integer.
## Privilege drop (startup sequence)
Implemented in `privdrop.go`, called first thing in `main()` before any listener, goroutine, or DB connection.
- Order: `setgroups([])``setgid(PGID)``setuid(PUID)`, exactly.
- Go ≥ 1.16 applies the raw syscalls process-wide; module targets `go 1.22`, builder is go 1.22.2.
- After dropping, verification: `os.Getuid()`/`os.Getgid()` must equal `PUID`/`PGID`, and `syscall.Setuid(0)` must fail. Effective uid/gid logged at `info`. Verification failure → `os.Exit(1)`; never serve as root.
- The MySQL pool is opened **after** the drop.
- `LISTEN_ADDR` is `:3000` (>1024), so no capability retention; everything is dropped.
- **[as-built]** If the process starts as **non-root** (local dev runs), the drop is skipped with a `warn` log ("already running unprivileged"); `PUID`/`PGID` remain required config. This path is unit-tested; the root path is verified at container level (`docker top` shows uid/gid 4321/4321 with test values).
## Phase 0 — Empirical semantics discovery ✅ (findings in `docs/SEMANTICS.md`)
Ground truth was established against the **live** MariaDB and the **running** CoreDNS (never restarted or reconfigured — its behavior is part of the ground truth):
1. **CoreDNS side (verified empirically, not from plugin docs):**
- Table `coredns_records(id, zone, name, ttl, content TEXT/JSON, record_type)`; `zone` FQDN with trailing dot; `name` **relative to zone, no trailing dot**, `''` at apex; multi-label relative names work; FQDN-in-name is NOT served.
- **Multiple TXT values on one name = multiple rows**, one `{"text":"..."}` each. A JSON array inside one row is NOT served.
- TXT quoting: store the **raw unquoted** token; CoreDNS adds wire quoting. Storing pdns-style `\"tok\"` yields literal quotes on the wire (wrong for ACME).
- Queries are case-insensitive; store names lowercase.
- **Visibility [key as-built finding]:** record lookups hit MySQL live — writes to an *existing* zone are visible **immediately**. `zone_update_interval` (120s in the Corefile) only gates the cached **zone list**, i.e. newly created zones. It was NOT lowered for dev (CoreDNS is hands-off); it didn't need to be.
2. **PowerDNS side:** rrset model `{name, type, ttl, changetype, records:[{content, disabled}]}`; `REPLACE` replaces the entire `(name, type)` set; `DELETE` removes it (and arrives without `ttl`/`records`).
3. The full mapping table (pdns operation → exact SQL, TXT quote handling, FQDN normalization) is in `docs/SEMANTICS.md` §4; every handler implements it.
## API surface implemented (what acme.sh `dns_pdns` uses)
All under `/api/v1`, all requiring `X-API-Key`; unknown `{server_id}` → 404 on every route; zone_id accepted with/without trailing dot and URL-encoded dots; `Content-Type: application/json` on all responses.
1. **`GET /zones`** — zone list from `SELECT DISTINCT zone`; `id`/`name` canonical with trailing dot (acme.sh `_get_root` substring-matches `"name":"zone."`), plus `url` and **[as-built]** `kind:"Native"` for shape parity.
2. **`GET /zones/{zone_id}`** — zone detail with `rrsets` aggregated from rows grouped by `(name, type)`. TXT contents re-quoted (`"\"tok\""`); A/AAAA→`ip`, CNAME/NS→`host`, SOA assembled as `ns mbox 0 refresh retry expire minttl` (serial not stored → 0); unparsable content passed through raw. 0 rows → 404.
3. **`PATCH /zones/{zone_id}`** — `REPLACE`: transactional delete-all + one INSERT **per record** (the two-TXT-values-on-one-`_acme-challenge` case is the critical path and is covered by tests, curl, dig, and the E2E). `DELETE`: delete-all for `(zone, name, type)`. `204` on success, `422` + `{"error":"<specific>"}` on malformed rrsets, `404` on unknown zone. **[as-built]** Write support: TXT/A/AAAA/CNAME/NS; names lowercased, must be within the zone (label-boundary checked, `notexample.com.` vs `example.com.` → 422); TTL absent/0 → `DEFAULT_TTL`.
4. **`PUT /zones/{zone_id}/notify`** — no-op (CoreDNS reads MySQL live); `200` + `{"result":"Notification queued"}`; unknown zone → 404.
## MySQL layer
- Exact live schema, table name from `TABLE_PREFIX` (`store.go`).
- Prepared statements; REPLACE (delete+insert) wrapped in a transaction, rollback on any error.
- **[as-built]** Pool: MaxOpenConns 10, MaxIdleConns 5, ConnMaxLifetime 60s (mirrors the Corefile limits); ping at startup with 5s timeout, fail fast.
- **[as-built]** Driver pinned to `go-sql-driver/mysql v1.9.3` (v1.10 forces `go 1.24`, above the module's `go 1.22`).
## Development / test environment (as actually used)
- Dev server on `:3000`, exposed via the running ngrok tunnel:
**`https://claude-test-endpoint.ngrok-free.dev``http://127.0.0.1:3000`**
- CoreDNS running on port **5053**, public forward **`1.2.3.4:53` → localhost:5053**.
- **Test zone: `example.com`** (SAN: `www.example.com`) — **placeholder; this will need changing before any real run.** ACME validation resolves through the public DNS tree even against the staging CA, so the zone must be genuinely delegated from its public parent to the CoreDNS IP (`1.2.3.4`) — and Let's Encrypt additionally refuses to issue for `example.com` itself by policy (`rejectedIdentifier`). The delivery E2E therefore ran against a real delegated sub-zone, redacted to `example.com` throughout these docs. The zone rows (SOA, apex NS/A, `ns1` A → `1.2.3.4`, `www` CNAME) live in `coredns_records`.
- **dig caveat [as-built]:** this dev host's outbound UDP/53 is transparently intercepted by a middlebox (proven: an IP with no DNS server "answers"). `dig @1.2.3.4` was unreachable during part of development and later worked (verified genuine via TCP + answer-shape/serial match with the local instance). When in doubt, verify against `dig @127.0.0.1 -p 5053` (same instance) and externally via DoH (`https://dns.google/resolve?...` reports `"Response from 1.2.3.4"`).
## Test plan (all executed; evidence in `docs/EVIDENCE.md`)
### Phase 1 — Unit/handler tests ✅
27 top-level tests: auth (missing/wrong/correct key), zone listing, zone detail rrset aggregation + JSON field order, PATCH REPLACE/DELETE semantics, TXT quote handling both directions, FQDN normalization + boundary cases, notify, trailing-dot equivalence, 404/422 bodies, config fail-fast + defaults, privdrop non-root path. **[as-built]** DB-backed tests run against an isolated `test_pdnsapi_records` table on the live MariaDB (created/seeded/dropped per run; skip cleanly if DB unreachable); the real `coredns_records` is never touched.
### Phase 2 — Manual API tests with curl (localhost) ✅
```
curl -s -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones
curl -s -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones/example.com.
curl -s -X PATCH -H "X-API-Key: $PDNS_API_KEY" -H "Content-Type: application/json" \
-d '{"rrsets":[{"name":"_acme-challenge.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"test-token-123\"","disabled":false}]}]}' \
http://127.0.0.1:3000/api/v1/servers/localhost/zones/example.com.
```
Verified: wrong key → 401, missing zone → 404, malformed rrset → 422, two-record REPLACE on one `_acme-challenge` name → 204 with exactly two `{"text":...}` rows.
### Phase 3 — DNS propagation check with dig ✅
```
dig @1.2.3.4 TXT _acme-challenge.example.com +norecurse +noall +answer
# fallback if the dev host's UDP/53 interception bites: dig @127.0.0.1 -p 5053 ...
```
Both values of the two-record REPLACE served; after `changetype: DELETE` → NXDOMAIN. Writes were visible immediately (see Phase 0 visibility finding — no `zone_update_interval` wait for records on existing zones).
### Phase 4 — Public endpoint test (ngrok) ✅
Phase 2 calls repeated against `https://claude-test-endpoint.ngrok-free.dev`: identical bodies and status codes (200/204/401/404/422), PATCH-through-tunnel served by CoreDNS.
### Phase 5 — Full E2E with acme.sh (Let's Encrypt STAGING only) ✅
```
docker run --rm -v ~/acme.sh:/acme.sh \
-e PDNS_Url="https://claude-test-endpoint.ngrok-free.dev" \
-e PDNS_ServerId="localhost" \
-e PDNS_Token="$PDNS_API_KEY" \
-e PDNS_Ttl="120" \
neilpang/acme.sh --issue --staging --dns dns_pdns \
-d example.com -d www.example.com
```
All five checks passed: root-zone detection via `GET /zones` (server log), TXT PATCHes for **both** names (MySQL rows + dig), staging CA validated both identifiers, staging cert (issuer "(STAGING) Artificial Amaranth YE1", both SANs) saved under `~/acme.sh`, cleanup DELETEs confirmed (DB empty, NXDOMAIN).
**[as-built] flakiness note:** no `--dnssleep` is needed (propagation is immediate; acme.sh's default 20s check passes). One attempt failed with a **transient LE "secondary validation" timeout** — that is remote reachability of `1.2.3.4` from LE's vantage points, not propagation; the retry succeeded unchanged. If issuance flakes, retry before touching anything.
### Phase 6 — Production readiness ✅
- Dockerfile untouched (already compliant: 2-stage build → `scratch`, **no `USER` directive** — the binary itself drops to `PUID`/`PGID`).
- **[as-built]** makefile amendments (style preserved): `run`/`dockerrun` publish container port **3000** (was 80), pass through the nine app env vars with `--env`; `dockerrun` no longer mounts `config.yaml` and uses the `:$${APP_VERSION}` tag. `.vars` (gitignored) created locally for the build (`bryanpedini/gobuilder:1.22.2-alpine3.19`, image `git.bjphoster.com/source/coredns-powerdns-api-wrapper`).
- Privilege-drop checks: unit test (non-root path) + container-level — image run with test `PUID=4321`/`PGID=4321`; startup log `privileges dropped uid=4321 gid=4321` and host-side `docker top` shows the serving process as 4321/4321, not root.
- Graceful shutdown (SIGTERM), slog request logging at `info`, structured errors, `/healthz` (MySQL ping, unauthenticated, no data) — all in place.
- README: env var table (incl. `TABLE_PREFIX` ↔ Corefile `table_prefix`), docker run example, acme.sh staging/production usage, propagation/`--dnssleep` guidance, known limitations.
- Final Phase 5 re-run against the built image (`:0.1.0`, `--network host` since MariaDB binds to 127.0.0.1 only): cached staging authorizations were **deactivated first** so DNS-01 genuinely re-ran through the container; fresh cert issued.
## Definition of done — all satisfied 2026-07-10
- `docs/SEMANTICS.md` exists; every handler implements its mapping table. ✅
- All YAML config code and files removed; env-vars-only confirmed by grep. ✅
- All Go tests pass (`go test ./... -count=1`). ✅
- Phases 25 pass, evidence recorded in `docs/EVIDENCE.md`. ✅
- acme.sh issues a staging certificate for the test zone + `www` SAN with zero acme.sh-side workarounds. ✅ (zone is `example.com`, see environment section)
- Container image builds and runs the same E2E, serving process verified as `PUID`/`PGID`, not root. ✅
## Explicit non-goals (unchanged, binding)
- Full PowerDNS API coverage (zone CRUD beyond GET, cryptokeys, metadata, search, TSIG).
- DNSSEC.
- Any DNS serving by this service itself — CoreDNS remains the only resolver.

88
README.md Normal file
View File

@@ -0,0 +1,88 @@
# PowerDNS APIs for CoreDNS
A Go service exposing the subset of the PowerDNS Authoritative HTTP API that
acme.sh's `dns_pdns` provider uses, translating rrset operations into the
MySQL table read by the CoreDNS `mysql` plugin. Contract in one sentence:
**PowerDNS at the HTTP edge, CoreDNS rows at the DB edge.**
## Configuration
Configuration is environment variables only — there is no config file.
| Variable | Purpose | Default |
|---|---|---|
| `PDNS_API_KEY` | Shared secret for `X-API-Key` | *(required, no default)* |
| `LISTEN_ADDR` | HTTP listen address | `:3000` |
| `MYSQL_DSN` | Go MySQL DSN (`user:pass@tcp(host:3306)/dbname?parseTime=true`) | *(required)* |
| `TABLE_PREFIX` | Table name prefix; must mirror the Corefile `table_prefix` directive (e.g. `coredns_`) | `coredns_` |
| `PDNS_SERVER_ID` | Server id in API paths | `localhost` |
| `DEFAULT_TTL` | TTL when acme.sh doesn't send one | `120` |
| `LOG_LEVEL` | `debug` / `info` / `warn` / `error` | `info` |
| `PUID` | UID to drop privileges to after startup | *(required)* |
| `PGID` | GID to drop privileges to after startup | *(required)* |
Startup fails fast, naming the missing variable, if a required one is absent.
## Privilege drop
The container starts the binary as root. Before serving any traffic, the
binary irrevocably drops to `PGID`/`PUID` (`setgroups``setgid``setuid`).
The image does **not** declare a `USER` directive — the drop is the binary's
job, not the container's. If started as a non-root user already, the drop is
skipped and the process serves as the invoking user.
## Running
```
docker run --rm \
--publish 3000:3000 \
--env PDNS_API_KEY=changeme \
--env MYSQL_DSN="coredns:coredns@tcp(127.0.0.1:3306)/coredns?parseTime=true" \
--env TABLE_PREFIX=coredns_ \
--env PDNS_SERVER_ID=localhost \
--env DEFAULT_TTL=120 \
--env LOG_LEVEL=info \
--env PUID=1000 \
--env PGID=1000 \
git.bjphoster.com/source/coredns-powerdns-api-wrapper:latest
```
`/healthz` is an unauthenticated health check that pings MySQL; it exposes no
data.
## acme.sh usage
```
docker run -it --rm -v ~/acme.sh:/acme.sh \
-e PDNS_Url="https://your-endpoint" \
-e PDNS_ServerId="localhost" \
-e PDNS_Token="$PDNS_API_KEY" \
-e PDNS_Ttl="120" \
neilpang/acme.sh --issue --staging --dns dns_pdns \
-d example.com -d www.example.com
```
For production issuance, drop `--staging` (and optionally add
`--server letsencrypt`).
Implemented API surface, all under `/api/v1`, all requiring `X-API-Key`:
- `GET /api/v1/servers/{server_id}/zones`
- `GET /api/v1/servers/{server_id}/zones/{zone_id}`
- `PATCH /api/v1/servers/{server_id}/zones/{zone_id}` (rrsets, `REPLACE`/`DELETE`)
- `PUT /api/v1/servers/{server_id}/zones/{zone_id}/notify`
## Propagation / --dnssleep guidance
Record writes to an **existing** zone are visible to CoreDNS immediately —
record lookups hit MySQL live, so no `--dnssleep` is needed for TXT changes.
Only newly created zones are subject to the Corefile's `zone_update_interval`
(120s), since that setting only gates the cached zone list. Verified
empirically; see `docs/SEMANTICS.md`.
## Known limitations
- Only the acme.sh API slice is implemented: no zone CRUD beyond `GET`, no
cryptokeys/metadata/search/TSIG, no DNSSEC.
- This service serves no DNS itself — CoreDNS remains the resolver.
- rrset types supported for writes: `TXT`, `A`, `AAAA`, `CNAME`, `NS`.

28
auth.go Normal file
View File

@@ -0,0 +1,28 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"encoding/json"
"net/http"
)
// writeJSONError writes a PowerDNS-shaped error body: {"error": msg}.
func writeJSONError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
// authMiddleware guards the /api/v1 subrouter: requests must carry
// X-API-Key equal to Config.PDNSAPIKey, or they get a 401 in PowerDNS shape.
func (s *WebServer) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-API-Key") != s.Config.PDNSAPIKey {
writeJSONError(w, http.StatusUnauthorized, "Unauthorized")
return
}
next.ServeHTTP(w, r)
})
}

174
auth_test.go Normal file
View File

@@ -0,0 +1,174 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/gorilla/mux"
)
// newTestRouter builds a WebServer with a minimal Config and wires the real
// Routes()/middleware stack, without any DB (nil Store/DB) — for the pure
// handler tests that never reach the Store (auth, unknown server_id, etc).
func newTestRouter(cfg *Config) (*WebServer, *mux.Router) {
ws := &WebServer{
Config: cfg,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
r := mux.NewRouter()
r.Use(ws.loggingMiddleware)
ws.Routes(r)
return ws, r
}
func testConfig() *Config {
return &Config{
PDNSAPIKey: "test-secret-key",
ListenAddr: ":3000",
TablePrefix: "test_pdnsapi_",
PDNSServerID: "localhost",
DefaultTTL: 120,
LogLevel: "info",
PUID: 1000,
PGID: 1000,
}
}
func TestAuthMiddleware(t *testing.T) {
cfg := testConfig()
_, r := newTestRouter(cfg)
srv := httptest.NewServer(r)
defer srv.Close()
tests := []struct {
name string
headerKey string
sendHeader bool
wantStatus int
wantBody bool
}{
{"missing key", "", false, http.StatusUnauthorized, true},
{"wrong key", "wrong-key", true, http.StatusUnauthorized, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req, err := http.NewRequest("GET", srv.URL+"/api/v1/servers/localhost/zones", nil)
if err != nil {
t.Fatal(err)
}
if tt.sendHeader {
req.Header.Set("X-API-Key", tt.headerKey)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != tt.wantStatus {
t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
wantBody := `{"error":"Unauthorized"}` + "\n"
if string(body) != wantBody {
t.Fatalf("body = %q, want %q", string(body), wantBody)
}
})
}
}
func TestAuthMiddleware_CorrectKeyPassesThrough(t *testing.T) {
// Correct key should reach the handler (which will fail past auth since
// there is no Store — but it must NOT be a 401). Unknown server_id also
// checked before Store is touched, so use that to prove auth passed.
cfg := testConfig()
_, r := newTestRouter(cfg)
srv := httptest.NewServer(r)
defer srv.Close()
req, err := http.NewRequest("GET", srv.URL+"/api/v1/servers/not-localhost/zones", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-API-Key", cfg.PDNSAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("correct key was rejected with 401")
}
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want %d (unknown server_id, proves auth passed)", resp.StatusCode, http.StatusNotFound)
}
}
func TestUnknownServerID(t *testing.T) {
cfg := testConfig()
_, r := newTestRouter(cfg)
srv := httptest.NewServer(r)
defer srv.Close()
paths := []struct {
method string
path string
}{
{"GET", "/api/v1/servers/bogus/zones"},
{"GET", "/api/v1/servers/bogus/zones/example.com."},
{"PATCH", "/api/v1/servers/bogus/zones/example.com."},
{"PUT", "/api/v1/servers/bogus/zones/example.com./notify"},
}
for _, p := range paths {
t.Run(p.method+" "+p.path, func(t *testing.T) {
req, err := http.NewRequest(p.method, srv.URL+p.path, nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-API-Key", cfg.PDNSAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
want := `{"error":"Not Found"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", string(body), want)
}
})
}
}
func TestWriteJSONError(t *testing.T) {
w := httptest.NewRecorder()
writeJSONError(w, http.StatusTeapot, "custom message")
if w.Code != http.StatusTeapot {
t.Fatalf("status = %d, want %d", w.Code, http.StatusTeapot)
}
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
t.Fatalf("Content-Type = %q, want application/json", ct)
}
want := `{"error":"custom message"}` + "\n"
if w.Body.String() != want {
t.Fatalf("body = %q, want %q", w.Body.String(), want)
}
}

93
config.go Normal file
View File

@@ -0,0 +1,93 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"fmt"
"os"
"strconv"
)
type Config struct {
PDNSAPIKey string
ListenAddr string
MySQLDSN string
TablePrefix string
PDNSServerID string
DefaultTTL int
LogLevel string
PUID int
PGID int
}
// LoadConfig reads configuration exclusively from environment variables.
// Missing/invalid required variables are a fatal startup error.
func LoadConfig() *Config {
c := &Config{
ListenAddr: envOrDefault("LISTEN_ADDR", ":3000"),
TablePrefix: envOrDefault("TABLE_PREFIX", "coredns_"),
PDNSServerID: envOrDefault("PDNS_SERVER_ID", "localhost"),
LogLevel: envOrDefault("LOG_LEVEL", "info"),
}
c.PDNSAPIKey = requireEnv("PDNS_API_KEY")
c.MySQLDSN = requireEnv("MYSQL_DSN")
c.DefaultTTL = requirePositiveInt("DEFAULT_TTL", 120)
switch c.LogLevel {
case "debug", "info", "warn", "error":
default:
fatalf("invalid LOG_LEVEL %q: must be one of debug, info, warn, error", c.LogLevel)
}
c.PUID = requireInt("PUID")
c.PGID = requireInt("PGID")
if c.PUID == 0 || c.PGID == 0 {
fatalf("refusing to serve as root: PUID and PGID must both be non-zero")
}
return c
}
func envOrDefault(key, def string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return def
}
func requireEnv(key string) string {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
fatalf("missing required environment variable: %s", key)
}
return v
}
func requireInt(key string) int {
v := requireEnv(key)
n, err := strconv.Atoi(v)
if err != nil {
fatalf("invalid %s: %q is not an integer", key, v)
}
return n
}
func requirePositiveInt(key string, def int) int {
v, ok := os.LookupEnv(key)
if !ok || v == "" {
return def
}
n, err := strconv.Atoi(v)
if err != nil || n <= 0 {
fatalf("invalid %s: %q is not a positive integer", key, v)
}
return n
}
func fatalf(format string, args ...any) {
fmt.Fprintf(os.Stderr, "config error: "+format+"\n", args...)
os.Exit(1)
}

113
config_test.go Normal file
View File

@@ -0,0 +1,113 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"os"
"os/exec"
"strings"
"testing"
)
// TestLoadConfigDefaults exercises the success path in-process: all required
// vars set, defaults left unset, and asserts the documented defaults.
func TestLoadConfigDefaults(t *testing.T) {
t.Setenv("PDNS_API_KEY", "k")
t.Setenv("MYSQL_DSN", "user:pass@tcp(127.0.0.1:3306)/db")
t.Setenv("PUID", "1000")
t.Setenv("PGID", "1000")
os.Unsetenv("LISTEN_ADDR")
os.Unsetenv("TABLE_PREFIX")
os.Unsetenv("DEFAULT_TTL")
os.Unsetenv("PDNS_SERVER_ID")
os.Unsetenv("LOG_LEVEL")
cfg := LoadConfig()
if cfg.ListenAddr != ":3000" {
t.Errorf("ListenAddr = %q, want :3000", cfg.ListenAddr)
}
if cfg.TablePrefix != "coredns_" {
t.Errorf("TablePrefix = %q, want coredns_", cfg.TablePrefix)
}
if cfg.DefaultTTL != 120 {
t.Errorf("DefaultTTL = %d, want 120", cfg.DefaultTTL)
}
if cfg.PDNSServerID != "localhost" {
t.Errorf("PDNSServerID = %q, want localhost", cfg.PDNSServerID)
}
if cfg.LogLevel != "info" {
t.Errorf("LogLevel = %q, want info", cfg.LogLevel)
}
}
// TestLoadConfigFatal exercises the os.Exit(1) paths of LoadConfig via a
// re-exec subprocess, since LoadConfig calls os.Exit directly and cannot be
// asserted in-process.
func TestLoadConfigFatal(t *testing.T) {
baseEnv := map[string]string{
"PDNS_API_KEY": "k",
"MYSQL_DSN": "user:pass@tcp(127.0.0.1:3306)/db",
"PUID": "1000",
"PGID": "1000",
}
tests := []struct {
name string
mutate map[string]string // overrides/additions to baseEnv; "" value means unset
wantInErr string
}{
{"missing PDNS_API_KEY", map[string]string{"PDNS_API_KEY": ""}, "PDNS_API_KEY"},
{"missing MYSQL_DSN", map[string]string{"MYSQL_DSN": ""}, "MYSQL_DSN"},
{"missing PUID", map[string]string{"PUID": ""}, "PUID"},
{"missing PGID", map[string]string{"PGID": ""}, "PGID"},
{"PUID zero rejected", map[string]string{"PUID": "0"}, "root"},
{"PGID zero rejected", map[string]string{"PGID": "0"}, "root"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
env := map[string]string{}
for k, v := range baseEnv {
env[k] = v
}
for k, v := range tt.mutate {
env[k] = v
}
cmd := exec.Command(os.Args[0], "-test.run=TestConfigFatalHelper")
cmd.Env = []string{"GO_CONFIG_CRASH=1"}
for k, v := range env {
if v != "" {
cmd.Env = append(cmd.Env, k+"="+v)
}
}
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("expected non-zero exit, got success. output: %s", out)
}
exitErr, ok := err.(*exec.ExitError)
if !ok {
t.Fatalf("expected *exec.ExitError, got %T: %v", err, err)
}
if exitErr.ExitCode() == 0 {
t.Fatalf("expected non-zero exit code")
}
if !strings.Contains(string(out), tt.wantInErr) {
t.Fatalf("stderr output = %q, want substring %q", out, tt.wantInErr)
}
})
}
}
// TestConfigFatalHelper is not a real test; it's invoked as a subprocess by
// TestLoadConfigFatal via -test.run, guarded by GO_CONFIG_CRASH so it never
// runs (and never touches the DB) under a normal `go test` invocation.
func TestConfigFatalHelper(t *testing.T) {
if os.Getenv("GO_CONFIG_CRASH") == "" {
t.Skip("only runs as a re-exec subprocess")
}
LoadConfig()
}

39
db.go Normal file
View File

@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"log/slog"
"os"
"time"
_ "github.com/go-sql-driver/mysql"
)
// OpenDB opens the MySQL pool and pings it with a timeout, mirroring the
// Corefile's pool limits. Must be called after the privilege drop.
func OpenDB(logger *slog.Logger, dsn string) *sql.DB {
db, err := sql.Open("mysql", dsn)
if err != nil {
logger.Error("failed to open mysql pool", "error", err)
os.Exit(1)
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
db.SetConnMaxLifetime(60 * time.Second)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
logger.Error("failed to reach mysql", "error", err)
os.Exit(1)
}
logger.Info("mysql pool ready")
return db
}

41
deploy.sh Executable file
View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Convert deployment paths into array
ENVIRONMENTS=($DEPLOYMENT_PATHS)
# Check if the DEPLOYMENT_PATH is not already set
if [ -z "${DEPLOYMENT_PATH}" ]; then
# Print and ask for deployment environment (if more than one)
if [ "${#ENVIRONMENTS[@]}" -gt 1 ]; then
for i in "${!ENVIRONMENTS[@]}"; do
echo "$i: ${ENVIRONMENTS[$i]}"
done
read -p "Deployment environment: " DEPLOYMENT_ENVIRONMENT
fi
if [ -z "${DEPLOYMENT_ENVIRONMENT}" ]; then
DEPLOYMENT_ENVIRONMENT=0
fi
# Select correct path
DEPLOYMENT_PATH="${ENVIRONMENTS[$DEPLOYMENT_ENVIRONMENT]}"
fi
# Check if the DEPLOYMENT_VERSION is not already set
if [ -z "${DEPLOYMENT_VERSION}" ]; then
# Ask for deployment version
read -p "Version [latest]: " DEPLOYMENT_VERSION
if [ -z "${DEPLOYMENT_VERSION}" ]; then
DEPLOYMENT_VERSION=latest
fi
fi
echo "${DEPLOYMENT_PATH}"
echo "${DEPLOYMENT_VERSION}"
ssh $DEPLOYMENT_HOST \
"cd ${DEPLOYMENT_PATH} && \
git pull && \
sed -i "s/VERSION=.*/VERSION=${DEPLOYMENT_VERSION}/" .env && \
docker compose pull && \
docker compose up -d"

36
dockerfile Normal file
View File

@@ -0,0 +1,36 @@
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
# Stage 1 · go builder
ARG GO_BUILDER=golang
ARG GO_VERSION=latest
FROM ${GO_BUILDER}:${GO_VERSION} AS build
ARG GO_OS
ARG GO_ARCH
ARG GIT_HOST
ARG REPO_ORG
ARG REPO_NAME
ARG APP_VERSION
# Copy the project inside the builder container
WORKDIR $GOPATH/src/${GIT_HOST}/$REPO_ORG/$REPO_NAME/
COPY . .
# Build the binary
RUN CGO_ENABLED=0 GOOS=${GO_OS} GOARCH=${GO_ARCH} \
go build \
-installsuffix cgo \
-ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=$(git log HEAD --oneline | awk '{print $1}' | head -n1)'" \
--o /app
# Stage 2 · scratch image
FROM scratch
# Copy the necessary stuff from the build stage
COPY --from=build /app /app
# Copy the certificates - in case of fetches
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem
# Execute the binary
ENTRYPOINT ["/app"]

151
docs/EVIDENCE.md Normal file
View File

@@ -0,0 +1,151 @@
# EVIDENCE.md — phase gate outputs (recorded 2026-07-10)
Test zone shown as `example.com` — a redaction of the real delegated sub-zone
used during the runs (publicly delegated to `1.2.3.4` → CoreDNS `:5053`).
To reproduce, that name will need changing: ACME needs a real zone to
validate against even on the staging CA (validation follows the public DNS
tree), and Let's Encrypt refuses to issue for `example.com` itself by policy
(`rejectedIdentifier`). API key redacted as `$PDNS_API_KEY`, server IP as
`1.2.3.4` throughout.
## Phase 1 — Go tests
```
$ go test ./... -count=1
ok git.bjphoster.com/source/coredns-powerdns-api-wrapper 1.440s
```
27 top-level tests (auth, zone list/detail, rrset aggregation + field order,
PATCH REPLACE/DELETE, TXT quoting both directions, FQDN normalization, notify,
trailing-dot equivalence, 404/422 bodies, config fail-fast, privdrop non-root
path). Isolated table `test_pdnsapi_records`; live `coredns_records` row count
identical before/after (4/4).
## Phase 2 — curl on localhost (2026-07-10T21:43 CEST)
```
$ curl -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones
[{"id":"example.com.","name":"example.com.","url":"/api/v1/servers/localhost/zones/example.com.","kind":"Native"}]
$ curl .../zones/example.com. # zone detail (trimmed)
{"id":"example.com.","name":"example.com.","kind":"Native","url":"...","rrsets":[
{"name":"example.com.","type":"A","ttl":300,"records":[{"content":"1.2.3.4","disabled":false}]},
{"name":"example.com.","type":"NS","ttl":300,"records":[{"content":"ns1.example.com.","disabled":false}]},
{"name":"example.com.","type":"SOA","ttl":300,"records":[{"content":"ns1.example.com. hostmaster.example.com. 0 44 55 66 300","disabled":false}]},
{"name":"www.example.com.","type":"CNAME","ttl":300,"records":[{"content":"example.com.","disabled":false}]}]}
$ PATCH REPLACE single token "test-token-123" → HTTP 204
$ wrong API key → {"error":"Unauthorized"} HTTP 401
$ missing zone (nosuchzone.org.) → {"error":"Not Found"} HTTP 404
$ malformed rrset (REPLACE, records: []) → {"error":"REPLACE requires at least one record"} HTTP 422
$ PATCH REPLACE two records on _acme-challenge → HTTP 204
$ SELECT name,ttl,content,record_type FROM coredns_records WHERE name LIKE '_acme%';
_acme-challenge 120 {"text":"token-for-example-com"} TXT
_acme-challenge 120 {"text":"token-for-www-example-com"} TXT
```
## Phase 3 — dig propagation
`dig @1.2.3.4` is not reachable from inside this host (hairpin NAT +
ISP-level UDP/53 interception, proven by getting an answer from an IP with no
DNS server). Verification ran against the same CoreDNS instance directly and,
externally, via public resolvers (see Phase 5 prep).
```
$ dig @127.0.0.1 -p 5053 TXT _acme-challenge.example.com +norecurse +noall +answer
_acme-challenge.example.com. 120 IN TXT "token-for-example-com"
_acme-challenge.example.com. 120 IN TXT "token-for-www-example-com" ← both values of the 2-record REPLACE
$ PATCH changetype:DELETE → HTTP 204, then:
$ dig ... → status: NXDOMAIN; SELECT COUNT(*) ... LIKE '_acme%' → 0
```
Record writes on an existing zone were visible immediately (no
zone_update_interval wait; the interval only gates new-zone visibility).
## Phase 4 — ngrok public endpoint (https://claude-test-endpoint.ngrok-free.dev)
```
$ zones via ngrok → identical body to Phase 2, HTTP 200
$ zone detail via ngrok → identical body to Phase 2, HTTP 200
$ PATCH REPLACE via ngrok → HTTP 204; dig @127.0.0.1 -p 5053 served "ngrok-test-token"
$ wrong key via ngrok → {"error":"Unauthorized"} HTTP 401
$ missing zone via ngrok → {"error":"Not Found"} HTTP 404
$ changetype BOGUS → {"error":"unknown or missing changetype"} HTTP 422
$ PATCH DELETE (cleanup) → HTTP 204
```
## Phase 5 — acme.sh staging E2E (zone example.com)
Zone setup: SOA/NS/A(apex)/A(ns1)/CNAME(www) rows inserted; CoreDNS served the
new zone after ~10s; external chain verified before the run:
```
$ curl 'https://dns.google/resolve?name=example.com&type=SOA'
{"Status":0,...,"Comment":"Response from 1.2.3.4."} ← public chain hits our CoreDNS
check-host.net: 10/10 nodes (DE HK IL×2 IN IR MD UA US×2) resolved A=1.2.3.4
```
First attempt failed with `During secondary validation: DNS problem: query
timed out` (transient reachability of one LE vantage point); retry succeeded
with zero acme.sh-side workarounds:
```
docker run --rm -v ~/acme.sh:/acme.sh -e PDNS_Url=... -e PDNS_ServerId=localhost \
-e PDNS_Token=$PDNS_API_KEY -e PDNS_Ttl=120 \
neilpang/acme.sh --issue --staging --dns dns_pdns \
-d example.com -d www.example.com
[...] Adding TXT value: GzM5RMGKqZ... for domain: _acme-challenge.example.com
[...] The TXT record has been successfully added.
[...] Success for domain example.com / www.example.com
[...] Cert success.
Your cert is in: /acme.sh/example.com_ecc/example.com.cer
```
Server log (request sequence per acme.sh call): GET /zones 200 (_get_root) →
GET /zones/{zone} 200 (existing challenges) → PATCH 204 → PUT notify 200;
cleanup DELETE PATCHes 204. After cleanup: DB `_acme%` rows = 0, dig NXDOMAIN.
```
$ openssl x509 -noout -issuer -ext subjectAltName -dates
issuer=C=US, O=Let's Encrypt, CN=(STAGING) Artificial Amaranth YE1
DNS:example.com, DNS:www.example.com
```
## Phase 6 — container image E2E + privilege drop
```
$ APP_VERSION=0.1.0 make docker → git.bjphoster.com/source/coredns-powerdns-api-wrapper:0.1.0 (7.94MB, scratch)
$ docker run -d --name pdns-api-e2e --network host --env PDNS_API_KEY \
--env MYSQL_DSN=... --env PUID=4321 --env PGID=4321 <image>
$ docker logs pdns-api-e2e
level=INFO msg="privileges dropped" uid=4321 gid=4321
level=INFO msg="mysql pool ready"
level=INFO msg=listening addr=:3000
$ docker top pdns-api-e2e -eo pid,uid,gid,comm ← host view
PID UID GID COMMAND
446573 4321 4321 app ← NOT root
$ curl http://127.0.0.1:3000/healthz → {"status":"ok"}
```
Full Phase 5 re-run against the container (cached LE authorizations
deactivated first so dns-01 actually re-ran):
```
[...] Verifying: example.com → Success
[...] Verifying: www.example.com → Success
[...] Cert success.
```
Container request log shows the same GET/PATCH/notify sequence; post-run DB
`_acme%` rows = 0, dig NXDOMAIN, cert dated Jul 10 19:19:58 2026 GMT with both
SANs, staging issuer.
## Definition-of-done greps
```
$ grep -ri yaml --exclude-dir=.git . → only CLAUDE.md/PLAN.md prose;
nothing config-related in Go sources, makefile, or dockerfile
(update_child_repos.sh, the last stray reference, has since been deleted)
$ gofmt -l . → clean; go vet ./... → clean
```

146
docs/SEMANTICS.md Normal file
View File

@@ -0,0 +1,146 @@
# SEMANTICS.md — CoreDNS (mysql plugin) ↔ PowerDNS API data-shape contract
Ground truth established empirically on 2026-07-10 against the live stack:
MariaDB 12.3.2 (`coredns` db, user `coredns`), CoreDNS with compiled-in `mysql`
plugin serving on `:5053`, Corefile `table_prefix coredns_`,
`zone_update_interval 120s`, plugin `ttl 300`.
## 1. Live schema (source of truth: `SHOW CREATE TABLE coredns_records`)
```sql
CREATE TABLE `coredns_records` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`zone` varchar(255) NOT NULL,
`name` varchar(255) NOT NULL,
`ttl` int(11) DEFAULT NULL,
`content` text DEFAULT NULL,
`record_type` varchar(255) NOT NULL,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
```
- `zone`: FQDN **with trailing dot**, lowercase (`example.com.`).
- `name`: **relative to zone, no trailing dot**; empty string `''` at the apex;
multi-label relative names work (`_p0f.www` served at `_p0f.www.example.com.`
— verified). A name stored as FQDN is **not** served (verified).
- `content`: JSON object, one row per record value. Type-specific keys (observed
live + plugin conventions):
| record_type | content JSON |
|---|---|
| TXT | `{"text":"<raw value>"}` |
| A | `{"ip":"1.2.3.4"}` |
| AAAA | `{"ip":"::1"}` |
| CNAME / NS | `{"host":"target.example.com."}` |
| SOA | `{"ttl":300,"mbox":"hostmaster.example.com.","ns":"ns1.example.com.","refresh":44,"retry":55,"expire":66}` |
- `ttl`: integer seconds, honored as served TTL (verified: row ttl 120 served
as 120).
## 2. Empirical CoreDNS read semantics (all verified with dig against the live instance)
| Question | Verified answer |
|---|---|
| Multiple TXT values on one name | **Multiple rows**, same `(zone,name,record_type)`, one `{"text":...}` each. Both values served. |
| JSON array inside one row (`{"text":["a","b"]}`) | **Not served** (empty answer). Never write this shape. |
| TXT quoting in DB | Store the **raw, unquoted** token. CoreDNS adds wire quoting: `{"text":"tok"}``"tok"` on the wire. Storing `\"tok\"` yields literal quotes inside the value (`"\"tok\""` on the wire) — wrong for ACME. |
| Query case sensitivity | Case-insensitive (`_P0A.EXAMPLE.COM` matched row `_p0a`). Store names lowercase. |
| Write visibility | Record inserts/deletes on an **existing** zone are visible **immediately** (record lookups hit the DB per query). `zone_update_interval` (120s) only gates the cached **zone list**, i.e. only newly-created zones are delayed. No CoreDNS restart, no dnssleep needed for TXT changes on `example.com.`. |
## 3. PowerDNS API semantics (from `dns_pdns.sh` in the `neilpang/acme.sh` image + pdns API docs)
rrset model: `{"name": "<fqdn.>", "type": "TXT", "ttl": N, "changetype": "REPLACE"|"DELETE", "records": [{"content": "\"token\"", "disabled": false}]}`
- `REPLACE` replaces the **entire** record set for `(name, type)`.
- `DELETE` removes the entire set; acme.sh sends it **without `ttl` and without
`records`** — validation must accept that.
- TXT `content` on the API is **quoted**: `"\"token\""` in the JSON body.
- acme.sh specifics that the responses must satisfy:
- `_get_root` substring-matches `"name":"<candidate>."` against the
normalized zone-list JSON → every zone `name` must be FQDN with trailing
dot.
- Zone id in URLs arrives **with** trailing dot (`/zones/example.com.`);
accept it also without a dot and with URL-encoded dots, like PowerDNS.
- `_list_existingchallenges` regex-scrapes the zone-detail JSON: within each
rrset object, `"name"` must appear **before** `"records"`, names must be
FQDN with trailing dot, and TXT contents must be quoted
(`"content":"\"tok\""`). Use ordered Go structs, never maps.
- `PUT /zones/{id}/notify` is called after every PATCH and must succeed.
## 4. Mapping table (every handler implements exactly this)
Notation: `zone` = canonical zone FQDN with trailing dot; `rel(name)` =
lowercased rrset name with the zone suffix and trailing dot stripped (apex →
`''`); `unq(c)` = TXT content with one leading and one trailing `"` removed;
`q(t)` = `"` + t + `"`. `{p}` = `TABLE_PREFIX` (default `coredns_`).
### GET /api/v1/servers/{sid}/zones
```sql
SELECT DISTINCT zone FROM {p}records ORDER BY zone;
```
`[{"id":"<zone>","name":"<zone>","url":"/api/v1/servers/{sid}/zones/<zone>"}]`
(zone already carries the trailing dot; emit as-is, plus `kind: "Native"` for
shape parity).
### GET /api/v1/servers/{sid}/zones/{zone_id}
Canonicalize `zone_id` (URL-decode, lowercase, ensure trailing dot). Then:
```sql
SELECT name, ttl, content, record_type FROM {p}records WHERE zone = ?;
```
Zone unknown (0 rows) → 404. Group rows by `(name, record_type)` → one rrset
each: `name` = `rel==''` ? zone : `rel + "." + zone`; `ttl` = the rows' ttl
(first non-NULL, else plugin default 300); `records[i].content` per type:
| record_type | rrset content |
|---|---|
| TXT | `q(content.text)` |
| A / AAAA | `content.ip` |
| CNAME / NS | `content.host` |
| SOA | `content.ns + " " + content.mbox + " 0 " + refresh + " " + retry + " " + expire + " " + (content.ttl or 300)` (serial not stored; emit 0) |
| other/unparsable | raw `content` string (never omit the row) |
`records[i].disabled` = `false` always. Response: `{"id","name","kind":"Native","url","rrsets":[...]}` with struct field order `name`, `type`, `ttl`, `records`.
### PATCH /api/v1/servers/{sid}/zones/{zone_id} (TXT via acme.sh; generic for others)
For each rrset, canonicalize `name``rel`. All statements for one PATCH run
in **one transaction**; any error rolls back everything.
`changetype: REPLACE` (requires `type`, `ttl` ≥ 0, ≥1 record):
```sql
DELETE FROM {p}records WHERE zone = ? AND name = ? AND record_type = ?;
-- then, one INSERT PER RECORD (multi-value = multiple rows, §2):
INSERT INTO {p}records (zone, name, ttl, content, record_type)
VALUES (?, ?, ?, ?, ?);
```
TXT insert content = `{"text": unq(api content)}` (JSON-marshalled, so inner
quotes/backslashes stay valid JSON). A/AAAA → `{"ip": c}`; CNAME/NS →
`{"host": c}`. TTL: from rrset; if absent/0 use `DEFAULT_TTL` env (120).
`changetype: DELETE` (no `ttl`/`records` required):
```sql
DELETE FROM {p}records WHERE zone = ? AND name = ? AND record_type = ?;
```
Success → `204 No Content` (empty body). Unknown zone → 404. Malformed
(unknown changetype, empty/missing name or type, REPLACE without records) →
`422` with PowerDNS error body.
### PUT /api/v1/servers/{sid}/zones/{zone_id}/notify
No-op (CoreDNS reads the DB live). Zone must exist (else 404). Return
`200` + `{"result":"Notification queued"}`.
## 5. Cross-cutting
- Auth: every `/api/v1/*` route requires `X-API-Key` equal to `PDNS_API_KEY`;
otherwise `401` + error body. `/healthz` is unauthenticated.
- Error body, PowerDNS shape: `{"error":"<message>"}` (400/401/404/422/500).
- Unknown `{sid}` (≠ `PDNS_SERVER_ID`) → 404.
- All responses `Content-Type: application/json` (204 has no body).
- The apex TXT case (`name == zone`) maps to `rel = ''` — same SQL applies.
- ACME tokens are base64url (`A-Za-z0-9_-`), no escaping hazards; the JSON
marshaller handles anything else.

10
go.mod Normal file
View File

@@ -0,0 +1,10 @@
module git.bjphoster.com/source/coredns-powerdns-api-wrapper
go 1.22
require (
github.com/go-sql-driver/mysql v1.9.3
github.com/gorilla/mux v1.8.1
)
require filippo.io/edwards25519 v1.1.1 // indirect

6
go.sum Normal file
View File

@@ -0,0 +1,6 @@
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=

30
healthz.go Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"encoding/json"
"net/http"
"time"
)
// handleHealthz pings the DB with a short timeout and reports status. No
// auth, no data exposed beyond ok/unhealthy.
func (s *WebServer) handleHealthz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
w.Header().Set("Content-Type", "application/json")
if err := s.DB.PingContext(ctx); err != nil {
s.Logger.Error("healthz ping failed", "error", err)
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "unhealthy"})
return
}
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
}

14
initialize.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
set -euo pipefail
if [ ! -f ".vars" ]; then
cp vars.example .vars
fi
if [ ! -f "go.mod" ]; then
module_name=$(git remote get-url origin | sed 's|^https://||')
go mod init "$module_name"
fi

60
logging.go Normal file
View File

@@ -0,0 +1,60 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"log/slog"
"net/http"
"os"
"time"
)
// NewLogger builds the process-wide structured logger, text output to
// stdout, level driven by LOG_LEVEL.
func NewLogger(level string) *slog.Logger {
var lvl slog.Level
switch level {
case "debug":
lvl = slog.LevelDebug
case "warn":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
return slog.New(handler)
}
// statusRecorder captures the status code written by downstream handlers so
// the logging middleware can report it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
// loggingMiddleware logs method, path, status, and duration at info level
// for every request.
func (s *WebServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
s.Logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"duration", time.Since(start),
)
})
}

52
main.go Normal file
View File

@@ -0,0 +1,52 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"time"
)
var APP_VERSION string = "latest"
var COMMIT_ID string = "undefined"
var ws *WebServer
func main() {
// Initialize the WebService structure (parses env config, sets up logger)
ws = new(WebServer)
ws.Initialize()
// Drop root privileges before any listener, goroutine, or DB connection
dropPrivileges(ws.Logger, ws.Config.PUID, ws.Config.PGID)
// Create a channel to receive the OS signals
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
// Open the MySQL pool (after the privilege drop) and ping it
ws.DB = OpenDB(ws.Logger, ws.Config.MySQLDSN)
ws.Store = NewStore(ws.DB, ws.Config.TablePrefix)
// Start the WebService in a separate goroutine
go ws.Start()
// Wait for a signal
<-sc
fmt.Println("Shutting down...")
// Create a context with a timeout for graceful shutdown
shCtx, shCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shCancel()
// Shutdown the HTTP server
err := ws.HTTPServer.Shutdown(shCtx)
if err != nil {
fmt.Printf("Server shutdown error: %s", err)
os.Exit(1)
}
}

77
makefile Normal file
View File

@@ -0,0 +1,77 @@
#!make
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
include .vars
default: version
clean:
if [ "$$(docker images "$${CONTAINER_ORG}/$${CONTAINER_IMAGE}" --format "{{.Repository}}:{{.Tag}}")" != "" ]; then \
docker image rm $$(docker images "$${CONTAINER_ORG}/$${CONTAINER_IMAGE}" --all --format "{{.Repository}}:{{.Tag}}"); \
fi
docker: clean
docker build \
--build-arg GO_BUILDER=$${GO_BUILDER} \
--build-arg GO_VERSION=$${GO_VERSION} \
--build-arg GO_OS=$${GO_OS} \
--build-arg GO_ARCH=$${GO_ARCH} \
--build-arg GIT_HOST=$${GIT_HOST} \
--build-arg REPO_ORG=$${REPO_ORG} \
--build-arg REPO_NAME=$${REPO_NAME} \
--build-arg APP_VERSION=$${APP_VERSION} \
-t $${CONTAINER_ORG}/$${CONTAINER_IMAGE}:$${APP_VERSION} .; \
if [ "$$(docker images --filter "dangling=true" --quiet --no-trunc)" != "" ]; then \
docker image rm $$(docker images --filter "dangling=true" --quiet --no-trunc); \
fi
dockerpush:
docker push \
$${CONTAINER_ORG}/$${CONTAINER_IMAGE}:$${APP_VERSION}
deploy:
bash -c "./deploy.sh"
test:
go test ./...
version:
bash -c "./version.sh"
run:
docker run \
--rm \
--tty \
--interactive \
--publish $${CONTAINER_IP}:$${CONTAINER_PORT}:3000 \
--workdir /go/src/$${GIT_HOST}/$${REPO_ORG}/$${REPO_NAME} \
--volume $(shell pwd):/go/src/$${GIT_HOST}/$${REPO_ORG}/$${REPO_NAME} \
--env PDNS_API_KEY \
--env MYSQL_DSN \
--env TABLE_PREFIX \
--env PDNS_SERVER_ID \
--env DEFAULT_TTL \
--env LOG_LEVEL \
--env LISTEN_ADDR \
--env PUID \
--env PGID \
$${GO_BUILDER}:$${GO_VERSION} \
go run .
dockerrun:
docker run \
--rm \
--tty \
--interactive \
--publish $${CONTAINER_IP}:$${CONTAINER_PORT}:3000 \
--env PDNS_API_KEY \
--env MYSQL_DSN \
--env TABLE_PREFIX \
--env PDNS_SERVER_ID \
--env DEFAULT_TTL \
--env LOG_LEVEL \
--env LISTEN_ADDR \
--env PUID \
--env PGID \
$${CONTAINER_ORG}/$${CONTAINER_IMAGE}:$${APP_VERSION}

411
pdns.go Normal file
View File

@@ -0,0 +1,411 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// pdnsZone is the zone-list entry shape.
type pdnsZone struct {
ID string `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Kind string `json:"kind"`
}
// pdnsRecord is one record within an rrset. Field order matches PowerDNS.
type pdnsRecord struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
// pdnsRRset is one rrset. Field order MUST be name, type, ttl, records —
// acme.sh regex-scrapes the zone-detail JSON expecting "name" before
// "records".
type pdnsRRset struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
Records []pdnsRecord `json:"records"`
}
// pdnsZoneDetail is the zone-detail response shape.
type pdnsZoneDetail struct {
ID string `json:"id"`
Name string `json:"name"`
Kind string `json:"kind"`
URL string `json:"url"`
RRsets []pdnsRRset `json:"rrsets"`
}
// checkServerID returns false and writes a 404 if server_id doesn't match
// the configured PDNS_SERVER_ID. Cross-cutting per SEMANTICS §5.
func (s *WebServer) checkServerID(w http.ResponseWriter, r *http.Request) bool {
if mux.Vars(r)["server_id"] != s.Config.PDNSServerID {
writeJSONError(w, http.StatusNotFound, "Not Found")
return false
}
return true
}
// canonicalZone lowercases and ensures a trailing dot, per SEMANTICS §4.
func canonicalZone(zoneID string) string {
zone := strings.ToLower(zoneID)
if !strings.HasSuffix(zone, ".") {
zone += "."
}
return zone
}
// handleListZones implements GET /api/v1/servers/{server_id}/zones.
func (s *WebServer) handleListZones(w http.ResponseWriter, r *http.Request) {
if !s.checkServerID(w, r) {
return
}
zones, err := s.Store.ListZones(r.Context())
if err != nil {
s.Logger.Error("list zones failed", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
serverID := mux.Vars(r)["server_id"]
out := make([]pdnsZone, 0, len(zones))
for _, zone := range zones {
out = append(out, pdnsZone{
ID: zone,
Name: zone,
URL: "/api/v1/servers/" + serverID + "/zones/" + zone,
Kind: "Native",
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// soaContent is the type-specific content shape for SOA rows.
type soaContent struct {
TTL int `json:"ttl"`
Mbox string `json:"mbox"`
NS string `json:"ns"`
Refresh int `json:"refresh"`
Retry int `json:"retry"`
Expire int `json:"expire"`
}
// rrsetContent renders the pdns rrset "content" string for one row per
// SEMANTICS §4's mapping table. Unparsable/unknown content is emitted raw,
// never dropped.
func rrsetContent(recordType, content string) string {
switch recordType {
case "TXT":
var c struct {
Text string `json:"text"`
}
if err := json.Unmarshal([]byte(content), &c); err != nil {
return content
}
return `"` + c.Text + `"`
case "A", "AAAA":
var c struct {
IP string `json:"ip"`
}
if err := json.Unmarshal([]byte(content), &c); err != nil {
return content
}
return c.IP
case "CNAME", "NS":
var c struct {
Host string `json:"host"`
}
if err := json.Unmarshal([]byte(content), &c); err != nil {
return content
}
return c.Host
case "SOA":
var c soaContent
if err := json.Unmarshal([]byte(content), &c); err != nil {
return content
}
ttl := c.TTL
if ttl == 0 {
ttl = 300
}
return c.NS + " " + c.Mbox + " 0 " +
strconv.Itoa(c.Refresh) + " " + strconv.Itoa(c.Retry) + " " +
strconv.Itoa(c.Expire) + " " + strconv.Itoa(ttl)
default:
return content
}
}
// rrsetKey groups rows by (name, record_type).
type rrsetKey struct {
name string
recordType string
}
// handleZoneDetail implements GET /api/v1/servers/{server_id}/zones/{zone_id}.
func (s *WebServer) handleZoneDetail(w http.ResponseWriter, r *http.Request) {
if !s.checkServerID(w, r) {
return
}
vars := mux.Vars(r)
zone := canonicalZone(vars["zone_id"])
records, err := s.Store.GetZoneRecords(r.Context(), zone)
if err != nil {
s.Logger.Error("get zone records failed", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
if len(records) == 0 {
writeJSONError(w, http.StatusNotFound, "Not Found")
return
}
groups := make(map[rrsetKey][]Record)
var order []rrsetKey
for _, rec := range records {
key := rrsetKey{name: rec.Name, recordType: rec.RecordType}
if _, ok := groups[key]; !ok {
order = append(order, key)
}
groups[key] = append(groups[key], rec)
}
sort.Slice(order, func(i, j int) bool {
if order[i].name != order[j].name {
return order[i].name < order[j].name
}
return order[i].recordType < order[j].recordType
})
rrsets := make([]pdnsRRset, 0, len(order))
for _, key := range order {
rows := groups[key]
fqdn := zone
if key.name != "" {
fqdn = key.name + "." + zone
}
ttl := 300
for _, rec := range rows {
if rec.TTL.Valid {
ttl = int(rec.TTL.Int64)
break
}
}
recs := make([]pdnsRecord, 0, len(rows))
for _, rec := range rows {
recs = append(recs, pdnsRecord{
Content: rrsetContent(rec.RecordType, rec.Content),
Disabled: false,
})
}
rrsets = append(rrsets, pdnsRRset{
Name: fqdn,
Type: key.recordType,
TTL: ttl,
Records: recs,
})
}
serverID := vars["server_id"]
detail := pdnsZoneDetail{
ID: zone,
Name: zone,
Kind: "Native",
URL: "/api/v1/servers/" + serverID + "/zones/" + zone,
RRsets: rrsets,
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(detail)
}
// patchRecord is one record entry within a PATCH rrset.
type patchRecord struct {
Content string `json:"content"`
Disabled bool `json:"disabled"`
}
// patchRRset is one rrset entry within a PATCH request body.
type patchRRset struct {
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
ChangeType string `json:"changetype"`
Records []patchRecord `json:"records"`
}
// patchRequest is the PATCH zone body.
type patchRequest struct {
RRsets []patchRRset `json:"rrsets"`
}
// unq strips exactly one leading and one trailing double quote, if present.
func unq(c string) string {
if len(c) >= 1 && strings.HasPrefix(c, `"`) {
c = c[1:]
}
if len(c) >= 1 && strings.HasSuffix(c, `"`) {
c = c[:len(c)-1]
}
return c
}
// recordContent marshals the type-specific content JSON for storage, per
// SEMANTICS §4. supportedTypes lists what this handler accepts.
func recordContent(recordType, apiContent string) (string, bool) {
switch recordType {
case "TXT":
b, err := json.Marshal(map[string]string{"text": unq(apiContent)})
if err != nil {
return "", false
}
return string(b), true
case "A", "AAAA":
b, err := json.Marshal(map[string]string{"ip": apiContent})
if err != nil {
return "", false
}
return string(b), true
case "CNAME", "NS":
b, err := json.Marshal(map[string]string{"host": apiContent})
if err != nil {
return "", false
}
return string(b), true
default:
return "", false
}
}
// handleZonePatch implements PATCH /api/v1/servers/{server_id}/zones/{zone_id}.
func (s *WebServer) handleZonePatch(w http.ResponseWriter, r *http.Request) {
if !s.checkServerID(w, r) {
return
}
vars := mux.Vars(r)
zone := canonicalZone(vars["zone_id"])
exists, err := s.Store.ZoneExists(r.Context(), zone)
if err != nil {
s.Logger.Error("zone exists check failed", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
if !exists {
writeJSONError(w, http.StatusNotFound, "Not Found")
return
}
var body patchRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONError(w, http.StatusUnprocessableEntity, "malformed JSON body")
return
}
changes := make([]RRsetChange, 0, len(body.RRsets))
for _, rrset := range body.RRsets {
if rrset.ChangeType != "REPLACE" && rrset.ChangeType != "DELETE" {
writeJSONError(w, http.StatusUnprocessableEntity, "unknown or missing changetype")
return
}
if rrset.Name == "" || rrset.Type == "" {
writeJSONError(w, http.StatusUnprocessableEntity, "missing name or type")
return
}
name := strings.ToLower(rrset.Name)
if !strings.HasSuffix(name, ".") {
name += "."
}
if name != zone && !strings.HasSuffix(name, "."+zone) {
writeJSONError(w, http.StatusUnprocessableEntity, "name is not part of zone")
return
}
rel := strings.TrimSuffix(name, zone)
rel = strings.TrimSuffix(rel, ".")
change := RRsetChange{
Zone: zone,
Name: rel,
RecordType: rrset.Type,
ChangeType: rrset.ChangeType,
}
if rrset.ChangeType == "REPLACE" {
if len(rrset.Records) == 0 {
writeJSONError(w, http.StatusUnprocessableEntity, "REPLACE requires at least one record")
return
}
ttl := rrset.TTL
if ttl <= 0 {
ttl = s.Config.DefaultTTL
}
change.TTL = ttl
contents := make([]string, 0, len(rrset.Records))
for _, rec := range rrset.Records {
content, ok := recordContent(rrset.Type, rec.Content)
if !ok {
writeJSONError(w, http.StatusUnprocessableEntity, "unsupported record type")
return
}
contents = append(contents, content)
}
change.Contents = contents
}
changes = append(changes, change)
}
if err := s.Store.ApplyRRsets(r.Context(), changes); err != nil {
s.Logger.Error("apply rrsets failed", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleZoneNotify implements PUT /api/v1/servers/{server_id}/zones/{zone_id}/notify.
func (s *WebServer) handleZoneNotify(w http.ResponseWriter, r *http.Request) {
if !s.checkServerID(w, r) {
return
}
vars := mux.Vars(r)
zone := canonicalZone(vars["zone_id"])
exists, err := s.Store.ZoneExists(r.Context(), zone)
if err != nil {
s.Logger.Error("zone exists check failed", "error", err)
writeJSONError(w, http.StatusInternalServerError, "internal error")
return
}
if !exists {
writeJSONError(w, http.StatusNotFound, "Not Found")
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]string{"result": "Notification queued"})
}

492
pdns_test.go Normal file
View File

@@ -0,0 +1,492 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/mux"
)
// dbTestServer builds a WebServer wired to the isolated test_pdnsapi_
// prefixed Store, with the real Routes()/middleware stack, and reseeds the
// known example.com. fixture before returning. Callers get a fresh httptest
// server per test.
func dbTestServer(t *testing.T) (*httptest.Server, *Config) {
t.Helper()
createTestSchema(t)
db := requireDB(t)
reseed(t, db, exampleComSeed())
cfg := testConfig()
ws := &WebServer{
Config: cfg,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
DB: db,
Store: NewStore(db, testTablePrefix),
}
r := newRouter(ws)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
return srv, cfg
}
func newRouter(ws *WebServer) http.Handler {
router := mux.NewRouter()
router.Use(ws.loggingMiddleware)
ws.Routes(router)
return router
}
// doReq issues an authenticated request against srv and returns the response
// with the body read into memory (so callers don't need to remember Close).
func doReq(t *testing.T, srv *httptest.Server, apiKey, method, path string, body []byte) (*http.Response, []byte) {
t.Helper()
var rdr io.Reader
if body != nil {
rdr = bytes.NewReader(body)
}
req, err := http.NewRequest(method, srv.URL+path, rdr)
if err != nil {
t.Fatal(err)
}
if apiKey != "" {
req.Header.Set("X-API-Key", apiKey)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp, got
}
// --- healthz -------------------------------------------------------------
func TestHealthzReachableWithoutKey(t *testing.T) {
// /healthz must be reachable without X-API-Key, and must succeed given a
// live DB handle (the shape it always has when actually serving —
// main.go exits before serving if OpenDB fails).
srv, _ := dbTestServer(t)
resp, err := http.Get(srv.URL + "/healthz")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusUnauthorized {
t.Fatalf("/healthz should not require auth, got 401")
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var body map[string]string
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body["status"] != "ok" {
t.Fatalf("status field = %q, want ok", body["status"])
}
}
// --- zone listing --------------------------------------------------------
func TestHandleListZones(t *testing.T) {
srv, cfg := dbTestServer(t)
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
var zones []pdnsZone
if err := json.Unmarshal(body, &zones); err != nil {
t.Fatalf("unmarshal: %v; body = %s", err, body)
}
if len(zones) != 1 {
t.Fatalf("got %d zones, want 1: %+v", len(zones), zones)
}
z := zones[0]
if z.ID != "example.com." || z.Name != "example.com." {
t.Fatalf("zone = %+v, want id/name example.com. with trailing dot", z)
}
wantURL := "/api/v1/servers/localhost/zones/example.com."
if z.URL != wantURL {
t.Fatalf("url = %q, want %q", z.URL, wantURL)
}
}
// --- zone detail: rrset shape --------------------------------------------
func TestHandleZoneDetail_RRsetAggregation(t *testing.T) {
srv, cfg := dbTestServer(t)
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
var detail pdnsZoneDetail
if err := json.Unmarshal(body, &detail); err != nil {
t.Fatalf("unmarshal: %v; body = %s", err, body)
}
// multi TXT rows (two rows, same name/type) must aggregate into ONE
// rrset with both records.
var multiTXT *pdnsRRset
for i := range detail.RRsets {
if detail.RRsets[i].Type == "TXT" && strings.HasPrefix(detail.RRsets[i].Name, "multi.") {
multiTXT = &detail.RRsets[i]
}
}
if multiTXT == nil {
t.Fatalf("no multi.example.com. TXT rrset found in %+v", detail.RRsets)
}
if len(multiTXT.Records) != 2 {
t.Fatalf("multi TXT rrset has %d records, want 2: %+v", len(multiTXT.Records), multiTXT.Records)
}
contents := map[string]bool{}
for _, r := range multiTXT.Records {
contents[r.Content] = true
}
if !contents[`"first"`] || !contents[`"second"`] {
t.Fatalf("multi TXT contents = %v, want both \"first\" and \"second\"", contents)
}
// apex rows (name == '') must appear as the zone FQDN.
var apexFound bool
for _, rr := range detail.RRsets {
if rr.Type == "SOA" {
apexFound = true
if rr.Name != "example.com." {
t.Fatalf("apex SOA rrset name = %q, want example.com.", rr.Name)
}
}
}
if !apexFound {
t.Fatalf("no SOA rrset found in %+v", detail.RRsets)
}
}
func TestHandleZoneDetail_FieldOrder(t *testing.T) {
srv, cfg := dbTestServer(t)
_, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
// acme.sh regex-scrapes expecting "name" before "records" within each
// rrset object. Assert on the raw body, not a re-marshalled map (which
// would lose field order).
nameIdx := strings.Index(string(body), `"name":"www.example.com."`)
if nameIdx == -1 {
t.Fatalf("did not find www.example.com. rrset name in body: %s", body)
}
rest := string(body)[nameIdx:]
typeIdx := strings.Index(rest, `"type"`)
ttlIdx := strings.Index(rest, `"ttl"`)
recordsIdx := strings.Index(rest, `"records"`)
if typeIdx == -1 || ttlIdx == -1 || recordsIdx == -1 {
t.Fatalf("missing expected fields after name in: %s", rest)
}
if !(typeIdx < ttlIdx && ttlIdx < recordsIdx) {
t.Fatalf("field order wrong: type@%d ttl@%d records@%d, want type<ttl<records", typeIdx, ttlIdx, recordsIdx)
}
}
func TestHandleZoneDetail_TrailingDotEquivalence(t *testing.T) {
srv, cfg := dbTestServer(t)
_, bodyWithDot := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
_, bodyNoDot := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com", nil)
if !bytes.Equal(bodyWithDot, bodyNoDot) {
t.Fatalf("zone detail differs with/without trailing dot:\nwith: %s\nwithout: %s", bodyWithDot, bodyNoDot)
}
}
// --- TXT quoting both directions -----------------------------------------
func TestTXTQuotingDBToAPI(t *testing.T) {
srv, cfg := dbTestServer(t)
_, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
var detail pdnsZoneDetail
if err := json.Unmarshal(body, &detail); err != nil {
t.Fatal(err)
}
found := false
for _, rr := range detail.RRsets {
if rr.Type == "TXT" {
for _, rec := range rr.Records {
if rec.Content == `"first"` || rec.Content == `"second"` {
found = true
}
}
}
}
if !found {
t.Fatalf("expected quoted TXT content \"first\"/\"second\" in %+v", detail.RRsets)
}
}
func TestTXTQuotingAPIToDB(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
patchBody := `{"rrsets":[{"name":"tok.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"tok\"","disabled":false}]}]}`
resp, respBody := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, respBody)
}
var content string
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "tok", "TXT").Scan(&content)
if err != nil {
t.Fatal(err)
}
if content != `{"text":"tok"}` {
t.Fatalf("stored content = %q, want {\"text\":\"tok\"}", content)
}
}
// --- PATCH REPLACE ---------------------------------------------------------
func TestHandlePatchReplace(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
patchBody := `{"rrsets":[{"name":"www.example.com.","type":"CNAME","ttl":60,"changetype":"REPLACE","records":[{"content":"a.example.com.","disabled":false},{"content":"b.example.com.","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
if len(body) != 0 {
t.Fatalf("expected empty body on 204, got %q", body)
}
rows, err := db.QueryContext(context.Background(),
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "www", "CNAME")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var contents []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
contents = append(contents, c)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(contents) != 2 {
t.Fatalf("got %d rows after REPLACE, want exactly 2: %v", len(contents), contents)
}
want := map[string]bool{`{"host":"a.example.com."}`: true, `{"host":"b.example.com."}`: true}
for _, c := range contents {
if !want[c] {
t.Fatalf("unexpected content %q, want one of %v", c, want)
}
}
}
// --- PATCH DELETE -----------------------------------------------------------
func TestHandlePatchDelete(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
// DELETE without ttl/records fields, matching acme.sh's shape.
patchBody := `{"rrsets":[{"name":"www.example.com.","type":"CNAME","changetype":"DELETE"}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
var count int
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "www", "CNAME").Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("count = %d after DELETE, want 0", count)
}
}
// --- FQDN normalization -----------------------------------------------------
func TestFQDNNormalization(t *testing.T) {
srv, cfg := dbTestServer(t)
db := requireDB(t)
// Uppercase name, zone_id without trailing dot.
patchBody := `{"rrsets":[{"name":"_x.EXAMPLE.COM.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com", []byte(patchBody))
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
var name string
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT name FROM %s WHERE zone=? AND record_type=? AND content=?", testTableName),
"example.com.", "TXT", `{"text":"v"}`).Scan(&name)
if err != nil {
t.Fatalf("query stored row: %v", err)
}
if name != "_x" {
t.Fatalf("stored name = %q, want relative lowercase _x", name)
}
}
func TestFQDNNormalization_OutsideZone422(t *testing.T) {
srv, cfg := dbTestServer(t)
patchBody := `{"rrsets":[{"name":"foo.other.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
}
}
func TestFQDNNormalization_BoundaryCase(t *testing.T) {
srv, cfg := dbTestServer(t)
// "notexample.com." is NOT part of zone "example.com." despite the
// substring match — must be 422, not accepted.
patchBody := `{"rrsets":[{"name":"notexample.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
}
}
// --- notify ------------------------------------------------------------
func TestHandleNotify(t *testing.T) {
srv, cfg := dbTestServer(t)
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PUT", "/api/v1/servers/localhost/zones/example.com./notify", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
}
want := `{"result":"Notification queued"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
}
func TestHandleNotify_UnknownZone(t *testing.T) {
srv, cfg := dbTestServer(t)
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PUT", "/api/v1/servers/localhost/zones/nope.com./notify", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, body = %s, want 404", resp.StatusCode, body)
}
}
// --- error bodies --------------------------------------------------------
func TestUnknownZoneErrors(t *testing.T) {
srv, cfg := dbTestServer(t)
t.Run("GET", func(t *testing.T) {
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/nope.com.", nil)
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
want := `{"error":"Not Found"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
})
t.Run("PATCH", func(t *testing.T) {
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/nope.com.", []byte(`{"rrsets":[]}`))
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("status = %d, want 404", resp.StatusCode)
}
want := `{"error":"Not Found"}` + "\n"
if string(body) != want {
t.Fatalf("body = %q, want %q", body, want)
}
})
}
func TestMalformedRRsets422(t *testing.T) {
srv, cfg := dbTestServer(t)
// ZoneExists (→404) is checked before body decode/validation (→422), so
// every case here must target the seeded existing zone example.com.
tests := []struct {
name string
body string
}{
{"missing changetype", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"records":[{"content":"\"v\""}]}]}`},
{"unknown changetype", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"changetype":"BOGUS","records":[{"content":"\"v\""}]}]}`},
{"REPLACE with 0 records", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[]}]}`},
{"missing name", `{"rrsets":[{"type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\""}]}]}`},
{"missing type", `{"rrsets":[{"name":"www.example.com.","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\""}]}]}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(tt.body))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
}
var errBody map[string]string
if err := json.Unmarshal(body, &errBody); err != nil {
t.Fatalf("error body not JSON: %v; body = %s", err, body)
}
if _, ok := errBody["error"]; !ok {
t.Fatalf("error body missing \"error\" key: %s", body)
}
})
}
}
func TestMalformedJSONBody(t *testing.T) {
srv, cfg := dbTestServer(t)
// Current behavior: json.Decode failure -> 422 with {"error": "malformed
// JSON body"}. Asserting current behavior per instructions.
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(`{not valid json`))
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status = %d, body = %s, want 422 (current behavior)", resp.StatusCode, body)
}
var errBody map[string]string
if err := json.Unmarshal(body, &errBody); err != nil {
t.Fatalf("error body not JSON: %v; body = %s", err, body)
}
if _, ok := errBody["error"]; !ok {
t.Fatalf("error body missing \"error\" key: %s", body)
}
}

46
privdrop.go Normal file
View File

@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"log/slog"
"os"
"syscall"
)
// dropPrivileges irrevocably drops from root to PUID/PGID before any
// listener, goroutine, or DB connection is created. If already unprivileged
// (dev runs), it skips the drop and logs a warning. Any failure to drop or
// verify the drop is fatal — this process must never serve traffic as root.
func dropPrivileges(logger *slog.Logger, puid, pgid int) {
if os.Getuid() != 0 {
logger.Warn("already running unprivileged, skipping privilege drop", "uid", os.Getuid(), "gid", os.Getgid())
return
}
if err := syscall.Setgroups([]int{}); err != nil {
logger.Error("failed to clear supplementary groups", "error", err)
os.Exit(1)
}
if err := syscall.Setgid(pgid); err != nil {
logger.Error("failed to setgid", "gid", pgid, "error", err)
os.Exit(1)
}
if err := syscall.Setuid(puid); err != nil {
logger.Error("failed to setuid", "uid", puid, "error", err)
os.Exit(1)
}
if os.Getuid() != puid || os.Getgid() != pgid {
logger.Error("privilege drop verification failed", "want_uid", puid, "got_uid", os.Getuid(), "want_gid", pgid, "got_gid", os.Getgid())
os.Exit(1)
}
if err := syscall.Setuid(0); err == nil {
logger.Error("re-escalation to root succeeded, this must never happen")
os.Exit(1)
}
logger.Info("privileges dropped", "uid", os.Getuid(), "gid", os.Getgid())
}

30
privdrop_test.go Normal file
View File

@@ -0,0 +1,30 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"io"
"log/slog"
"os"
"testing"
)
// TestDropPrivilegesNonRootSkipsDrop covers the non-root half (Phase 6): when
// the test binary is not running as root (the normal CI/dev case), dropPrivileges
// must take the skip path — log a warning and return — without calling exit
// or erroring. The root half (actual setuid/setgid) requires a root-owned
// process and is exercised at the container level (Phase 6), not here.
func TestDropPrivilegesNonRootSkipsDrop(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("this test asserts the non-root skip path; running as root here")
}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
// If dropPrivileges took the root path here, it would call
// syscall.Setgid/Setuid as non-root, fail, and os.Exit(1) — which would
// kill the test binary. Reaching this point at all proves the skip path
// was taken, without erroring or exiting.
dropPrivileges(logger, 1000, 1000)
}

19
routes.go Normal file
View File

@@ -0,0 +1,19 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import "github.com/gorilla/mux"
func (s *WebServer) Routes(r *mux.Router) {
r.HandleFunc("/version", handleVersion).Methods("GET")
r.HandleFunc("/healthz", s.handleHealthz).Methods("GET")
api := r.PathPrefix("/api/v1").Subrouter()
api.Use(s.authMiddleware)
api.HandleFunc("/servers/{server_id}/zones", s.handleListZones).Methods("GET")
api.HandleFunc("/servers/{server_id}/zones/{zone_id}", s.handleZoneDetail).Methods("GET")
api.HandleFunc("/servers/{server_id}/zones/{zone_id}", s.handleZonePatch).Methods("PATCH")
api.HandleFunc("/servers/{server_id}/zones/{zone_id}/notify", s.handleZoneNotify).Methods("PUT")
}

123
store.go Normal file
View File

@@ -0,0 +1,123 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"fmt"
)
// Store wraps DB access against the {TablePrefix}records table (schema per
// docs/SEMANTICS.md §1).
type Store struct {
db *sql.DB
tableName string
}
// NewStore builds a Store bound to {prefix}records.
func NewStore(db *sql.DB, tablePrefix string) *Store {
return &Store{
db: db,
tableName: tablePrefix + "records",
}
}
// Record is one row of {prefix}records.
type Record struct {
Name string
TTL sql.NullInt64
Content string
RecordType string
}
// ListZones returns distinct zones present in the table, ordered by name.
func (st *Store) ListZones(ctx context.Context) ([]string, error) {
query := fmt.Sprintf("SELECT DISTINCT zone FROM %s ORDER BY zone", st.tableName)
rows, err := st.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var zones []string
for rows.Next() {
var zone string
if err := rows.Scan(&zone); err != nil {
return nil, err
}
zones = append(zones, zone)
}
return zones, rows.Err()
}
// GetZoneRecords returns every row for the given zone.
func (st *Store) GetZoneRecords(ctx context.Context, zone string) ([]Record, error) {
query := fmt.Sprintf("SELECT name, ttl, content, record_type FROM %s WHERE zone = ?", st.tableName)
rows, err := st.db.QueryContext(ctx, query, zone)
if err != nil {
return nil, err
}
defer rows.Close()
var records []Record
for rows.Next() {
var rec Record
if err := rows.Scan(&rec.Name, &rec.TTL, &rec.Content, &rec.RecordType); err != nil {
return nil, err
}
records = append(records, rec)
}
return records, rows.Err()
}
// ZoneExists reports whether at least one row exists for the zone.
func (st *Store) ZoneExists(ctx context.Context, zone string) (bool, error) {
query := fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM %s WHERE zone = ?)", st.tableName)
var exists bool
if err := st.db.QueryRowContext(ctx, query, zone).Scan(&exists); err != nil {
return false, err
}
return exists, nil
}
// RRsetChange is one decomposed rrset change ready to apply to the DB.
type RRsetChange struct {
Zone string
Name string // relative name, per SEMANTICS §1 (empty string at apex)
RecordType string
ChangeType string // REPLACE or DELETE
TTL int
Contents []string // marshalled JSON content, one per record; unused for DELETE
}
// ApplyRRsets runs every rrset change of one PATCH inside a single
// transaction. REPLACE deletes existing rows for (zone, name, record_type)
// then inserts one row per record. DELETE just deletes. Any error rolls
// back the entire transaction.
func (st *Store) ApplyRRsets(ctx context.Context, changes []RRsetChange) error {
tx, err := st.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
deleteQuery := fmt.Sprintf("DELETE FROM %s WHERE zone = ? AND name = ? AND record_type = ?", st.tableName)
insertQuery := fmt.Sprintf("INSERT INTO %s (zone, name, ttl, content, record_type) VALUES (?, ?, ?, ?, ?)", st.tableName)
for _, ch := range changes {
if _, err := tx.ExecContext(ctx, deleteQuery, ch.Zone, ch.Name, ch.RecordType); err != nil {
return err
}
if ch.ChangeType == "REPLACE" {
for _, content := range ch.Contents {
if _, err := tx.ExecContext(ctx, insertQuery, ch.Zone, ch.Name, ch.TTL, content, ch.RecordType); err != nil {
return err
}
}
}
}
return tx.Commit()
}

269
store_test.go Normal file
View File

@@ -0,0 +1,269 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"context"
"database/sql"
"fmt"
"os"
"testing"
"time"
_ "github.com/go-sql-driver/mysql"
)
// Isolated test schema, per PLAN.md Phase 1: a dedicated table
// (test_pdnsapi_records) on the live MariaDB, never the real coredns_records
// table. TABLE_PREFIX for the Store under test is "test_pdnsapi_".
const testTablePrefix = "test_pdnsapi_"
const testTableName = testTablePrefix + "records"
var (
testDB *sql.DB
testDBReason string // non-empty if the DB is unreachable; tests should t.Skip with this
testDBReady bool
)
func TestMain(m *testing.M) {
dsn := os.Getenv("MYSQL_DSN")
if dsn == "" {
dsn = "coredns:coredns@tcp(127.0.0.1:3306)/coredns?parseTime=true"
}
db, err := sql.Open("mysql", dsn)
if err != nil {
testDBReason = fmt.Sprintf("mysql: sql.Open failed: %v", err)
} else {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
pingErr := db.PingContext(ctx)
cancel()
if pingErr != nil {
testDBReason = fmt.Sprintf("mysql: unreachable at %s: %v", dsn, pingErr)
} else {
testDB = db
testDBReady = true
}
}
code := m.Run()
if testDB != nil {
// Final safety net teardown in case an individual test's defer was
// skipped (e.g. t.Fatal before cleanup registration). Never touches
// coredns_records — only the isolated test_pdnsapi_records table.
_, _ = testDB.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", testTableName))
testDB.Close()
}
os.Exit(code)
}
// requireDB skips the calling test if the live MariaDB is unreachable.
func requireDB(t *testing.T) *sql.DB {
t.Helper()
if !testDBReady {
t.Skipf("skipping: live MySQL/MariaDB not reachable (%s)", testDBReason)
}
return testDB
}
// createTestSchema (re)creates test_pdnsapi_records with the exact live
// schema from docs/SEMANTICS.md §1, and registers a cleanup to drop it.
func createTestSchema(t *testing.T) {
t.Helper()
db := requireDB(t)
ctx := context.Background()
_, err := db.ExecContext(ctx, fmt.Sprintf(`DROP TABLE IF EXISTS %s`, testTableName))
if err != nil {
t.Fatalf("drop existing test table: %v", err)
}
_, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE TABLE %s (
id int(11) NOT NULL AUTO_INCREMENT,
zone varchar(255) NOT NULL,
name varchar(255) NOT NULL,
ttl int(11) DEFAULT NULL,
content text DEFAULT NULL,
record_type varchar(255) NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, testTableName))
if err != nil {
t.Fatalf("create test table: %v", err)
}
t.Cleanup(func() {
_, err := db.ExecContext(context.Background(), fmt.Sprintf(`DROP TABLE IF EXISTS %s`, testTableName))
if err != nil {
t.Errorf("drop test table on cleanup: %v", err)
}
})
}
// seedRow is one row to insert during reseed.
type seedRow struct {
zone string
name string
ttl sql.NullInt64
content string
recordType string
}
// exampleComSeed is the SOA/NS/A/CNAME example.com. seed from
// docs/SEMANTICS.md §1 (mirrors the live coredns_records example.com data),
// plus two TXT rows on one name to exercise rrset aggregation.
func exampleComSeed() []seedRow {
nn := func(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: true} }
return []seedRow{
{"example.com.", "", nn(300), `{"ttl":300, "mbox":"hostmaster.example.com.", "ns":"ns1.example.com.", "refresh":44, "retry":55, "expire":66}`, "SOA"},
{"example.com.", "", nn(300), `{"host":"ns1.example.com."}`, "NS"},
{"example.com.", "", nn(300), `{"ip":"1.2.3.4"}`, "A"},
{"example.com.", "www", nn(300), `{"host":"example.com."}`, "CNAME"},
{"example.com.", "multi", nn(120), `{"text":"first"}`, "TXT"},
{"example.com.", "multi", nn(120), `{"text":"second"}`, "TXT"},
}
}
// reseed truncates test_pdnsapi_records and inserts the known seed rows.
// Call at the start of every DB-backed test that reads or mutates rows, since
// the table is shared mutable state across tests in the same run.
func reseed(t *testing.T, db *sql.DB, rows []seedRow) {
t.Helper()
ctx := context.Background()
if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s", testTableName)); err != nil {
t.Fatalf("truncate test table: %v", err)
}
insert := fmt.Sprintf("INSERT INTO %s (zone, name, ttl, content, record_type) VALUES (?, ?, ?, ?, ?)", testTableName)
for _, row := range rows {
if _, err := db.ExecContext(ctx, insert, row.zone, row.name, row.ttl, row.content, row.recordType); err != nil {
t.Fatalf("seed insert %+v: %v", row, err)
}
}
}
// --- Store-level tests -------------------------------------------------
func TestStoreListZones(t *testing.T) {
createTestSchema(t)
db := requireDB(t)
reseed(t, db, exampleComSeed())
st := NewStore(db, testTablePrefix)
zones, err := st.ListZones(context.Background())
if err != nil {
t.Fatal(err)
}
if len(zones) != 1 || zones[0] != "example.com." {
t.Fatalf("zones = %v, want [example.com.]", zones)
}
}
func TestStoreZoneExists(t *testing.T) {
createTestSchema(t)
db := requireDB(t)
reseed(t, db, exampleComSeed())
st := NewStore(db, testTablePrefix)
ok, err := st.ZoneExists(context.Background(), "example.com.")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected example.com. to exist")
}
ok, err = st.ZoneExists(context.Background(), "nope.com.")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected nope.com. to not exist")
}
}
func TestStoreGetZoneRecords(t *testing.T) {
createTestSchema(t)
db := requireDB(t)
reseed(t, db, exampleComSeed())
st := NewStore(db, testTablePrefix)
records, err := st.GetZoneRecords(context.Background(), "example.com.")
if err != nil {
t.Fatal(err)
}
if len(records) != len(exampleComSeed()) {
t.Fatalf("got %d records, want %d", len(records), len(exampleComSeed()))
}
}
func TestStoreApplyRRsetsReplace(t *testing.T) {
createTestSchema(t)
db := requireDB(t)
reseed(t, db, exampleComSeed())
st := NewStore(db, testTablePrefix)
changes := []RRsetChange{
{
Zone: "example.com.",
Name: "www",
RecordType: "CNAME",
ChangeType: "REPLACE",
TTL: 60,
Contents: []string{`{"host":"other.example.com."}`},
},
}
if err := st.ApplyRRsets(context.Background(), changes); err != nil {
t.Fatal(err)
}
rows, err := db.QueryContext(context.Background(),
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "www", "CNAME")
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var contents []string
for rows.Next() {
var c string
if err := rows.Scan(&c); err != nil {
t.Fatal(err)
}
contents = append(contents, c)
}
if err := rows.Err(); err != nil {
t.Fatal(err)
}
if len(contents) != 1 || contents[0] != `{"host":"other.example.com."}` {
t.Fatalf("contents = %v, want single other.example.com. row", contents)
}
}
func TestStoreApplyRRsetsDelete(t *testing.T) {
createTestSchema(t)
db := requireDB(t)
reseed(t, db, exampleComSeed())
st := NewStore(db, testTablePrefix)
changes := []RRsetChange{
{Zone: "example.com.", Name: "www", RecordType: "CNAME", ChangeType: "DELETE"},
}
if err := st.ApplyRRsets(context.Background(), changes); err != nil {
t.Fatal(err)
}
var count int
err := db.QueryRowContext(context.Background(),
fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
"example.com.", "www", "CNAME").Scan(&count)
if err != nil {
t.Fatal(err)
}
if count != 0 {
t.Fatalf("count = %d, want 0 after DELETE", count)
}
}

View File

@@ -0,0 +1,14 @@
<!-- SPDX-License-Identifier: GPL-2.0-or-later -->
<!-- Copyright (C) 2026 Bryan Joshua Pedini -->
<!DOCTYPE html>
<html>
<head>
<title>{{.Name}}</title>
</head>
<body>
<p>
Version: {{.Version}}<br />
Commit ID: {{.CommitId}}
</p>
</body>
</html>

49
type_webserver.go Normal file
View File

@@ -0,0 +1,49 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
"database/sql"
"fmt"
"log/slog"
"net/http"
"github.com/gorilla/mux"
)
type WebServer struct {
HTTPServer *http.Server
AppName string
Config *Config
Logger *slog.Logger
DB *sql.DB
Store *Store
}
func (s *WebServer) Initialize() {
s.AppName = "PowerDNS API Simulator"
s.Config = LoadConfig()
s.Logger = NewLogger(s.Config.LogLevel)
}
func (s *WebServer) Start() error {
// Create a new MUX router and an HTTP server
r := mux.NewRouter()
r.Use(s.loggingMiddleware)
s.HTTPServer = &http.Server{
Addr: s.Config.ListenAddr,
Handler: r,
}
// Associate the various handlers (routes)
s.Routes(r)
// Start the server
s.Logger.Info("listening", "addr", s.Config.ListenAddr)
fmt.Println("Listening on", s.Config.ListenAddr)
err := s.HTTPServer.ListenAndServe()
// Return error, or nil
return err
}

17
vars.example Normal file
View File

@@ -0,0 +1,17 @@
#/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
export GIT_HOST=git.bjphoster.com
export DEPLOYMENT_HOST=deploy.example.com
export DEPLOYMENT_PATHS=/srv/example
export GO_BUILDER=bryanpedini/gobuilder
export GO_VERSION=1.22.2-alpine3.19
export GOOS=linux
export GOARCH=amd64
export REPO_ORG=source
export REPO_NAME=coredns-powerdns-api-wrapper
export CONTAINER_ORG=git.bjphoster.com/source
export CONTAINER_IMAGE=coredns-powerdns-api-wrapper
export CONTAINER_IP=127.0.0.1
export CONTAINER_PORT=3000

29
version.go Normal file
View File

@@ -0,0 +1,29 @@
// SPDX-License-Identifier: GPL-2.0-or-later
// Copyright (C) 2026 Bryan Joshua Pedini
package main
import (
_ "embed"
"html/template"
"net/http"
)
//go:embed templates/html/version.html
var versionTemplate string
func handleVersion(w http.ResponseWriter, r *http.Request) {
type SiteInfo struct {
CommitId string
Name string
Version string
}
tmpl, _ := template.New("version.html").Parse(versionTemplate)
// Return (write) the version to the response body
tmpl.Execute(w, SiteInfo{
CommitId: COMMIT_ID,
Name: ws.AppName,
Version: APP_VERSION,
})
}

33
version.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/usr/bin/env bash
# SPDX-License-Identifier: GPL-2.0-or-later
# Copyright (C) 2026 Bryan Joshua Pedini
if [ -z ${APP_VERSION} ]; then
read -p "Version [latest]: " VERSIONINPUT
if [ -z ${VERSIONINPUT} ]; then
APP_VERSION="latest"
else
APP_VERSION=${VERSIONINPUT}
fi
fi
# Get docker push option from user
if [ -z ${DOCKERPUSH} ]; then
read -p "Docker push? [n]: " DOCKERPUSH
if [ -z ${DOCKERPUSH} ]; then
DOCKERPUSH=n
fi
fi
# Create version tag (if provided)
if [ ! -z ${VERSIONINPUT} ]; then
git tag ${APP_VERSION}
fi
# Build the app
export APP_VERSION
make docker
# If wanted, push the docker image
if [ ${DOCKERPUSH} = "y" ]; then
make dockerpush
fi