commit 869749bb018b6bcd58a8d61500056d5ba8cbdeb4 Author: Bryan Joshua Pedini Date: Thu Jul 9 22:16:19 2026 +0200 first commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9050844 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +LICENSE +dockerfile +makefile +.vars +.cursor +*.md +*.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..84d37ae --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +.vars +data +*.sql +.claude +coredns-powerdns-api-wrapper diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..822ecdc --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9efa6fb --- /dev/null +++ b/LICENSE @@ -0,0 +1,338 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + + 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. + + + Copyright (C) + + 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 . + +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. + + , 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. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..7043c06 --- /dev/null +++ b/PLAN.md @@ -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":""}` 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 2–5 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bca3dd4 --- /dev/null +++ b/README.md @@ -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`. diff --git a/auth.go b/auth.go new file mode 100644 index 0000000..5c52754 --- /dev/null +++ b/auth.go @@ -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) + }) +} diff --git a/auth_test.go b/auth_test.go new file mode 100644 index 0000000..f5e5204 --- /dev/null +++ b/auth_test.go @@ -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) + } +} diff --git a/config.go b/config.go new file mode 100644 index 0000000..d863628 --- /dev/null +++ b/config.go @@ -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) +} diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..b5cb742 --- /dev/null +++ b/config_test.go @@ -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() +} diff --git a/db.go b/db.go new file mode 100644 index 0000000..5f5d7d2 --- /dev/null +++ b/db.go @@ -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 +} diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..7862f79 --- /dev/null +++ b/deploy.sh @@ -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" diff --git a/dockerfile b/dockerfile new file mode 100644 index 0000000..ebf2d64 --- /dev/null +++ b/dockerfile @@ -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"] diff --git a/docs/EVIDENCE.md b/docs/EVIDENCE.md new file mode 100644 index 0000000..b63cece --- /dev/null +++ b/docs/EVIDENCE.md @@ -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 +$ 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 +``` diff --git a/docs/SEMANTICS.md b/docs/SEMANTICS.md new file mode 100644 index 0000000..82cc974 --- /dev/null +++ b/docs/SEMANTICS.md @@ -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":""}` | + | 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": "", "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":"."` 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":"","name":"","url":"/api/v1/servers/{sid}/zones/"}]` +(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":""}` (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. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6e3cc33 --- /dev/null +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bcdd825 --- /dev/null +++ b/go.sum @@ -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= diff --git a/healthz.go b/healthz.go new file mode 100644 index 0000000..13ddc55 --- /dev/null +++ b/healthz.go @@ -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"}) +} diff --git a/initialize.sh b/initialize.sh new file mode 100755 index 0000000..bb84189 --- /dev/null +++ b/initialize.sh @@ -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 diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..b815f70 --- /dev/null +++ b/logging.go @@ -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), + ) + }) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..bfd77a3 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/makefile b/makefile new file mode 100644 index 0000000..d8bdf8e --- /dev/null +++ b/makefile @@ -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} diff --git a/pdns.go b/pdns.go new file mode 100644 index 0000000..221bfcf --- /dev/null +++ b/pdns.go @@ -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"}) +} diff --git a/pdns_test.go b/pdns_test.go new file mode 100644 index 0000000..2e770da --- /dev/null +++ b/pdns_test.go @@ -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 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) + } +} diff --git a/privdrop.go b/privdrop.go new file mode 100644 index 0000000..8aa3619 --- /dev/null +++ b/privdrop.go @@ -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()) +} diff --git a/privdrop_test.go b/privdrop_test.go new file mode 100644 index 0000000..b22a15d --- /dev/null +++ b/privdrop_test.go @@ -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) +} diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..d6ff507 --- /dev/null +++ b/routes.go @@ -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") +} diff --git a/store.go b/store.go new file mode 100644 index 0000000..075f31f --- /dev/null +++ b/store.go @@ -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() +} diff --git a/store_test.go b/store_test.go new file mode 100644 index 0000000..c302004 --- /dev/null +++ b/store_test.go @@ -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) + } +} diff --git a/templates/html/version.html b/templates/html/version.html new file mode 100644 index 0000000..98ab2ae --- /dev/null +++ b/templates/html/version.html @@ -0,0 +1,14 @@ + + + + + + {{.Name}} + + +

+ Version: {{.Version}}
+ Commit ID: {{.CommitId}} +

+ + diff --git a/type_webserver.go b/type_webserver.go new file mode 100644 index 0000000..7cc70a0 --- /dev/null +++ b/type_webserver.go @@ -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 +} diff --git a/vars.example b/vars.example new file mode 100644 index 0000000..80776fd --- /dev/null +++ b/vars.example @@ -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 diff --git a/version.go b/version.go new file mode 100644 index 0000000..9951072 --- /dev/null +++ b/version.go @@ -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, + }) +} diff --git a/version.sh b/version.sh new file mode 100755 index 0000000..67cc90a --- /dev/null +++ b/version.sh @@ -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