From 869749bb018b6bcd58a8d61500056d5ba8cbdeb4 Mon Sep 17 00:00:00 2001 From: Bryan Joshua Pedini Date: Thu, 9 Jul 2026 22:16:19 +0200 Subject: [PATCH] first commit --- .dockerignore | 7 + .gitignore | 6 + CLAUDE.md | 13 + LICENSE | 338 +++++++++++++++++++++++++ PLAN.md | 150 +++++++++++ README.md | 88 +++++++ auth.go | 28 ++ auth_test.go | 174 +++++++++++++ config.go | 93 +++++++ config_test.go | 113 +++++++++ db.go | 39 +++ deploy.sh | 41 +++ dockerfile | 36 +++ docs/EVIDENCE.md | 151 +++++++++++ docs/SEMANTICS.md | 146 +++++++++++ go.mod | 10 + go.sum | 6 + healthz.go | 30 +++ initialize.sh | 14 + logging.go | 60 +++++ main.go | 52 ++++ makefile | 77 ++++++ pdns.go | 411 ++++++++++++++++++++++++++++++ pdns_test.go | 492 ++++++++++++++++++++++++++++++++++++ privdrop.go | 46 ++++ privdrop_test.go | 30 +++ routes.go | 19 ++ store.go | 123 +++++++++ store_test.go | 269 ++++++++++++++++++++ templates/html/version.html | 14 + type_webserver.go | 49 ++++ vars.example | 17 ++ version.go | 29 +++ version.sh | 33 +++ 34 files changed, 3204 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 LICENSE create mode 100644 PLAN.md create mode 100644 README.md create mode 100644 auth.go create mode 100644 auth_test.go create mode 100644 config.go create mode 100644 config_test.go create mode 100644 db.go create mode 100755 deploy.sh create mode 100644 dockerfile create mode 100644 docs/EVIDENCE.md create mode 100644 docs/SEMANTICS.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 healthz.go create mode 100755 initialize.sh create mode 100644 logging.go create mode 100644 main.go create mode 100644 makefile create mode 100644 pdns.go create mode 100644 pdns_test.go create mode 100644 privdrop.go create mode 100644 privdrop_test.go create mode 100644 routes.go create mode 100644 store.go create mode 100644 store_test.go create mode 100644 templates/html/version.html create mode 100644 type_webserver.go create mode 100644 vars.example create mode 100644 version.go create mode 100755 version.sh 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