You've already forked coredns-powerdns-api-wrapper
first commit
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
LICENSE
|
||||
dockerfile
|
||||
makefile
|
||||
.vars
|
||||
.cursor
|
||||
*.md
|
||||
*.sh
|
||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.vars
|
||||
data
|
||||
*.sql
|
||||
.claude
|
||||
coredns-powerdns-api-wrapper
|
||||
13
CLAUDE.md
Normal file
13
CLAUDE.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# PowerDNS API Simulator — project rules
|
||||
|
||||
Read PLAN.md before doing anything. It is the spec; docs/SEMANTICS.md (produced in Phase 0) is the data-shape contract.
|
||||
|
||||
Invariants that hold in every session and every subagent:
|
||||
|
||||
- Configuration is environment variables ONLY. No config files. `config.yaml` and its loader are being removed, not maintained.
|
||||
- Never run acme.sh against the Let's Encrypt production CA. `--staging` always.
|
||||
- The live MySQL schema is the source of truth for the CoreDNS side, not plugin documentation. Verify against the running DB.
|
||||
- Dockerfile and Makefile already exist: follow their style, amend only when required, never rewrite.
|
||||
- The binary starts as root and drops to PUID/PGID before serving. Do not add a `USER` directive to the Dockerfile.
|
||||
- DNS verification goes through `dig @1.2.3.4` (authoritative, no cache). Remember CoreDNS `zone_update_interval` before declaring a propagation failure.
|
||||
- Follow existing repository style. Direct, minimal output. No speculative features beyond PLAN.md's scope; the Explicit non-goals section is binding.
|
||||
338
LICENSE
Normal file
338
LICENSE
Normal file
@@ -0,0 +1,338 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 2, June 1991
|
||||
|
||||
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||
<https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The licenses for most software are designed to take away your
|
||||
freedom to share and change it. By contrast, the GNU General Public
|
||||
License is intended to guarantee your freedom to share and change free
|
||||
software--to make sure the software is free for all its users. This
|
||||
General Public License applies to most of the Free Software
|
||||
Foundation's software and to any other program whose authors commit to
|
||||
using it. (Some other Free Software Foundation software is covered by
|
||||
the GNU Lesser General Public License instead.) You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
this service if you wish), that you receive source code or can get it
|
||||
if you want it, that you can change the software or use pieces of it
|
||||
in new free programs; and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to make restrictions that forbid
|
||||
anyone to deny you these rights or to ask you to surrender the rights.
|
||||
These restrictions translate to certain responsibilities for you if you
|
||||
distribute copies of the software, or if you modify it.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must give the recipients all the rights that
|
||||
you have. You must make sure that they, too, receive or can get the
|
||||
source code. And you must show them these terms so they know their
|
||||
rights.
|
||||
|
||||
We protect your rights with two steps: (1) copyright the software, and
|
||||
(2) offer you this license which gives you legal permission to copy,
|
||||
distribute and/or modify the software.
|
||||
|
||||
Also, for each author's protection and ours, we want to make certain
|
||||
that everyone understands that there is no warranty for this free
|
||||
software. If the software is modified by someone else and passed on, we
|
||||
want its recipients to know that what they have is not the original, so
|
||||
that any problems introduced by others will not reflect on the original
|
||||
authors' reputations.
|
||||
|
||||
Finally, any free program is threatened constantly by software
|
||||
patents. We wish to avoid the danger that redistributors of a free
|
||||
program will individually obtain patent licenses, in effect making the
|
||||
program proprietary. To prevent this, we have made it clear that any
|
||||
patent must be licensed for everyone's free use or not licensed at all.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||
|
||||
0. This License applies to any program or other work which contains
|
||||
a notice placed by the copyright holder saying it may be distributed
|
||||
under the terms of this General Public License. The "Program", below,
|
||||
refers to any such program or work, and a "work based on the Program"
|
||||
means either the Program or any derivative work under copyright law:
|
||||
that is to say, a work containing the Program or a portion of it,
|
||||
either verbatim or with modifications and/or translated into another
|
||||
language. (Hereinafter, translation is included without limitation in
|
||||
the term "modification".) Each licensee is addressed as "you".
|
||||
|
||||
Activities other than copying, distribution and modification are not
|
||||
covered by this License; they are outside its scope. The act of
|
||||
running the Program is not restricted, and the output from the Program
|
||||
is covered only if its contents constitute a work based on the
|
||||
Program (independent of having been made by running the Program).
|
||||
Whether that is true depends on what the Program does.
|
||||
|
||||
1. You may copy and distribute verbatim copies of the Program's
|
||||
source code as you receive it, in any medium, provided that you
|
||||
conspicuously and appropriately publish on each copy an appropriate
|
||||
copyright notice and disclaimer of warranty; keep intact all the
|
||||
notices that refer to this License and to the absence of any warranty;
|
||||
and give any other recipients of the Program a copy of this License
|
||||
along with the Program.
|
||||
|
||||
You may charge a fee for the physical act of transferring a copy, and
|
||||
you may at your option offer warranty protection in exchange for a fee.
|
||||
|
||||
2. You may modify your copy or copies of the Program or any portion
|
||||
of it, thus forming a work based on the Program, and copy and
|
||||
distribute such modifications or work under the terms of Section 1
|
||||
above, provided that you also meet all of these conditions:
|
||||
|
||||
a) You must cause the modified files to carry prominent notices
|
||||
stating that you changed the files and the date of any change.
|
||||
|
||||
b) You must cause any work that you distribute or publish, that in
|
||||
whole or in part contains or is derived from the Program or any
|
||||
part thereof, to be licensed as a whole at no charge to all third
|
||||
parties under the terms of this License.
|
||||
|
||||
c) If the modified program normally reads commands interactively
|
||||
when run, you must cause it, when started running for such
|
||||
interactive use in the most ordinary way, to print or display an
|
||||
announcement including an appropriate copyright notice and a
|
||||
notice that there is no warranty (or else, saying that you provide
|
||||
a warranty) and that users may redistribute the program under
|
||||
these conditions, and telling the user how to view a copy of this
|
||||
License. (Exception: if the Program itself is interactive but
|
||||
does not normally print such an announcement, your work based on
|
||||
the Program is not required to print an announcement.)
|
||||
|
||||
These requirements apply to the modified work as a whole. If
|
||||
identifiable sections of that work are not derived from the Program,
|
||||
and can be reasonably considered independent and separate works in
|
||||
themselves, then this License, and its terms, do not apply to those
|
||||
sections when you distribute them as separate works. But when you
|
||||
distribute the same sections as part of a whole which is a work based
|
||||
on the Program, the distribution of the whole must be on the terms of
|
||||
this License, whose permissions for other licensees extend to the
|
||||
entire whole, and thus to each and every part regardless of who wrote it.
|
||||
|
||||
Thus, it is not the intent of this section to claim rights or contest
|
||||
your rights to work written entirely by you; rather, the intent is to
|
||||
exercise the right to control the distribution of derivative or
|
||||
collective works based on the Program.
|
||||
|
||||
In addition, mere aggregation of another work not based on the Program
|
||||
with the Program (or with a work based on the Program) on a volume of
|
||||
a storage or distribution medium does not bring the other work under
|
||||
the scope of this License.
|
||||
|
||||
3. You may copy and distribute the Program (or a work based on it,
|
||||
under Section 2) in object code or executable form under the terms of
|
||||
Sections 1 and 2 above provided that you also do one of the following:
|
||||
|
||||
a) Accompany it with the complete corresponding machine-readable
|
||||
source code, which must be distributed under the terms of Sections
|
||||
1 and 2 above on a medium customarily used for software interchange; or,
|
||||
|
||||
b) Accompany it with a written offer, valid for at least three
|
||||
years, to give any third party, for a charge no more than your
|
||||
cost of physically performing source distribution, a complete
|
||||
machine-readable copy of the corresponding source code, to be
|
||||
distributed under the terms of Sections 1 and 2 above on a medium
|
||||
customarily used for software interchange; or,
|
||||
|
||||
c) Accompany it with the information you received as to the offer
|
||||
to distribute corresponding source code. (This alternative is
|
||||
allowed only for noncommercial distribution and only if you
|
||||
received the program in object code or executable form with such
|
||||
an offer, in accord with Subsection b above.)
|
||||
|
||||
The source code for a work means the preferred form of the work for
|
||||
making modifications to it. For an executable work, complete source
|
||||
code means all the source code for all modules it contains, plus any
|
||||
associated interface definition files, plus the scripts used to
|
||||
control compilation and installation of the executable. However, as a
|
||||
special exception, the source code distributed need not include
|
||||
anything that is normally distributed (in either source or binary
|
||||
form) with the major components (compiler, kernel, and so on) of the
|
||||
operating system on which the executable runs, unless that component
|
||||
itself accompanies the executable.
|
||||
|
||||
If distribution of executable or object code is made by offering
|
||||
access to copy from a designated place, then offering equivalent
|
||||
access to copy the source code from the same place counts as
|
||||
distribution of the source code, even though third parties are not
|
||||
compelled to copy the source along with the object code.
|
||||
|
||||
4. You may not copy, modify, sublicense, or distribute the Program
|
||||
except as expressly provided under this License. Any attempt
|
||||
otherwise to copy, modify, sublicense or distribute the Program is
|
||||
void, and will automatically terminate your rights under this License.
|
||||
However, parties who have received copies, or rights, from you under
|
||||
this License will not have their licenses terminated so long as such
|
||||
parties remain in full compliance.
|
||||
|
||||
5. You are not required to accept this License, since you have not
|
||||
signed it. However, nothing else grants you permission to modify or
|
||||
distribute the Program or its derivative works. These actions are
|
||||
prohibited by law if you do not accept this License. Therefore, by
|
||||
modifying or distributing the Program (or any work based on the
|
||||
Program), you indicate your acceptance of this License to do so, and
|
||||
all its terms and conditions for copying, distributing or modifying
|
||||
the Program or works based on it.
|
||||
|
||||
6. Each time you redistribute the Program (or any work based on the
|
||||
Program), the recipient automatically receives a license from the
|
||||
original licensor to copy, distribute or modify the Program subject to
|
||||
these terms and conditions. You may not impose any further
|
||||
restrictions on the recipients' exercise of the rights granted herein.
|
||||
You are not responsible for enforcing compliance by third parties to
|
||||
this License.
|
||||
|
||||
7. If, as a consequence of a court judgment or allegation of patent
|
||||
infringement or for any other reason (not limited to patent issues),
|
||||
conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot
|
||||
distribute so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you
|
||||
may not distribute the Program at all. For example, if a patent
|
||||
license would not permit royalty-free redistribution of the Program by
|
||||
all those who receive copies directly or indirectly through you, then
|
||||
the only way you could satisfy both it and this License would be to
|
||||
refrain entirely from distribution of the Program.
|
||||
|
||||
If any portion of this section is held invalid or unenforceable under
|
||||
any particular circumstance, the balance of the section is intended to
|
||||
apply and the section as a whole is intended to apply in other
|
||||
circumstances.
|
||||
|
||||
It is not the purpose of this section to induce you to infringe any
|
||||
patents or other property right claims or to contest validity of any
|
||||
such claims; this section has the sole purpose of protecting the
|
||||
integrity of the free software distribution system, which is
|
||||
implemented by public license practices. Many people have made
|
||||
generous contributions to the wide range of software distributed
|
||||
through that system in reliance on consistent application of that
|
||||
system; it is up to the author/donor to decide if he or she is willing
|
||||
to distribute software through any other system and a licensee cannot
|
||||
impose that choice.
|
||||
|
||||
This section is intended to make thoroughly clear what is believed to
|
||||
be a consequence of the rest of this License.
|
||||
|
||||
8. If the distribution and/or use of the Program is restricted in
|
||||
certain countries either by patents or by copyrighted interfaces, the
|
||||
original copyright holder who places the Program under this License
|
||||
may add an explicit geographical distribution limitation excluding
|
||||
those countries, so that distribution is permitted only in or among
|
||||
countries not thus excluded. In such case, this License incorporates
|
||||
the limitation as if written in the body of this License.
|
||||
|
||||
9. The Free Software Foundation may publish revised and/or new versions
|
||||
of the General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the Program
|
||||
specifies a version number of this License which applies to it and "any
|
||||
later version", you have the option of following the terms and conditions
|
||||
either of that version or of any later version published by the Free
|
||||
Software Foundation. If the Program does not specify a version number of
|
||||
this License, you may choose any version ever published by the Free Software
|
||||
Foundation.
|
||||
|
||||
10. If you wish to incorporate parts of the Program into other free
|
||||
programs whose distribution conditions are different, write to the author
|
||||
to ask for permission. For software which is copyrighted by the Free
|
||||
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||
make exceptions for this. Our decision will be guided by the two goals
|
||||
of preserving the free status of all derivatives of our free software and
|
||||
of promoting the sharing and reuse of software generally.
|
||||
|
||||
NO WARRANTY
|
||||
|
||||
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||
REPAIR OR CORRECTION.
|
||||
|
||||
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGES.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
convey the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software; you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation; either version 2 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License along
|
||||
with this program; if not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program is interactive, make it output a short notice like this
|
||||
when it starts in an interactive mode:
|
||||
|
||||
Gnomovision version 69, Copyright (C) year name of author
|
||||
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, the commands you use may
|
||||
be called something other than `show w' and `show c'; they could even be
|
||||
mouse-clicks or menu items--whatever suits your program.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or your
|
||||
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||
necessary. Here is a sample; alter the names:
|
||||
|
||||
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||
|
||||
<signature of Moe Ghoul>, 1 April 1989
|
||||
Moe Ghoul, President of Vice
|
||||
|
||||
This General Public License does not permit incorporating your program into
|
||||
proprietary programs. If your program is a subroutine library, you may
|
||||
consider it more useful to permit linking proprietary applications with the
|
||||
library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License.
|
||||
150
PLAN.md
Normal file
150
PLAN.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# PLAN.md — PowerDNS API Simulator for CoreDNS (MySQL backend)
|
||||
|
||||
> **Status: DELIVERED 2026-07-10.** This document was updated after completion
|
||||
> to reflect the system as actually built and verified. Deviations from the
|
||||
> original plan are marked **[as-built]** inline. Phase-gate evidence lives in
|
||||
> `docs/EVIDENCE.md`; the empirical data-shape contract in `docs/SEMANTICS.md`.
|
||||
|
||||
## Goal
|
||||
|
||||
Implement a Go web service that exposes a **1:1 replica of the PowerDNS Authoritative HTTP API** (the subset used by ACME clients) and translates those calls into MySQL queries against the database that CoreDNS reads (mysql plugin). The sole consumer is **acme.sh's `dns_pdns` provider** for Let's Encrypt DNS-01 validation. The program must be production-ready and fully tested end-to-end before the work is considered done.
|
||||
|
||||
The translation contract in one sentence: **speak PowerDNS at the HTTP edge, read/write CoreDNS rows at the DB edge — in both directions.** A GET must reassemble CoreDNS rows into pdns rrsets; a PATCH must decompose pdns rrsets into CoreDNS rows.
|
||||
|
||||
The repository was already initialized with a minimal Go web server; it was extended, not rewritten (WebServer type, gorilla/mux `Routes`, `/version`, graceful shutdown all kept).
|
||||
|
||||
## Non-negotiable constraints
|
||||
|
||||
- **No config.yaml.** All YAML config code, struct tags, and the YAML dependency are removed. All configuration comes exclusively from **environment variables** (Docker-friendly). **[as-built]** Confirmed: `grep -ri yaml` over the repo finds nothing config-related (`update_child_repos.sh`, the last stray reference, has been deleted).
|
||||
- **Exact PowerDNS API compatibility** for the endpoints acme.sh touches: same paths, same JSON shapes, same status codes, same headers. The PowerDNS API docs and the `dns_pdns.sh` source are authoritative. **[as-built]** `dns_pdns.sh` was read from the `neilpang/acme.sh` image (`/acmebin/dnsapi/dns_pdns.sh`); its scraping regexes require rrset JSON field order `name`, `type`, `ttl`, `records` (ordered Go structs, never maps) and `changetype: DELETE` bodies **without** `ttl`/`records` — both honored.
|
||||
- **API key auth** via `X-API-Key` header, value from `PDNS_API_KEY`. Missing/wrong key returns `401` with body `{"error":"Unauthorized"}`.
|
||||
- **Never hit the Let's Encrypt production CA.** Staging only, in every phase.
|
||||
- Done means: full acme.sh workflow (staging CA) succeeds and a certificate is emitted. **[as-built]** Achieved twice: once against `go`-built binary, once against the container image.
|
||||
|
||||
## Configuration (environment variables)
|
||||
|
||||
Implemented in `config.go` exactly as specified; fail-fast errors name the missing variable.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PDNS_API_KEY` | Shared secret for `X-API-Key` | *(required, no default)* |
|
||||
| `LISTEN_ADDR` | HTTP listen address | `:3000` |
|
||||
| `MYSQL_DSN` | Go MySQL DSN (`user:pass@tcp(host:3306)/dbname?parseTime=true`) | *(required)* |
|
||||
| `TABLE_PREFIX` | Table name prefix, mirroring the Corefile `table_prefix` directive (e.g. `coredns_`) | `coredns_` |
|
||||
| `PDNS_SERVER_ID` | Server id in API paths | `localhost` |
|
||||
| `DEFAULT_TTL` | TTL when acme.sh doesn't send one | `120` |
|
||||
| `LOG_LEVEL` | `debug` / `info` / `warn` / `error` (validated) | `info` |
|
||||
| `PUID` | UID to drop privileges to after startup | *(required)* |
|
||||
| `PGID` | GID to drop privileges to after startup | *(required)* |
|
||||
|
||||
**[as-built]** `PUID=0` or `PGID=0` is rejected at config load ("refusing to serve as root"). `DEFAULT_TTL` must be a positive integer.
|
||||
|
||||
## Privilege drop (startup sequence)
|
||||
|
||||
Implemented in `privdrop.go`, called first thing in `main()` before any listener, goroutine, or DB connection.
|
||||
|
||||
- Order: `setgroups([])` → `setgid(PGID)` → `setuid(PUID)`, exactly.
|
||||
- Go ≥ 1.16 applies the raw syscalls process-wide; module targets `go 1.22`, builder is go 1.22.2.
|
||||
- After dropping, verification: `os.Getuid()`/`os.Getgid()` must equal `PUID`/`PGID`, and `syscall.Setuid(0)` must fail. Effective uid/gid logged at `info`. Verification failure → `os.Exit(1)`; never serve as root.
|
||||
- The MySQL pool is opened **after** the drop.
|
||||
- `LISTEN_ADDR` is `:3000` (>1024), so no capability retention; everything is dropped.
|
||||
- **[as-built]** If the process starts as **non-root** (local dev runs), the drop is skipped with a `warn` log ("already running unprivileged"); `PUID`/`PGID` remain required config. This path is unit-tested; the root path is verified at container level (`docker top` shows uid/gid 4321/4321 with test values).
|
||||
|
||||
## Phase 0 — Empirical semantics discovery ✅ (findings in `docs/SEMANTICS.md`)
|
||||
|
||||
Ground truth was established against the **live** MariaDB and the **running** CoreDNS (never restarted or reconfigured — its behavior is part of the ground truth):
|
||||
|
||||
1. **CoreDNS side (verified empirically, not from plugin docs):**
|
||||
- Table `coredns_records(id, zone, name, ttl, content TEXT/JSON, record_type)`; `zone` FQDN with trailing dot; `name` **relative to zone, no trailing dot**, `''` at apex; multi-label relative names work; FQDN-in-name is NOT served.
|
||||
- **Multiple TXT values on one name = multiple rows**, one `{"text":"..."}` each. A JSON array inside one row is NOT served.
|
||||
- TXT quoting: store the **raw unquoted** token; CoreDNS adds wire quoting. Storing pdns-style `\"tok\"` yields literal quotes on the wire (wrong for ACME).
|
||||
- Queries are case-insensitive; store names lowercase.
|
||||
- **Visibility [key as-built finding]:** record lookups hit MySQL live — writes to an *existing* zone are visible **immediately**. `zone_update_interval` (120s in the Corefile) only gates the cached **zone list**, i.e. newly created zones. It was NOT lowered for dev (CoreDNS is hands-off); it didn't need to be.
|
||||
2. **PowerDNS side:** rrset model `{name, type, ttl, changetype, records:[{content, disabled}]}`; `REPLACE` replaces the entire `(name, type)` set; `DELETE` removes it (and arrives without `ttl`/`records`).
|
||||
3. The full mapping table (pdns operation → exact SQL, TXT quote handling, FQDN normalization) is in `docs/SEMANTICS.md` §4; every handler implements it.
|
||||
|
||||
## API surface implemented (what acme.sh `dns_pdns` uses)
|
||||
|
||||
All under `/api/v1`, all requiring `X-API-Key`; unknown `{server_id}` → 404 on every route; zone_id accepted with/without trailing dot and URL-encoded dots; `Content-Type: application/json` on all responses.
|
||||
|
||||
1. **`GET /zones`** — zone list from `SELECT DISTINCT zone`; `id`/`name` canonical with trailing dot (acme.sh `_get_root` substring-matches `"name":"zone."`), plus `url` and **[as-built]** `kind:"Native"` for shape parity.
|
||||
2. **`GET /zones/{zone_id}`** — zone detail with `rrsets` aggregated from rows grouped by `(name, type)`. TXT contents re-quoted (`"\"tok\""`); A/AAAA→`ip`, CNAME/NS→`host`, SOA assembled as `ns mbox 0 refresh retry expire minttl` (serial not stored → 0); unparsable content passed through raw. 0 rows → 404.
|
||||
3. **`PATCH /zones/{zone_id}`** — `REPLACE`: transactional delete-all + one INSERT **per record** (the two-TXT-values-on-one-`_acme-challenge` case is the critical path and is covered by tests, curl, dig, and the E2E). `DELETE`: delete-all for `(zone, name, type)`. `204` on success, `422` + `{"error":"<specific>"}` on malformed rrsets, `404` on unknown zone. **[as-built]** Write support: TXT/A/AAAA/CNAME/NS; names lowercased, must be within the zone (label-boundary checked, `notexample.com.` vs `example.com.` → 422); TTL absent/0 → `DEFAULT_TTL`.
|
||||
4. **`PUT /zones/{zone_id}/notify`** — no-op (CoreDNS reads MySQL live); `200` + `{"result":"Notification queued"}`; unknown zone → 404.
|
||||
|
||||
## MySQL layer
|
||||
|
||||
- Exact live schema, table name from `TABLE_PREFIX` (`store.go`).
|
||||
- Prepared statements; REPLACE (delete+insert) wrapped in a transaction, rollback on any error.
|
||||
- **[as-built]** Pool: MaxOpenConns 10, MaxIdleConns 5, ConnMaxLifetime 60s (mirrors the Corefile limits); ping at startup with 5s timeout, fail fast.
|
||||
- **[as-built]** Driver pinned to `go-sql-driver/mysql v1.9.3` (v1.10 forces `go 1.24`, above the module's `go 1.22`).
|
||||
|
||||
## Development / test environment (as actually used)
|
||||
|
||||
- Dev server on `:3000`, exposed via the running ngrok tunnel:
|
||||
**`https://claude-test-endpoint.ngrok-free.dev` → `http://127.0.0.1:3000`**
|
||||
- CoreDNS running on port **5053**, public forward **`1.2.3.4:53` → localhost:5053**.
|
||||
- **Test zone: `example.com`** (SAN: `www.example.com`) — **placeholder; this will need changing before any real run.** ACME validation resolves through the public DNS tree even against the staging CA, so the zone must be genuinely delegated from its public parent to the CoreDNS IP (`1.2.3.4`) — and Let's Encrypt additionally refuses to issue for `example.com` itself by policy (`rejectedIdentifier`). The delivery E2E therefore ran against a real delegated sub-zone, redacted to `example.com` throughout these docs. The zone rows (SOA, apex NS/A, `ns1` A → `1.2.3.4`, `www` CNAME) live in `coredns_records`.
|
||||
- **dig caveat [as-built]:** this dev host's outbound UDP/53 is transparently intercepted by a middlebox (proven: an IP with no DNS server "answers"). `dig @1.2.3.4` was unreachable during part of development and later worked (verified genuine via TCP + answer-shape/serial match with the local instance). When in doubt, verify against `dig @127.0.0.1 -p 5053` (same instance) and externally via DoH (`https://dns.google/resolve?...` reports `"Response from 1.2.3.4"`).
|
||||
|
||||
## Test plan (all executed; evidence in `docs/EVIDENCE.md`)
|
||||
|
||||
### Phase 1 — Unit/handler tests ✅
|
||||
27 top-level tests: auth (missing/wrong/correct key), zone listing, zone detail rrset aggregation + JSON field order, PATCH REPLACE/DELETE semantics, TXT quote handling both directions, FQDN normalization + boundary cases, notify, trailing-dot equivalence, 404/422 bodies, config fail-fast + defaults, privdrop non-root path. **[as-built]** DB-backed tests run against an isolated `test_pdnsapi_records` table on the live MariaDB (created/seeded/dropped per run; skip cleanly if DB unreachable); the real `coredns_records` is never touched.
|
||||
|
||||
### Phase 2 — Manual API tests with curl (localhost) ✅
|
||||
```
|
||||
curl -s -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones
|
||||
curl -s -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones/example.com.
|
||||
curl -s -X PATCH -H "X-API-Key: $PDNS_API_KEY" -H "Content-Type: application/json" \
|
||||
-d '{"rrsets":[{"name":"_acme-challenge.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"test-token-123\"","disabled":false}]}]}' \
|
||||
http://127.0.0.1:3000/api/v1/servers/localhost/zones/example.com.
|
||||
```
|
||||
Verified: wrong key → 401, missing zone → 404, malformed rrset → 422, two-record REPLACE on one `_acme-challenge` name → 204 with exactly two `{"text":...}` rows.
|
||||
|
||||
### Phase 3 — DNS propagation check with dig ✅
|
||||
```
|
||||
dig @1.2.3.4 TXT _acme-challenge.example.com +norecurse +noall +answer
|
||||
# fallback if the dev host's UDP/53 interception bites: dig @127.0.0.1 -p 5053 ...
|
||||
```
|
||||
Both values of the two-record REPLACE served; after `changetype: DELETE` → NXDOMAIN. Writes were visible immediately (see Phase 0 visibility finding — no `zone_update_interval` wait for records on existing zones).
|
||||
|
||||
### Phase 4 — Public endpoint test (ngrok) ✅
|
||||
Phase 2 calls repeated against `https://claude-test-endpoint.ngrok-free.dev`: identical bodies and status codes (200/204/401/404/422), PATCH-through-tunnel served by CoreDNS.
|
||||
|
||||
### Phase 5 — Full E2E with acme.sh (Let's Encrypt STAGING only) ✅
|
||||
```
|
||||
docker run --rm -v ~/acme.sh:/acme.sh \
|
||||
-e PDNS_Url="https://claude-test-endpoint.ngrok-free.dev" \
|
||||
-e PDNS_ServerId="localhost" \
|
||||
-e PDNS_Token="$PDNS_API_KEY" \
|
||||
-e PDNS_Ttl="120" \
|
||||
neilpang/acme.sh --issue --staging --dns dns_pdns \
|
||||
-d example.com -d www.example.com
|
||||
```
|
||||
All five checks passed: root-zone detection via `GET /zones` (server log), TXT PATCHes for **both** names (MySQL rows + dig), staging CA validated both identifiers, staging cert (issuer "(STAGING) Artificial Amaranth YE1", both SANs) saved under `~/acme.sh`, cleanup DELETEs confirmed (DB empty, NXDOMAIN).
|
||||
|
||||
**[as-built] flakiness note:** no `--dnssleep` is needed (propagation is immediate; acme.sh's default 20s check passes). One attempt failed with a **transient LE "secondary validation" timeout** — that is remote reachability of `1.2.3.4` from LE's vantage points, not propagation; the retry succeeded unchanged. If issuance flakes, retry before touching anything.
|
||||
|
||||
### Phase 6 — Production readiness ✅
|
||||
- Dockerfile untouched (already compliant: 2-stage build → `scratch`, **no `USER` directive** — the binary itself drops to `PUID`/`PGID`).
|
||||
- **[as-built]** makefile amendments (style preserved): `run`/`dockerrun` publish container port **3000** (was 80), pass through the nine app env vars with `--env`; `dockerrun` no longer mounts `config.yaml` and uses the `:$${APP_VERSION}` tag. `.vars` (gitignored) created locally for the build (`bryanpedini/gobuilder:1.22.2-alpine3.19`, image `git.bjphoster.com/source/coredns-powerdns-api-wrapper`).
|
||||
- Privilege-drop checks: unit test (non-root path) + container-level — image run with test `PUID=4321`/`PGID=4321`; startup log `privileges dropped uid=4321 gid=4321` and host-side `docker top` shows the serving process as 4321/4321, not root.
|
||||
- Graceful shutdown (SIGTERM), slog request logging at `info`, structured errors, `/healthz` (MySQL ping, unauthenticated, no data) — all in place.
|
||||
- README: env var table (incl. `TABLE_PREFIX` ↔ Corefile `table_prefix`), docker run example, acme.sh staging/production usage, propagation/`--dnssleep` guidance, known limitations.
|
||||
- Final Phase 5 re-run against the built image (`:0.1.0`, `--network host` since MariaDB binds to 127.0.0.1 only): cached staging authorizations were **deactivated first** so DNS-01 genuinely re-ran through the container; fresh cert issued.
|
||||
|
||||
## Definition of done — all satisfied 2026-07-10
|
||||
|
||||
- `docs/SEMANTICS.md` exists; every handler implements its mapping table. ✅
|
||||
- All YAML config code and files removed; env-vars-only confirmed by grep. ✅
|
||||
- All Go tests pass (`go test ./... -count=1`). ✅
|
||||
- Phases 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.
|
||||
88
README.md
Normal file
88
README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# PowerDNS APIs for CoreDNS
|
||||
|
||||
A Go service exposing the subset of the PowerDNS Authoritative HTTP API that
|
||||
acme.sh's `dns_pdns` provider uses, translating rrset operations into the
|
||||
MySQL table read by the CoreDNS `mysql` plugin. Contract in one sentence:
|
||||
**PowerDNS at the HTTP edge, CoreDNS rows at the DB edge.**
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is environment variables only — there is no config file.
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|---|---|---|
|
||||
| `PDNS_API_KEY` | Shared secret for `X-API-Key` | *(required, no default)* |
|
||||
| `LISTEN_ADDR` | HTTP listen address | `:3000` |
|
||||
| `MYSQL_DSN` | Go MySQL DSN (`user:pass@tcp(host:3306)/dbname?parseTime=true`) | *(required)* |
|
||||
| `TABLE_PREFIX` | Table name prefix; must mirror the Corefile `table_prefix` directive (e.g. `coredns_`) | `coredns_` |
|
||||
| `PDNS_SERVER_ID` | Server id in API paths | `localhost` |
|
||||
| `DEFAULT_TTL` | TTL when acme.sh doesn't send one | `120` |
|
||||
| `LOG_LEVEL` | `debug` / `info` / `warn` / `error` | `info` |
|
||||
| `PUID` | UID to drop privileges to after startup | *(required)* |
|
||||
| `PGID` | GID to drop privileges to after startup | *(required)* |
|
||||
|
||||
Startup fails fast, naming the missing variable, if a required one is absent.
|
||||
|
||||
## Privilege drop
|
||||
|
||||
The container starts the binary as root. Before serving any traffic, the
|
||||
binary irrevocably drops to `PGID`/`PUID` (`setgroups` → `setgid` → `setuid`).
|
||||
The image does **not** declare a `USER` directive — the drop is the binary's
|
||||
job, not the container's. If started as a non-root user already, the drop is
|
||||
skipped and the process serves as the invoking user.
|
||||
|
||||
## Running
|
||||
|
||||
```
|
||||
docker run --rm \
|
||||
--publish 3000:3000 \
|
||||
--env PDNS_API_KEY=changeme \
|
||||
--env MYSQL_DSN="coredns:coredns@tcp(127.0.0.1:3306)/coredns?parseTime=true" \
|
||||
--env TABLE_PREFIX=coredns_ \
|
||||
--env PDNS_SERVER_ID=localhost \
|
||||
--env DEFAULT_TTL=120 \
|
||||
--env LOG_LEVEL=info \
|
||||
--env PUID=1000 \
|
||||
--env PGID=1000 \
|
||||
git.bjphoster.com/source/coredns-powerdns-api-wrapper:latest
|
||||
```
|
||||
|
||||
`/healthz` is an unauthenticated health check that pings MySQL; it exposes no
|
||||
data.
|
||||
|
||||
## acme.sh usage
|
||||
|
||||
```
|
||||
docker run -it --rm -v ~/acme.sh:/acme.sh \
|
||||
-e PDNS_Url="https://your-endpoint" \
|
||||
-e PDNS_ServerId="localhost" \
|
||||
-e PDNS_Token="$PDNS_API_KEY" \
|
||||
-e PDNS_Ttl="120" \
|
||||
neilpang/acme.sh --issue --staging --dns dns_pdns \
|
||||
-d example.com -d www.example.com
|
||||
```
|
||||
|
||||
For production issuance, drop `--staging` (and optionally add
|
||||
`--server letsencrypt`).
|
||||
|
||||
Implemented API surface, all under `/api/v1`, all requiring `X-API-Key`:
|
||||
|
||||
- `GET /api/v1/servers/{server_id}/zones`
|
||||
- `GET /api/v1/servers/{server_id}/zones/{zone_id}`
|
||||
- `PATCH /api/v1/servers/{server_id}/zones/{zone_id}` (rrsets, `REPLACE`/`DELETE`)
|
||||
- `PUT /api/v1/servers/{server_id}/zones/{zone_id}/notify`
|
||||
|
||||
## Propagation / --dnssleep guidance
|
||||
|
||||
Record writes to an **existing** zone are visible to CoreDNS immediately —
|
||||
record lookups hit MySQL live, so no `--dnssleep` is needed for TXT changes.
|
||||
Only newly created zones are subject to the Corefile's `zone_update_interval`
|
||||
(120s), since that setting only gates the cached zone list. Verified
|
||||
empirically; see `docs/SEMANTICS.md`.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- Only the acme.sh API slice is implemented: no zone CRUD beyond `GET`, no
|
||||
cryptokeys/metadata/search/TSIG, no DNSSEC.
|
||||
- This service serves no DNS itself — CoreDNS remains the resolver.
|
||||
- rrset types supported for writes: `TXT`, `A`, `AAAA`, `CNAME`, `NS`.
|
||||
28
auth.go
Normal file
28
auth.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// writeJSONError writes a PowerDNS-shaped error body: {"error": msg}.
|
||||
func writeJSONError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// authMiddleware guards the /api/v1 subrouter: requests must carry
|
||||
// X-API-Key equal to Config.PDNSAPIKey, or they get a 401 in PowerDNS shape.
|
||||
func (s *WebServer) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("X-API-Key") != s.Config.PDNSAPIKey {
|
||||
writeJSONError(w, http.StatusUnauthorized, "Unauthorized")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
174
auth_test.go
Normal file
174
auth_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// newTestRouter builds a WebServer with a minimal Config and wires the real
|
||||
// Routes()/middleware stack, without any DB (nil Store/DB) — for the pure
|
||||
// handler tests that never reach the Store (auth, unknown server_id, etc).
|
||||
func newTestRouter(cfg *Config) (*WebServer, *mux.Router) {
|
||||
ws := &WebServer{
|
||||
Config: cfg,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
r := mux.NewRouter()
|
||||
r.Use(ws.loggingMiddleware)
|
||||
ws.Routes(r)
|
||||
return ws, r
|
||||
}
|
||||
|
||||
func testConfig() *Config {
|
||||
return &Config{
|
||||
PDNSAPIKey: "test-secret-key",
|
||||
ListenAddr: ":3000",
|
||||
TablePrefix: "test_pdnsapi_",
|
||||
PDNSServerID: "localhost",
|
||||
DefaultTTL: 120,
|
||||
LogLevel: "info",
|
||||
PUID: 1000,
|
||||
PGID: 1000,
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, r := newTestRouter(cfg)
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headerKey string
|
||||
sendHeader bool
|
||||
wantStatus int
|
||||
wantBody bool
|
||||
}{
|
||||
{"missing key", "", false, http.StatusUnauthorized, true},
|
||||
{"wrong key", "wrong-key", true, http.StatusUnauthorized, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", srv.URL+"/api/v1/servers/localhost/zones", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tt.sendHeader {
|
||||
req.Header.Set("X-API-Key", tt.headerKey)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != tt.wantStatus {
|
||||
t.Fatalf("status = %d, want %d", resp.StatusCode, tt.wantStatus)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantBody := `{"error":"Unauthorized"}` + "\n"
|
||||
if string(body) != wantBody {
|
||||
t.Fatalf("body = %q, want %q", string(body), wantBody)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMiddleware_CorrectKeyPassesThrough(t *testing.T) {
|
||||
// Correct key should reach the handler (which will fail past auth since
|
||||
// there is no Store — but it must NOT be a 401). Unknown server_id also
|
||||
// checked before Store is touched, so use that to prove auth passed.
|
||||
cfg := testConfig()
|
||||
_, r := newTestRouter(cfg)
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
req, err := http.NewRequest("GET", srv.URL+"/api/v1/servers/not-localhost/zones", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-API-Key", cfg.PDNSAPIKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
t.Fatalf("correct key was rejected with 401")
|
||||
}
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d (unknown server_id, proves auth passed)", resp.StatusCode, http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownServerID(t *testing.T) {
|
||||
cfg := testConfig()
|
||||
_, r := newTestRouter(cfg)
|
||||
srv := httptest.NewServer(r)
|
||||
defer srv.Close()
|
||||
|
||||
paths := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{"GET", "/api/v1/servers/bogus/zones"},
|
||||
{"GET", "/api/v1/servers/bogus/zones/example.com."},
|
||||
{"PATCH", "/api/v1/servers/bogus/zones/example.com."},
|
||||
{"PUT", "/api/v1/servers/bogus/zones/example.com./notify"},
|
||||
}
|
||||
|
||||
for _, p := range paths {
|
||||
t.Run(p.method+" "+p.path, func(t *testing.T) {
|
||||
req, err := http.NewRequest(p.method, srv.URL+p.path, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("X-API-Key", cfg.PDNSAPIKey)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
want := `{"error":"Not Found"}` + "\n"
|
||||
if string(body) != want {
|
||||
t.Fatalf("body = %q, want %q", string(body), want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteJSONError(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
writeJSONError(w, http.StatusTeapot, "custom message")
|
||||
|
||||
if w.Code != http.StatusTeapot {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusTeapot)
|
||||
}
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/json" {
|
||||
t.Fatalf("Content-Type = %q, want application/json", ct)
|
||||
}
|
||||
want := `{"error":"custom message"}` + "\n"
|
||||
if w.Body.String() != want {
|
||||
t.Fatalf("body = %q, want %q", w.Body.String(), want)
|
||||
}
|
||||
}
|
||||
93
config.go
Normal file
93
config.go
Normal file
@@ -0,0 +1,93 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
PDNSAPIKey string
|
||||
ListenAddr string
|
||||
MySQLDSN string
|
||||
TablePrefix string
|
||||
PDNSServerID string
|
||||
DefaultTTL int
|
||||
LogLevel string
|
||||
PUID int
|
||||
PGID int
|
||||
}
|
||||
|
||||
// LoadConfig reads configuration exclusively from environment variables.
|
||||
// Missing/invalid required variables are a fatal startup error.
|
||||
func LoadConfig() *Config {
|
||||
c := &Config{
|
||||
ListenAddr: envOrDefault("LISTEN_ADDR", ":3000"),
|
||||
TablePrefix: envOrDefault("TABLE_PREFIX", "coredns_"),
|
||||
PDNSServerID: envOrDefault("PDNS_SERVER_ID", "localhost"),
|
||||
LogLevel: envOrDefault("LOG_LEVEL", "info"),
|
||||
}
|
||||
|
||||
c.PDNSAPIKey = requireEnv("PDNS_API_KEY")
|
||||
c.MySQLDSN = requireEnv("MYSQL_DSN")
|
||||
|
||||
c.DefaultTTL = requirePositiveInt("DEFAULT_TTL", 120)
|
||||
|
||||
switch c.LogLevel {
|
||||
case "debug", "info", "warn", "error":
|
||||
default:
|
||||
fatalf("invalid LOG_LEVEL %q: must be one of debug, info, warn, error", c.LogLevel)
|
||||
}
|
||||
|
||||
c.PUID = requireInt("PUID")
|
||||
c.PGID = requireInt("PGID")
|
||||
if c.PUID == 0 || c.PGID == 0 {
|
||||
fatalf("refusing to serve as root: PUID and PGID must both be non-zero")
|
||||
}
|
||||
|
||||
return c
|
||||
}
|
||||
|
||||
func envOrDefault(key, def string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func requireEnv(key string) string {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
fatalf("missing required environment variable: %s", key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func requireInt(key string) int {
|
||||
v := requireEnv(key)
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
fatalf("invalid %s: %q is not an integer", key, v)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func requirePositiveInt(key string, def int) int {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil || n <= 0 {
|
||||
fatalf("invalid %s: %q is not a positive integer", key, v)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "config error: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
113
config_test.go
Normal file
113
config_test.go
Normal file
@@ -0,0 +1,113 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestLoadConfigDefaults exercises the success path in-process: all required
|
||||
// vars set, defaults left unset, and asserts the documented defaults.
|
||||
func TestLoadConfigDefaults(t *testing.T) {
|
||||
t.Setenv("PDNS_API_KEY", "k")
|
||||
t.Setenv("MYSQL_DSN", "user:pass@tcp(127.0.0.1:3306)/db")
|
||||
t.Setenv("PUID", "1000")
|
||||
t.Setenv("PGID", "1000")
|
||||
os.Unsetenv("LISTEN_ADDR")
|
||||
os.Unsetenv("TABLE_PREFIX")
|
||||
os.Unsetenv("DEFAULT_TTL")
|
||||
os.Unsetenv("PDNS_SERVER_ID")
|
||||
os.Unsetenv("LOG_LEVEL")
|
||||
|
||||
cfg := LoadConfig()
|
||||
|
||||
if cfg.ListenAddr != ":3000" {
|
||||
t.Errorf("ListenAddr = %q, want :3000", cfg.ListenAddr)
|
||||
}
|
||||
if cfg.TablePrefix != "coredns_" {
|
||||
t.Errorf("TablePrefix = %q, want coredns_", cfg.TablePrefix)
|
||||
}
|
||||
if cfg.DefaultTTL != 120 {
|
||||
t.Errorf("DefaultTTL = %d, want 120", cfg.DefaultTTL)
|
||||
}
|
||||
if cfg.PDNSServerID != "localhost" {
|
||||
t.Errorf("PDNSServerID = %q, want localhost", cfg.PDNSServerID)
|
||||
}
|
||||
if cfg.LogLevel != "info" {
|
||||
t.Errorf("LogLevel = %q, want info", cfg.LogLevel)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadConfigFatal exercises the os.Exit(1) paths of LoadConfig via a
|
||||
// re-exec subprocess, since LoadConfig calls os.Exit directly and cannot be
|
||||
// asserted in-process.
|
||||
func TestLoadConfigFatal(t *testing.T) {
|
||||
baseEnv := map[string]string{
|
||||
"PDNS_API_KEY": "k",
|
||||
"MYSQL_DSN": "user:pass@tcp(127.0.0.1:3306)/db",
|
||||
"PUID": "1000",
|
||||
"PGID": "1000",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate map[string]string // overrides/additions to baseEnv; "" value means unset
|
||||
wantInErr string
|
||||
}{
|
||||
{"missing PDNS_API_KEY", map[string]string{"PDNS_API_KEY": ""}, "PDNS_API_KEY"},
|
||||
{"missing MYSQL_DSN", map[string]string{"MYSQL_DSN": ""}, "MYSQL_DSN"},
|
||||
{"missing PUID", map[string]string{"PUID": ""}, "PUID"},
|
||||
{"missing PGID", map[string]string{"PGID": ""}, "PGID"},
|
||||
{"PUID zero rejected", map[string]string{"PUID": "0"}, "root"},
|
||||
{"PGID zero rejected", map[string]string{"PGID": "0"}, "root"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := map[string]string{}
|
||||
for k, v := range baseEnv {
|
||||
env[k] = v
|
||||
}
|
||||
for k, v := range tt.mutate {
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=TestConfigFatalHelper")
|
||||
cmd.Env = []string{"GO_CONFIG_CRASH=1"}
|
||||
for k, v := range env {
|
||||
if v != "" {
|
||||
cmd.Env = append(cmd.Env, k+"="+v)
|
||||
}
|
||||
}
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-zero exit, got success. output: %s", out)
|
||||
}
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *exec.ExitError, got %T: %v", err, err)
|
||||
}
|
||||
if exitErr.ExitCode() == 0 {
|
||||
t.Fatalf("expected non-zero exit code")
|
||||
}
|
||||
if !strings.Contains(string(out), tt.wantInErr) {
|
||||
t.Fatalf("stderr output = %q, want substring %q", out, tt.wantInErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFatalHelper is not a real test; it's invoked as a subprocess by
|
||||
// TestLoadConfigFatal via -test.run, guarded by GO_CONFIG_CRASH so it never
|
||||
// runs (and never touches the DB) under a normal `go test` invocation.
|
||||
func TestConfigFatalHelper(t *testing.T) {
|
||||
if os.Getenv("GO_CONFIG_CRASH") == "" {
|
||||
t.Skip("only runs as a re-exec subprocess")
|
||||
}
|
||||
LoadConfig()
|
||||
}
|
||||
39
db.go
Normal file
39
db.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// OpenDB opens the MySQL pool and pings it with a timeout, mirroring the
|
||||
// Corefile's pool limits. Must be called after the privilege drop.
|
||||
func OpenDB(logger *slog.Logger, dsn string) *sql.DB {
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
logger.Error("failed to open mysql pool", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db.SetMaxOpenConns(10)
|
||||
db.SetMaxIdleConns(5)
|
||||
db.SetConnMaxLifetime(60 * time.Second)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
logger.Error("failed to reach mysql", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("mysql pool ready")
|
||||
return db
|
||||
}
|
||||
41
deploy.sh
Executable file
41
deploy.sh
Executable file
@@ -0,0 +1,41 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
# Convert deployment paths into array
|
||||
ENVIRONMENTS=($DEPLOYMENT_PATHS)
|
||||
|
||||
# Check if the DEPLOYMENT_PATH is not already set
|
||||
if [ -z "${DEPLOYMENT_PATH}" ]; then
|
||||
# Print and ask for deployment environment (if more than one)
|
||||
if [ "${#ENVIRONMENTS[@]}" -gt 1 ]; then
|
||||
for i in "${!ENVIRONMENTS[@]}"; do
|
||||
echo "$i: ${ENVIRONMENTS[$i]}"
|
||||
done
|
||||
read -p "Deployment environment: " DEPLOYMENT_ENVIRONMENT
|
||||
fi
|
||||
if [ -z "${DEPLOYMENT_ENVIRONMENT}" ]; then
|
||||
DEPLOYMENT_ENVIRONMENT=0
|
||||
fi
|
||||
# Select correct path
|
||||
DEPLOYMENT_PATH="${ENVIRONMENTS[$DEPLOYMENT_ENVIRONMENT]}"
|
||||
fi
|
||||
|
||||
# Check if the DEPLOYMENT_VERSION is not already set
|
||||
if [ -z "${DEPLOYMENT_VERSION}" ]; then
|
||||
# Ask for deployment version
|
||||
read -p "Version [latest]: " DEPLOYMENT_VERSION
|
||||
if [ -z "${DEPLOYMENT_VERSION}" ]; then
|
||||
DEPLOYMENT_VERSION=latest
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "${DEPLOYMENT_PATH}"
|
||||
echo "${DEPLOYMENT_VERSION}"
|
||||
|
||||
ssh $DEPLOYMENT_HOST \
|
||||
"cd ${DEPLOYMENT_PATH} && \
|
||||
git pull && \
|
||||
sed -i "s/VERSION=.*/VERSION=${DEPLOYMENT_VERSION}/" .env && \
|
||||
docker compose pull && \
|
||||
docker compose up -d"
|
||||
36
dockerfile
Normal file
36
dockerfile
Normal file
@@ -0,0 +1,36 @@
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
# Stage 1 · go builder
|
||||
ARG GO_BUILDER=golang
|
||||
ARG GO_VERSION=latest
|
||||
FROM ${GO_BUILDER}:${GO_VERSION} AS build
|
||||
|
||||
ARG GO_OS
|
||||
ARG GO_ARCH
|
||||
ARG GIT_HOST
|
||||
ARG REPO_ORG
|
||||
ARG REPO_NAME
|
||||
ARG APP_VERSION
|
||||
|
||||
# Copy the project inside the builder container
|
||||
WORKDIR $GOPATH/src/${GIT_HOST}/$REPO_ORG/$REPO_NAME/
|
||||
COPY . .
|
||||
|
||||
# Build the binary
|
||||
RUN CGO_ENABLED=0 GOOS=${GO_OS} GOARCH=${GO_ARCH} \
|
||||
go build \
|
||||
-installsuffix cgo \
|
||||
-ldflags="-w -s -X 'main.APP_VERSION=${APP_VERSION}' -X 'main.COMMIT_ID=$(git log HEAD --oneline | awk '{print $1}' | head -n1)'" \
|
||||
--o /app
|
||||
|
||||
# Stage 2 · scratch image
|
||||
FROM scratch
|
||||
|
||||
# Copy the necessary stuff from the build stage
|
||||
COPY --from=build /app /app
|
||||
# Copy the certificates - in case of fetches
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/cert.pem
|
||||
|
||||
# Execute the binary
|
||||
ENTRYPOINT ["/app"]
|
||||
151
docs/EVIDENCE.md
Normal file
151
docs/EVIDENCE.md
Normal file
@@ -0,0 +1,151 @@
|
||||
# EVIDENCE.md — phase gate outputs (recorded 2026-07-10)
|
||||
|
||||
Test zone shown as `example.com` — a redaction of the real delegated sub-zone
|
||||
used during the runs (publicly delegated to `1.2.3.4` → CoreDNS `:5053`).
|
||||
To reproduce, that name will need changing: ACME needs a real zone to
|
||||
validate against even on the staging CA (validation follows the public DNS
|
||||
tree), and Let's Encrypt refuses to issue for `example.com` itself by policy
|
||||
(`rejectedIdentifier`). API key redacted as `$PDNS_API_KEY`, server IP as
|
||||
`1.2.3.4` throughout.
|
||||
|
||||
## Phase 1 — Go tests
|
||||
|
||||
```
|
||||
$ go test ./... -count=1
|
||||
ok git.bjphoster.com/source/coredns-powerdns-api-wrapper 1.440s
|
||||
```
|
||||
27 top-level tests (auth, zone list/detail, rrset aggregation + field order,
|
||||
PATCH REPLACE/DELETE, TXT quoting both directions, FQDN normalization, notify,
|
||||
trailing-dot equivalence, 404/422 bodies, config fail-fast, privdrop non-root
|
||||
path). Isolated table `test_pdnsapi_records`; live `coredns_records` row count
|
||||
identical before/after (4/4).
|
||||
|
||||
## Phase 2 — curl on localhost (2026-07-10T21:43 CEST)
|
||||
|
||||
```
|
||||
$ curl -H "X-API-Key: $PDNS_API_KEY" http://127.0.0.1:3000/api/v1/servers/localhost/zones
|
||||
[{"id":"example.com.","name":"example.com.","url":"/api/v1/servers/localhost/zones/example.com.","kind":"Native"}]
|
||||
|
||||
$ curl .../zones/example.com. # zone detail (trimmed)
|
||||
{"id":"example.com.","name":"example.com.","kind":"Native","url":"...","rrsets":[
|
||||
{"name":"example.com.","type":"A","ttl":300,"records":[{"content":"1.2.3.4","disabled":false}]},
|
||||
{"name":"example.com.","type":"NS","ttl":300,"records":[{"content":"ns1.example.com.","disabled":false}]},
|
||||
{"name":"example.com.","type":"SOA","ttl":300,"records":[{"content":"ns1.example.com. hostmaster.example.com. 0 44 55 66 300","disabled":false}]},
|
||||
{"name":"www.example.com.","type":"CNAME","ttl":300,"records":[{"content":"example.com.","disabled":false}]}]}
|
||||
|
||||
$ PATCH REPLACE single token "test-token-123" → HTTP 204
|
||||
$ wrong API key → {"error":"Unauthorized"} HTTP 401
|
||||
$ missing zone (nosuchzone.org.) → {"error":"Not Found"} HTTP 404
|
||||
$ malformed rrset (REPLACE, records: []) → {"error":"REPLACE requires at least one record"} HTTP 422
|
||||
$ PATCH REPLACE two records on _acme-challenge → HTTP 204
|
||||
|
||||
$ SELECT name,ttl,content,record_type FROM coredns_records WHERE name LIKE '_acme%';
|
||||
_acme-challenge 120 {"text":"token-for-example-com"} TXT
|
||||
_acme-challenge 120 {"text":"token-for-www-example-com"} TXT
|
||||
```
|
||||
|
||||
## Phase 3 — dig propagation
|
||||
|
||||
`dig @1.2.3.4` is not reachable from inside this host (hairpin NAT +
|
||||
ISP-level UDP/53 interception, proven by getting an answer from an IP with no
|
||||
DNS server). Verification ran against the same CoreDNS instance directly and,
|
||||
externally, via public resolvers (see Phase 5 prep).
|
||||
|
||||
```
|
||||
$ dig @127.0.0.1 -p 5053 TXT _acme-challenge.example.com +norecurse +noall +answer
|
||||
_acme-challenge.example.com. 120 IN TXT "token-for-example-com"
|
||||
_acme-challenge.example.com. 120 IN TXT "token-for-www-example-com" ← both values of the 2-record REPLACE
|
||||
|
||||
$ PATCH changetype:DELETE → HTTP 204, then:
|
||||
$ dig ... → status: NXDOMAIN; SELECT COUNT(*) ... LIKE '_acme%' → 0
|
||||
```
|
||||
Record writes on an existing zone were visible immediately (no
|
||||
zone_update_interval wait; the interval only gates new-zone visibility).
|
||||
|
||||
## Phase 4 — ngrok public endpoint (https://claude-test-endpoint.ngrok-free.dev)
|
||||
|
||||
```
|
||||
$ zones via ngrok → identical body to Phase 2, HTTP 200
|
||||
$ zone detail via ngrok → identical body to Phase 2, HTTP 200
|
||||
$ PATCH REPLACE via ngrok → HTTP 204; dig @127.0.0.1 -p 5053 served "ngrok-test-token"
|
||||
$ wrong key via ngrok → {"error":"Unauthorized"} HTTP 401
|
||||
$ missing zone via ngrok → {"error":"Not Found"} HTTP 404
|
||||
$ changetype BOGUS → {"error":"unknown or missing changetype"} HTTP 422
|
||||
$ PATCH DELETE (cleanup) → HTTP 204
|
||||
```
|
||||
|
||||
## Phase 5 — acme.sh staging E2E (zone example.com)
|
||||
|
||||
Zone setup: SOA/NS/A(apex)/A(ns1)/CNAME(www) rows inserted; CoreDNS served the
|
||||
new zone after ~10s; external chain verified before the run:
|
||||
|
||||
```
|
||||
$ curl 'https://dns.google/resolve?name=example.com&type=SOA'
|
||||
{"Status":0,...,"Comment":"Response from 1.2.3.4."} ← public chain hits our CoreDNS
|
||||
check-host.net: 10/10 nodes (DE HK IL×2 IN IR MD UA US×2) resolved A=1.2.3.4
|
||||
```
|
||||
|
||||
First attempt failed with `During secondary validation: DNS problem: query
|
||||
timed out` (transient reachability of one LE vantage point); retry succeeded
|
||||
with zero acme.sh-side workarounds:
|
||||
|
||||
```
|
||||
docker run --rm -v ~/acme.sh:/acme.sh -e PDNS_Url=... -e PDNS_ServerId=localhost \
|
||||
-e PDNS_Token=$PDNS_API_KEY -e PDNS_Ttl=120 \
|
||||
neilpang/acme.sh --issue --staging --dns dns_pdns \
|
||||
-d example.com -d www.example.com
|
||||
|
||||
[...] Adding TXT value: GzM5RMGKqZ... for domain: _acme-challenge.example.com
|
||||
[...] The TXT record has been successfully added.
|
||||
[...] Success for domain example.com / www.example.com
|
||||
[...] Cert success.
|
||||
Your cert is in: /acme.sh/example.com_ecc/example.com.cer
|
||||
```
|
||||
|
||||
Server log (request sequence per acme.sh call): GET /zones 200 (_get_root) →
|
||||
GET /zones/{zone} 200 (existing challenges) → PATCH 204 → PUT notify 200;
|
||||
cleanup DELETE PATCHes 204. After cleanup: DB `_acme%` rows = 0, dig NXDOMAIN.
|
||||
|
||||
```
|
||||
$ openssl x509 -noout -issuer -ext subjectAltName -dates
|
||||
issuer=C=US, O=Let's Encrypt, CN=(STAGING) Artificial Amaranth YE1
|
||||
DNS:example.com, DNS:www.example.com
|
||||
```
|
||||
|
||||
## Phase 6 — container image E2E + privilege drop
|
||||
|
||||
```
|
||||
$ APP_VERSION=0.1.0 make docker → git.bjphoster.com/source/coredns-powerdns-api-wrapper:0.1.0 (7.94MB, scratch)
|
||||
|
||||
$ docker run -d --name pdns-api-e2e --network host --env PDNS_API_KEY \
|
||||
--env MYSQL_DSN=... --env PUID=4321 --env PGID=4321 <image>
|
||||
$ docker logs pdns-api-e2e
|
||||
level=INFO msg="privileges dropped" uid=4321 gid=4321
|
||||
level=INFO msg="mysql pool ready"
|
||||
level=INFO msg=listening addr=:3000
|
||||
$ docker top pdns-api-e2e -eo pid,uid,gid,comm ← host view
|
||||
PID UID GID COMMAND
|
||||
446573 4321 4321 app ← NOT root
|
||||
$ curl http://127.0.0.1:3000/healthz → {"status":"ok"}
|
||||
```
|
||||
|
||||
Full Phase 5 re-run against the container (cached LE authorizations
|
||||
deactivated first so dns-01 actually re-ran):
|
||||
|
||||
```
|
||||
[...] Verifying: example.com → Success
|
||||
[...] Verifying: www.example.com → Success
|
||||
[...] Cert success.
|
||||
```
|
||||
Container request log shows the same GET/PATCH/notify sequence; post-run DB
|
||||
`_acme%` rows = 0, dig NXDOMAIN, cert dated Jul 10 19:19:58 2026 GMT with both
|
||||
SANs, staging issuer.
|
||||
|
||||
## Definition-of-done greps
|
||||
|
||||
```
|
||||
$ grep -ri yaml --exclude-dir=.git . → only CLAUDE.md/PLAN.md prose;
|
||||
nothing config-related in Go sources, makefile, or dockerfile
|
||||
(update_child_repos.sh, the last stray reference, has since been deleted)
|
||||
$ gofmt -l . → clean; go vet ./... → clean
|
||||
```
|
||||
146
docs/SEMANTICS.md
Normal file
146
docs/SEMANTICS.md
Normal file
@@ -0,0 +1,146 @@
|
||||
# SEMANTICS.md — CoreDNS (mysql plugin) ↔ PowerDNS API data-shape contract
|
||||
|
||||
Ground truth established empirically on 2026-07-10 against the live stack:
|
||||
MariaDB 12.3.2 (`coredns` db, user `coredns`), CoreDNS with compiled-in `mysql`
|
||||
plugin serving on `:5053`, Corefile `table_prefix coredns_`,
|
||||
`zone_update_interval 120s`, plugin `ttl 300`.
|
||||
|
||||
## 1. Live schema (source of truth: `SHOW CREATE TABLE coredns_records`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE `coredns_records` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`zone` varchar(255) NOT NULL,
|
||||
`name` varchar(255) NOT NULL,
|
||||
`ttl` int(11) DEFAULT NULL,
|
||||
`content` text DEFAULT NULL,
|
||||
`record_type` varchar(255) NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
- `zone`: FQDN **with trailing dot**, lowercase (`example.com.`).
|
||||
- `name`: **relative to zone, no trailing dot**; empty string `''` at the apex;
|
||||
multi-label relative names work (`_p0f.www` served at `_p0f.www.example.com.`
|
||||
— verified). A name stored as FQDN is **not** served (verified).
|
||||
- `content`: JSON object, one row per record value. Type-specific keys (observed
|
||||
live + plugin conventions):
|
||||
|
||||
| record_type | content JSON |
|
||||
|---|---|
|
||||
| TXT | `{"text":"<raw value>"}` |
|
||||
| A | `{"ip":"1.2.3.4"}` |
|
||||
| AAAA | `{"ip":"::1"}` |
|
||||
| CNAME / NS | `{"host":"target.example.com."}` |
|
||||
| SOA | `{"ttl":300,"mbox":"hostmaster.example.com.","ns":"ns1.example.com.","refresh":44,"retry":55,"expire":66}` |
|
||||
|
||||
- `ttl`: integer seconds, honored as served TTL (verified: row ttl 120 served
|
||||
as 120).
|
||||
|
||||
## 2. Empirical CoreDNS read semantics (all verified with dig against the live instance)
|
||||
|
||||
| Question | Verified answer |
|
||||
|---|---|
|
||||
| Multiple TXT values on one name | **Multiple rows**, same `(zone,name,record_type)`, one `{"text":...}` each. Both values served. |
|
||||
| JSON array inside one row (`{"text":["a","b"]}`) | **Not served** (empty answer). Never write this shape. |
|
||||
| TXT quoting in DB | Store the **raw, unquoted** token. CoreDNS adds wire quoting: `{"text":"tok"}` → `"tok"` on the wire. Storing `\"tok\"` yields literal quotes inside the value (`"\"tok\""` on the wire) — wrong for ACME. |
|
||||
| Query case sensitivity | Case-insensitive (`_P0A.EXAMPLE.COM` matched row `_p0a`). Store names lowercase. |
|
||||
| Write visibility | Record inserts/deletes on an **existing** zone are visible **immediately** (record lookups hit the DB per query). `zone_update_interval` (120s) only gates the cached **zone list**, i.e. only newly-created zones are delayed. No CoreDNS restart, no dnssleep needed for TXT changes on `example.com.`. |
|
||||
|
||||
## 3. PowerDNS API semantics (from `dns_pdns.sh` in the `neilpang/acme.sh` image + pdns API docs)
|
||||
|
||||
rrset model: `{"name": "<fqdn.>", "type": "TXT", "ttl": N, "changetype": "REPLACE"|"DELETE", "records": [{"content": "\"token\"", "disabled": false}]}`
|
||||
|
||||
- `REPLACE` replaces the **entire** record set for `(name, type)`.
|
||||
- `DELETE` removes the entire set; acme.sh sends it **without `ttl` and without
|
||||
`records`** — validation must accept that.
|
||||
- TXT `content` on the API is **quoted**: `"\"token\""` in the JSON body.
|
||||
- acme.sh specifics that the responses must satisfy:
|
||||
- `_get_root` substring-matches `"name":"<candidate>."` against the
|
||||
normalized zone-list JSON → every zone `name` must be FQDN with trailing
|
||||
dot.
|
||||
- Zone id in URLs arrives **with** trailing dot (`/zones/example.com.`);
|
||||
accept it also without a dot and with URL-encoded dots, like PowerDNS.
|
||||
- `_list_existingchallenges` regex-scrapes the zone-detail JSON: within each
|
||||
rrset object, `"name"` must appear **before** `"records"`, names must be
|
||||
FQDN with trailing dot, and TXT contents must be quoted
|
||||
(`"content":"\"tok\""`). Use ordered Go structs, never maps.
|
||||
- `PUT /zones/{id}/notify` is called after every PATCH and must succeed.
|
||||
|
||||
## 4. Mapping table (every handler implements exactly this)
|
||||
|
||||
Notation: `zone` = canonical zone FQDN with trailing dot; `rel(name)` =
|
||||
lowercased rrset name with the zone suffix and trailing dot stripped (apex →
|
||||
`''`); `unq(c)` = TXT content with one leading and one trailing `"` removed;
|
||||
`q(t)` = `"` + t + `"`. `{p}` = `TABLE_PREFIX` (default `coredns_`).
|
||||
|
||||
### GET /api/v1/servers/{sid}/zones
|
||||
|
||||
```sql
|
||||
SELECT DISTINCT zone FROM {p}records ORDER BY zone;
|
||||
```
|
||||
→ `[{"id":"<zone>","name":"<zone>","url":"/api/v1/servers/{sid}/zones/<zone>"}]`
|
||||
(zone already carries the trailing dot; emit as-is, plus `kind: "Native"` for
|
||||
shape parity).
|
||||
|
||||
### GET /api/v1/servers/{sid}/zones/{zone_id}
|
||||
|
||||
Canonicalize `zone_id` (URL-decode, lowercase, ensure trailing dot). Then:
|
||||
|
||||
```sql
|
||||
SELECT name, ttl, content, record_type FROM {p}records WHERE zone = ?;
|
||||
```
|
||||
Zone unknown (0 rows) → 404. Group rows by `(name, record_type)` → one rrset
|
||||
each: `name` = `rel==''` ? zone : `rel + "." + zone`; `ttl` = the rows' ttl
|
||||
(first non-NULL, else plugin default 300); `records[i].content` per type:
|
||||
|
||||
| record_type | rrset content |
|
||||
|---|---|
|
||||
| TXT | `q(content.text)` |
|
||||
| A / AAAA | `content.ip` |
|
||||
| CNAME / NS | `content.host` |
|
||||
| SOA | `content.ns + " " + content.mbox + " 0 " + refresh + " " + retry + " " + expire + " " + (content.ttl or 300)` (serial not stored; emit 0) |
|
||||
| other/unparsable | raw `content` string (never omit the row) |
|
||||
|
||||
`records[i].disabled` = `false` always. Response: `{"id","name","kind":"Native","url","rrsets":[...]}` with struct field order `name`, `type`, `ttl`, `records`.
|
||||
|
||||
### PATCH /api/v1/servers/{sid}/zones/{zone_id} (TXT via acme.sh; generic for others)
|
||||
|
||||
For each rrset, canonicalize `name` → `rel`. All statements for one PATCH run
|
||||
in **one transaction**; any error rolls back everything.
|
||||
|
||||
`changetype: REPLACE` (requires `type`, `ttl` ≥ 0, ≥1 record):
|
||||
```sql
|
||||
DELETE FROM {p}records WHERE zone = ? AND name = ? AND record_type = ?;
|
||||
-- then, one INSERT PER RECORD (multi-value = multiple rows, §2):
|
||||
INSERT INTO {p}records (zone, name, ttl, content, record_type)
|
||||
VALUES (?, ?, ?, ?, ?);
|
||||
```
|
||||
TXT insert content = `{"text": unq(api content)}` (JSON-marshalled, so inner
|
||||
quotes/backslashes stay valid JSON). A/AAAA → `{"ip": c}`; CNAME/NS →
|
||||
`{"host": c}`. TTL: from rrset; if absent/0 use `DEFAULT_TTL` env (120).
|
||||
|
||||
`changetype: DELETE` (no `ttl`/`records` required):
|
||||
```sql
|
||||
DELETE FROM {p}records WHERE zone = ? AND name = ? AND record_type = ?;
|
||||
```
|
||||
|
||||
Success → `204 No Content` (empty body). Unknown zone → 404. Malformed
|
||||
(unknown changetype, empty/missing name or type, REPLACE without records) →
|
||||
`422` with PowerDNS error body.
|
||||
|
||||
### PUT /api/v1/servers/{sid}/zones/{zone_id}/notify
|
||||
|
||||
No-op (CoreDNS reads the DB live). Zone must exist (else 404). Return
|
||||
`200` + `{"result":"Notification queued"}`.
|
||||
|
||||
## 5. Cross-cutting
|
||||
|
||||
- Auth: every `/api/v1/*` route requires `X-API-Key` equal to `PDNS_API_KEY`;
|
||||
otherwise `401` + error body. `/healthz` is unauthenticated.
|
||||
- Error body, PowerDNS shape: `{"error":"<message>"}` (400/401/404/422/500).
|
||||
- Unknown `{sid}` (≠ `PDNS_SERVER_ID`) → 404.
|
||||
- All responses `Content-Type: application/json` (204 has no body).
|
||||
- The apex TXT case (`name == zone`) maps to `rel = ''` — same SQL applies.
|
||||
- ACME tokens are base64url (`A-Za-z0-9_-`), no escaping hazards; the JSON
|
||||
marshaller handles anything else.
|
||||
10
go.mod
Normal file
10
go.mod
Normal file
@@ -0,0 +1,10 @@
|
||||
module git.bjphoster.com/source/coredns-powerdns-api-wrapper
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/gorilla/mux v1.8.1
|
||||
)
|
||||
|
||||
require filippo.io/edwards25519 v1.1.1 // indirect
|
||||
6
go.sum
Normal file
6
go.sum
Normal file
@@ -0,0 +1,6 @@
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
30
healthz.go
Normal file
30
healthz.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// handleHealthz pings the DB with a short timeout and reports status. No
|
||||
// auth, no data exposed beyond ok/unhealthy.
|
||||
func (s *WebServer) handleHealthz(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
if err := s.DB.PingContext(ctx); err != nil {
|
||||
s.Logger.Error("healthz ping failed", "error", err)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "unhealthy"})
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||
}
|
||||
14
initialize.sh
Executable file
14
initialize.sh
Executable file
@@ -0,0 +1,14 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f ".vars" ]; then
|
||||
cp vars.example .vars
|
||||
fi
|
||||
|
||||
if [ ! -f "go.mod" ]; then
|
||||
module_name=$(git remote get-url origin | sed 's|^https://||')
|
||||
go mod init "$module_name"
|
||||
fi
|
||||
60
logging.go
Normal file
60
logging.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewLogger builds the process-wide structured logger, text output to
|
||||
// stdout, level driven by LOG_LEVEL.
|
||||
func NewLogger(level string) *slog.Logger {
|
||||
var lvl slog.Level
|
||||
switch level {
|
||||
case "debug":
|
||||
lvl = slog.LevelDebug
|
||||
case "warn":
|
||||
lvl = slog.LevelWarn
|
||||
case "error":
|
||||
lvl = slog.LevelError
|
||||
default:
|
||||
lvl = slog.LevelInfo
|
||||
}
|
||||
|
||||
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
|
||||
return slog.New(handler)
|
||||
}
|
||||
|
||||
// statusRecorder captures the status code written by downstream handlers so
|
||||
// the logging middleware can report it.
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (r *statusRecorder) WriteHeader(status int) {
|
||||
r.status = status
|
||||
r.ResponseWriter.WriteHeader(status)
|
||||
}
|
||||
|
||||
// loggingMiddleware logs method, path, status, and duration at info level
|
||||
// for every request.
|
||||
func (s *WebServer) loggingMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
|
||||
next.ServeHTTP(rec, r)
|
||||
|
||||
s.Logger.Info("request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", rec.status,
|
||||
"duration", time.Since(start),
|
||||
)
|
||||
})
|
||||
}
|
||||
52
main.go
Normal file
52
main.go
Normal file
@@ -0,0 +1,52 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var APP_VERSION string = "latest"
|
||||
var COMMIT_ID string = "undefined"
|
||||
var ws *WebServer
|
||||
|
||||
func main() {
|
||||
// Initialize the WebService structure (parses env config, sets up logger)
|
||||
ws = new(WebServer)
|
||||
ws.Initialize()
|
||||
|
||||
// Drop root privileges before any listener, goroutine, or DB connection
|
||||
dropPrivileges(ws.Logger, ws.Config.PUID, ws.Config.PGID)
|
||||
|
||||
// Create a channel to receive the OS signals
|
||||
sc := make(chan os.Signal, 1)
|
||||
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
// Open the MySQL pool (after the privilege drop) and ping it
|
||||
ws.DB = OpenDB(ws.Logger, ws.Config.MySQLDSN)
|
||||
ws.Store = NewStore(ws.DB, ws.Config.TablePrefix)
|
||||
|
||||
// Start the WebService in a separate goroutine
|
||||
go ws.Start()
|
||||
|
||||
// Wait for a signal
|
||||
<-sc
|
||||
fmt.Println("Shutting down...")
|
||||
|
||||
// Create a context with a timeout for graceful shutdown
|
||||
shCtx, shCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shCancel()
|
||||
|
||||
// Shutdown the HTTP server
|
||||
err := ws.HTTPServer.Shutdown(shCtx)
|
||||
if err != nil {
|
||||
fmt.Printf("Server shutdown error: %s", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
77
makefile
Normal file
77
makefile
Normal file
@@ -0,0 +1,77 @@
|
||||
#!make
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
include .vars
|
||||
|
||||
default: version
|
||||
|
||||
clean:
|
||||
if [ "$$(docker images "$${CONTAINER_ORG}/$${CONTAINER_IMAGE}" --format "{{.Repository}}:{{.Tag}}")" != "" ]; then \
|
||||
docker image rm $$(docker images "$${CONTAINER_ORG}/$${CONTAINER_IMAGE}" --all --format "{{.Repository}}:{{.Tag}}"); \
|
||||
fi
|
||||
|
||||
docker: clean
|
||||
docker build \
|
||||
--build-arg GO_BUILDER=$${GO_BUILDER} \
|
||||
--build-arg GO_VERSION=$${GO_VERSION} \
|
||||
--build-arg GO_OS=$${GO_OS} \
|
||||
--build-arg GO_ARCH=$${GO_ARCH} \
|
||||
--build-arg GIT_HOST=$${GIT_HOST} \
|
||||
--build-arg REPO_ORG=$${REPO_ORG} \
|
||||
--build-arg REPO_NAME=$${REPO_NAME} \
|
||||
--build-arg APP_VERSION=$${APP_VERSION} \
|
||||
-t $${CONTAINER_ORG}/$${CONTAINER_IMAGE}:$${APP_VERSION} .; \
|
||||
if [ "$$(docker images --filter "dangling=true" --quiet --no-trunc)" != "" ]; then \
|
||||
docker image rm $$(docker images --filter "dangling=true" --quiet --no-trunc); \
|
||||
fi
|
||||
|
||||
dockerpush:
|
||||
docker push \
|
||||
$${CONTAINER_ORG}/$${CONTAINER_IMAGE}:$${APP_VERSION}
|
||||
|
||||
deploy:
|
||||
bash -c "./deploy.sh"
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
version:
|
||||
bash -c "./version.sh"
|
||||
|
||||
run:
|
||||
docker run \
|
||||
--rm \
|
||||
--tty \
|
||||
--interactive \
|
||||
--publish $${CONTAINER_IP}:$${CONTAINER_PORT}:3000 \
|
||||
--workdir /go/src/$${GIT_HOST}/$${REPO_ORG}/$${REPO_NAME} \
|
||||
--volume $(shell pwd):/go/src/$${GIT_HOST}/$${REPO_ORG}/$${REPO_NAME} \
|
||||
--env PDNS_API_KEY \
|
||||
--env MYSQL_DSN \
|
||||
--env TABLE_PREFIX \
|
||||
--env PDNS_SERVER_ID \
|
||||
--env DEFAULT_TTL \
|
||||
--env LOG_LEVEL \
|
||||
--env LISTEN_ADDR \
|
||||
--env PUID \
|
||||
--env PGID \
|
||||
$${GO_BUILDER}:$${GO_VERSION} \
|
||||
go run .
|
||||
|
||||
dockerrun:
|
||||
docker run \
|
||||
--rm \
|
||||
--tty \
|
||||
--interactive \
|
||||
--publish $${CONTAINER_IP}:$${CONTAINER_PORT}:3000 \
|
||||
--env PDNS_API_KEY \
|
||||
--env MYSQL_DSN \
|
||||
--env TABLE_PREFIX \
|
||||
--env PDNS_SERVER_ID \
|
||||
--env DEFAULT_TTL \
|
||||
--env LOG_LEVEL \
|
||||
--env LISTEN_ADDR \
|
||||
--env PUID \
|
||||
--env PGID \
|
||||
$${CONTAINER_ORG}/$${CONTAINER_IMAGE}:$${APP_VERSION}
|
||||
411
pdns.go
Normal file
411
pdns.go
Normal file
@@ -0,0 +1,411 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// pdnsZone is the zone-list entry shape.
|
||||
type pdnsZone struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// pdnsRecord is one record within an rrset. Field order matches PowerDNS.
|
||||
type pdnsRecord struct {
|
||||
Content string `json:"content"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// pdnsRRset is one rrset. Field order MUST be name, type, ttl, records —
|
||||
// acme.sh regex-scrapes the zone-detail JSON expecting "name" before
|
||||
// "records".
|
||||
type pdnsRRset struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int `json:"ttl"`
|
||||
Records []pdnsRecord `json:"records"`
|
||||
}
|
||||
|
||||
// pdnsZoneDetail is the zone-detail response shape.
|
||||
type pdnsZoneDetail struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
URL string `json:"url"`
|
||||
RRsets []pdnsRRset `json:"rrsets"`
|
||||
}
|
||||
|
||||
// checkServerID returns false and writes a 404 if server_id doesn't match
|
||||
// the configured PDNS_SERVER_ID. Cross-cutting per SEMANTICS §5.
|
||||
func (s *WebServer) checkServerID(w http.ResponseWriter, r *http.Request) bool {
|
||||
if mux.Vars(r)["server_id"] != s.Config.PDNSServerID {
|
||||
writeJSONError(w, http.StatusNotFound, "Not Found")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// canonicalZone lowercases and ensures a trailing dot, per SEMANTICS §4.
|
||||
func canonicalZone(zoneID string) string {
|
||||
zone := strings.ToLower(zoneID)
|
||||
if !strings.HasSuffix(zone, ".") {
|
||||
zone += "."
|
||||
}
|
||||
return zone
|
||||
}
|
||||
|
||||
// handleListZones implements GET /api/v1/servers/{server_id}/zones.
|
||||
func (s *WebServer) handleListZones(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkServerID(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
zones, err := s.Store.ListZones(r.Context())
|
||||
if err != nil {
|
||||
s.Logger.Error("list zones failed", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
serverID := mux.Vars(r)["server_id"]
|
||||
out := make([]pdnsZone, 0, len(zones))
|
||||
for _, zone := range zones {
|
||||
out = append(out, pdnsZone{
|
||||
ID: zone,
|
||||
Name: zone,
|
||||
URL: "/api/v1/servers/" + serverID + "/zones/" + zone,
|
||||
Kind: "Native",
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
// soaContent is the type-specific content shape for SOA rows.
|
||||
type soaContent struct {
|
||||
TTL int `json:"ttl"`
|
||||
Mbox string `json:"mbox"`
|
||||
NS string `json:"ns"`
|
||||
Refresh int `json:"refresh"`
|
||||
Retry int `json:"retry"`
|
||||
Expire int `json:"expire"`
|
||||
}
|
||||
|
||||
// rrsetContent renders the pdns rrset "content" string for one row per
|
||||
// SEMANTICS §4's mapping table. Unparsable/unknown content is emitted raw,
|
||||
// never dropped.
|
||||
func rrsetContent(recordType, content string) string {
|
||||
switch recordType {
|
||||
case "TXT":
|
||||
var c struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &c); err != nil {
|
||||
return content
|
||||
}
|
||||
return `"` + c.Text + `"`
|
||||
case "A", "AAAA":
|
||||
var c struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &c); err != nil {
|
||||
return content
|
||||
}
|
||||
return c.IP
|
||||
case "CNAME", "NS":
|
||||
var c struct {
|
||||
Host string `json:"host"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &c); err != nil {
|
||||
return content
|
||||
}
|
||||
return c.Host
|
||||
case "SOA":
|
||||
var c soaContent
|
||||
if err := json.Unmarshal([]byte(content), &c); err != nil {
|
||||
return content
|
||||
}
|
||||
ttl := c.TTL
|
||||
if ttl == 0 {
|
||||
ttl = 300
|
||||
}
|
||||
return c.NS + " " + c.Mbox + " 0 " +
|
||||
strconv.Itoa(c.Refresh) + " " + strconv.Itoa(c.Retry) + " " +
|
||||
strconv.Itoa(c.Expire) + " " + strconv.Itoa(ttl)
|
||||
default:
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// rrsetKey groups rows by (name, record_type).
|
||||
type rrsetKey struct {
|
||||
name string
|
||||
recordType string
|
||||
}
|
||||
|
||||
// handleZoneDetail implements GET /api/v1/servers/{server_id}/zones/{zone_id}.
|
||||
func (s *WebServer) handleZoneDetail(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkServerID(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
zone := canonicalZone(vars["zone_id"])
|
||||
|
||||
records, err := s.Store.GetZoneRecords(r.Context(), zone)
|
||||
if err != nil {
|
||||
s.Logger.Error("get zone records failed", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if len(records) == 0 {
|
||||
writeJSONError(w, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
groups := make(map[rrsetKey][]Record)
|
||||
var order []rrsetKey
|
||||
for _, rec := range records {
|
||||
key := rrsetKey{name: rec.Name, recordType: rec.RecordType}
|
||||
if _, ok := groups[key]; !ok {
|
||||
order = append(order, key)
|
||||
}
|
||||
groups[key] = append(groups[key], rec)
|
||||
}
|
||||
sort.Slice(order, func(i, j int) bool {
|
||||
if order[i].name != order[j].name {
|
||||
return order[i].name < order[j].name
|
||||
}
|
||||
return order[i].recordType < order[j].recordType
|
||||
})
|
||||
|
||||
rrsets := make([]pdnsRRset, 0, len(order))
|
||||
for _, key := range order {
|
||||
rows := groups[key]
|
||||
fqdn := zone
|
||||
if key.name != "" {
|
||||
fqdn = key.name + "." + zone
|
||||
}
|
||||
|
||||
ttl := 300
|
||||
for _, rec := range rows {
|
||||
if rec.TTL.Valid {
|
||||
ttl = int(rec.TTL.Int64)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
recs := make([]pdnsRecord, 0, len(rows))
|
||||
for _, rec := range rows {
|
||||
recs = append(recs, pdnsRecord{
|
||||
Content: rrsetContent(rec.RecordType, rec.Content),
|
||||
Disabled: false,
|
||||
})
|
||||
}
|
||||
|
||||
rrsets = append(rrsets, pdnsRRset{
|
||||
Name: fqdn,
|
||||
Type: key.recordType,
|
||||
TTL: ttl,
|
||||
Records: recs,
|
||||
})
|
||||
}
|
||||
|
||||
serverID := vars["server_id"]
|
||||
detail := pdnsZoneDetail{
|
||||
ID: zone,
|
||||
Name: zone,
|
||||
Kind: "Native",
|
||||
URL: "/api/v1/servers/" + serverID + "/zones/" + zone,
|
||||
RRsets: rrsets,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(detail)
|
||||
}
|
||||
|
||||
// patchRecord is one record entry within a PATCH rrset.
|
||||
type patchRecord struct {
|
||||
Content string `json:"content"`
|
||||
Disabled bool `json:"disabled"`
|
||||
}
|
||||
|
||||
// patchRRset is one rrset entry within a PATCH request body.
|
||||
type patchRRset struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
TTL int `json:"ttl"`
|
||||
ChangeType string `json:"changetype"`
|
||||
Records []patchRecord `json:"records"`
|
||||
}
|
||||
|
||||
// patchRequest is the PATCH zone body.
|
||||
type patchRequest struct {
|
||||
RRsets []patchRRset `json:"rrsets"`
|
||||
}
|
||||
|
||||
// unq strips exactly one leading and one trailing double quote, if present.
|
||||
func unq(c string) string {
|
||||
if len(c) >= 1 && strings.HasPrefix(c, `"`) {
|
||||
c = c[1:]
|
||||
}
|
||||
if len(c) >= 1 && strings.HasSuffix(c, `"`) {
|
||||
c = c[:len(c)-1]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// recordContent marshals the type-specific content JSON for storage, per
|
||||
// SEMANTICS §4. supportedTypes lists what this handler accepts.
|
||||
func recordContent(recordType, apiContent string) (string, bool) {
|
||||
switch recordType {
|
||||
case "TXT":
|
||||
b, err := json.Marshal(map[string]string{"text": unq(apiContent)})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
case "A", "AAAA":
|
||||
b, err := json.Marshal(map[string]string{"ip": apiContent})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
case "CNAME", "NS":
|
||||
b, err := json.Marshal(map[string]string{"host": apiContent})
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return string(b), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// handleZonePatch implements PATCH /api/v1/servers/{server_id}/zones/{zone_id}.
|
||||
func (s *WebServer) handleZonePatch(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkServerID(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
zone := canonicalZone(vars["zone_id"])
|
||||
|
||||
exists, err := s.Store.ZoneExists(r.Context(), zone)
|
||||
if err != nil {
|
||||
s.Logger.Error("zone exists check failed", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
writeJSONError(w, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
var body patchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSONError(w, http.StatusUnprocessableEntity, "malformed JSON body")
|
||||
return
|
||||
}
|
||||
|
||||
changes := make([]RRsetChange, 0, len(body.RRsets))
|
||||
for _, rrset := range body.RRsets {
|
||||
if rrset.ChangeType != "REPLACE" && rrset.ChangeType != "DELETE" {
|
||||
writeJSONError(w, http.StatusUnprocessableEntity, "unknown or missing changetype")
|
||||
return
|
||||
}
|
||||
if rrset.Name == "" || rrset.Type == "" {
|
||||
writeJSONError(w, http.StatusUnprocessableEntity, "missing name or type")
|
||||
return
|
||||
}
|
||||
|
||||
name := strings.ToLower(rrset.Name)
|
||||
if !strings.HasSuffix(name, ".") {
|
||||
name += "."
|
||||
}
|
||||
if name != zone && !strings.HasSuffix(name, "."+zone) {
|
||||
writeJSONError(w, http.StatusUnprocessableEntity, "name is not part of zone")
|
||||
return
|
||||
}
|
||||
rel := strings.TrimSuffix(name, zone)
|
||||
rel = strings.TrimSuffix(rel, ".")
|
||||
|
||||
change := RRsetChange{
|
||||
Zone: zone,
|
||||
Name: rel,
|
||||
RecordType: rrset.Type,
|
||||
ChangeType: rrset.ChangeType,
|
||||
}
|
||||
|
||||
if rrset.ChangeType == "REPLACE" {
|
||||
if len(rrset.Records) == 0 {
|
||||
writeJSONError(w, http.StatusUnprocessableEntity, "REPLACE requires at least one record")
|
||||
return
|
||||
}
|
||||
|
||||
ttl := rrset.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = s.Config.DefaultTTL
|
||||
}
|
||||
change.TTL = ttl
|
||||
|
||||
contents := make([]string, 0, len(rrset.Records))
|
||||
for _, rec := range rrset.Records {
|
||||
content, ok := recordContent(rrset.Type, rec.Content)
|
||||
if !ok {
|
||||
writeJSONError(w, http.StatusUnprocessableEntity, "unsupported record type")
|
||||
return
|
||||
}
|
||||
contents = append(contents, content)
|
||||
}
|
||||
change.Contents = contents
|
||||
}
|
||||
|
||||
changes = append(changes, change)
|
||||
}
|
||||
|
||||
if err := s.Store.ApplyRRsets(r.Context(), changes); err != nil {
|
||||
s.Logger.Error("apply rrsets failed", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// handleZoneNotify implements PUT /api/v1/servers/{server_id}/zones/{zone_id}/notify.
|
||||
func (s *WebServer) handleZoneNotify(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.checkServerID(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
zone := canonicalZone(vars["zone_id"])
|
||||
|
||||
exists, err := s.Store.ZoneExists(r.Context(), zone)
|
||||
if err != nil {
|
||||
s.Logger.Error("zone exists check failed", "error", err)
|
||||
writeJSONError(w, http.StatusInternalServerError, "internal error")
|
||||
return
|
||||
}
|
||||
if !exists {
|
||||
writeJSONError(w, http.StatusNotFound, "Not Found")
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(map[string]string{"result": "Notification queued"})
|
||||
}
|
||||
492
pdns_test.go
Normal file
492
pdns_test.go
Normal file
@@ -0,0 +1,492 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// dbTestServer builds a WebServer wired to the isolated test_pdnsapi_
|
||||
// prefixed Store, with the real Routes()/middleware stack, and reseeds the
|
||||
// known example.com. fixture before returning. Callers get a fresh httptest
|
||||
// server per test.
|
||||
func dbTestServer(t *testing.T) (*httptest.Server, *Config) {
|
||||
t.Helper()
|
||||
createTestSchema(t)
|
||||
db := requireDB(t)
|
||||
reseed(t, db, exampleComSeed())
|
||||
|
||||
cfg := testConfig()
|
||||
ws := &WebServer{
|
||||
Config: cfg,
|
||||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
DB: db,
|
||||
Store: NewStore(db, testTablePrefix),
|
||||
}
|
||||
|
||||
r := newRouter(ws)
|
||||
srv := httptest.NewServer(r)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, cfg
|
||||
}
|
||||
|
||||
func newRouter(ws *WebServer) http.Handler {
|
||||
router := mux.NewRouter()
|
||||
router.Use(ws.loggingMiddleware)
|
||||
ws.Routes(router)
|
||||
return router
|
||||
}
|
||||
|
||||
// doReq issues an authenticated request against srv and returns the response
|
||||
// with the body read into memory (so callers don't need to remember Close).
|
||||
func doReq(t *testing.T, srv *httptest.Server, apiKey, method, path string, body []byte) (*http.Response, []byte) {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if apiKey != "" {
|
||||
req.Header.Set("X-API-Key", apiKey)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
got, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return resp, got
|
||||
}
|
||||
|
||||
// --- healthz -------------------------------------------------------------
|
||||
|
||||
func TestHealthzReachableWithoutKey(t *testing.T) {
|
||||
// /healthz must be reachable without X-API-Key, and must succeed given a
|
||||
// live DB handle (the shape it always has when actually serving —
|
||||
// main.go exits before serving if OpenDB fails).
|
||||
srv, _ := dbTestServer(t)
|
||||
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized {
|
||||
t.Fatalf("/healthz should not require auth, got 401")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
|
||||
var body map[string]string
|
||||
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Fatalf("status field = %q, want ok", body["status"])
|
||||
}
|
||||
}
|
||||
|
||||
// --- zone listing --------------------------------------------------------
|
||||
|
||||
func TestHandleListZones(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var zones []pdnsZone
|
||||
if err := json.Unmarshal(body, &zones); err != nil {
|
||||
t.Fatalf("unmarshal: %v; body = %s", err, body)
|
||||
}
|
||||
if len(zones) != 1 {
|
||||
t.Fatalf("got %d zones, want 1: %+v", len(zones), zones)
|
||||
}
|
||||
z := zones[0]
|
||||
if z.ID != "example.com." || z.Name != "example.com." {
|
||||
t.Fatalf("zone = %+v, want id/name example.com. with trailing dot", z)
|
||||
}
|
||||
wantURL := "/api/v1/servers/localhost/zones/example.com."
|
||||
if z.URL != wantURL {
|
||||
t.Fatalf("url = %q, want %q", z.URL, wantURL)
|
||||
}
|
||||
}
|
||||
|
||||
// --- zone detail: rrset shape --------------------------------------------
|
||||
|
||||
func TestHandleZoneDetail_RRsetAggregation(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var detail pdnsZoneDetail
|
||||
if err := json.Unmarshal(body, &detail); err != nil {
|
||||
t.Fatalf("unmarshal: %v; body = %s", err, body)
|
||||
}
|
||||
|
||||
// multi TXT rows (two rows, same name/type) must aggregate into ONE
|
||||
// rrset with both records.
|
||||
var multiTXT *pdnsRRset
|
||||
for i := range detail.RRsets {
|
||||
if detail.RRsets[i].Type == "TXT" && strings.HasPrefix(detail.RRsets[i].Name, "multi.") {
|
||||
multiTXT = &detail.RRsets[i]
|
||||
}
|
||||
}
|
||||
if multiTXT == nil {
|
||||
t.Fatalf("no multi.example.com. TXT rrset found in %+v", detail.RRsets)
|
||||
}
|
||||
if len(multiTXT.Records) != 2 {
|
||||
t.Fatalf("multi TXT rrset has %d records, want 2: %+v", len(multiTXT.Records), multiTXT.Records)
|
||||
}
|
||||
contents := map[string]bool{}
|
||||
for _, r := range multiTXT.Records {
|
||||
contents[r.Content] = true
|
||||
}
|
||||
if !contents[`"first"`] || !contents[`"second"`] {
|
||||
t.Fatalf("multi TXT contents = %v, want both \"first\" and \"second\"", contents)
|
||||
}
|
||||
|
||||
// apex rows (name == '') must appear as the zone FQDN.
|
||||
var apexFound bool
|
||||
for _, rr := range detail.RRsets {
|
||||
if rr.Type == "SOA" {
|
||||
apexFound = true
|
||||
if rr.Name != "example.com." {
|
||||
t.Fatalf("apex SOA rrset name = %q, want example.com.", rr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !apexFound {
|
||||
t.Fatalf("no SOA rrset found in %+v", detail.RRsets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleZoneDetail_FieldOrder(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
_, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
|
||||
|
||||
// acme.sh regex-scrapes expecting "name" before "records" within each
|
||||
// rrset object. Assert on the raw body, not a re-marshalled map (which
|
||||
// would lose field order).
|
||||
nameIdx := strings.Index(string(body), `"name":"www.example.com."`)
|
||||
if nameIdx == -1 {
|
||||
t.Fatalf("did not find www.example.com. rrset name in body: %s", body)
|
||||
}
|
||||
rest := string(body)[nameIdx:]
|
||||
typeIdx := strings.Index(rest, `"type"`)
|
||||
ttlIdx := strings.Index(rest, `"ttl"`)
|
||||
recordsIdx := strings.Index(rest, `"records"`)
|
||||
if typeIdx == -1 || ttlIdx == -1 || recordsIdx == -1 {
|
||||
t.Fatalf("missing expected fields after name in: %s", rest)
|
||||
}
|
||||
if !(typeIdx < ttlIdx && ttlIdx < recordsIdx) {
|
||||
t.Fatalf("field order wrong: type@%d ttl@%d records@%d, want type<ttl<records", typeIdx, ttlIdx, recordsIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleZoneDetail_TrailingDotEquivalence(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
_, bodyWithDot := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
|
||||
_, bodyNoDot := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com", nil)
|
||||
|
||||
if !bytes.Equal(bodyWithDot, bodyNoDot) {
|
||||
t.Fatalf("zone detail differs with/without trailing dot:\nwith: %s\nwithout: %s", bodyWithDot, bodyNoDot)
|
||||
}
|
||||
}
|
||||
|
||||
// --- TXT quoting both directions -----------------------------------------
|
||||
|
||||
func TestTXTQuotingDBToAPI(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
_, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/example.com.", nil)
|
||||
var detail pdnsZoneDetail
|
||||
if err := json.Unmarshal(body, &detail); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, rr := range detail.RRsets {
|
||||
if rr.Type == "TXT" {
|
||||
for _, rec := range rr.Records {
|
||||
if rec.Content == `"first"` || rec.Content == `"second"` {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("expected quoted TXT content \"first\"/\"second\" in %+v", detail.RRsets)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTXTQuotingAPIToDB(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
db := requireDB(t)
|
||||
|
||||
patchBody := `{"rrsets":[{"name":"tok.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"tok\"","disabled":false}]}]}`
|
||||
resp, respBody := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, respBody)
|
||||
}
|
||||
|
||||
var content string
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
|
||||
"example.com.", "tok", "TXT").Scan(&content)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if content != `{"text":"tok"}` {
|
||||
t.Fatalf("stored content = %q, want {\"text\":\"tok\"}", content)
|
||||
}
|
||||
}
|
||||
|
||||
// --- PATCH REPLACE ---------------------------------------------------------
|
||||
|
||||
func TestHandlePatchReplace(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
db := requireDB(t)
|
||||
|
||||
patchBody := `{"rrsets":[{"name":"www.example.com.","type":"CNAME","ttl":60,"changetype":"REPLACE","records":[{"content":"a.example.com.","disabled":false},{"content":"b.example.com.","disabled":false}]}]}`
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
if len(body) != 0 {
|
||||
t.Fatalf("expected empty body on 204, got %q", body)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(context.Background(),
|
||||
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
|
||||
"example.com.", "www", "CNAME")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var contents []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents = append(contents, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contents) != 2 {
|
||||
t.Fatalf("got %d rows after REPLACE, want exactly 2: %v", len(contents), contents)
|
||||
}
|
||||
want := map[string]bool{`{"host":"a.example.com."}`: true, `{"host":"b.example.com."}`: true}
|
||||
for _, c := range contents {
|
||||
if !want[c] {
|
||||
t.Fatalf("unexpected content %q, want one of %v", c, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- PATCH DELETE -----------------------------------------------------------
|
||||
|
||||
func TestHandlePatchDelete(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
db := requireDB(t)
|
||||
|
||||
// DELETE without ttl/records fields, matching acme.sh's shape.
|
||||
patchBody := `{"rrsets":[{"name":"www.example.com.","type":"CNAME","changetype":"DELETE"}]}`
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
|
||||
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
|
||||
"example.com.", "www", "CNAME").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("count = %d after DELETE, want 0", count)
|
||||
}
|
||||
}
|
||||
|
||||
// --- FQDN normalization -----------------------------------------------------
|
||||
|
||||
func TestFQDNNormalization(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
db := requireDB(t)
|
||||
|
||||
// Uppercase name, zone_id without trailing dot.
|
||||
patchBody := `{"rrsets":[{"name":"_x.EXAMPLE.COM.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com", []byte(patchBody))
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
|
||||
var name string
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
fmt.Sprintf("SELECT name FROM %s WHERE zone=? AND record_type=? AND content=?", testTableName),
|
||||
"example.com.", "TXT", `{"text":"v"}`).Scan(&name)
|
||||
if err != nil {
|
||||
t.Fatalf("query stored row: %v", err)
|
||||
}
|
||||
if name != "_x" {
|
||||
t.Fatalf("stored name = %q, want relative lowercase _x", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNNormalization_OutsideZone422(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
patchBody := `{"rrsets":[{"name":"foo.other.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFQDNNormalization_BoundaryCase(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
// "notexample.com." is NOT part of zone "example.com." despite the
|
||||
// substring match — must be 422, not accepted.
|
||||
patchBody := `{"rrsets":[{"name":"notexample.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\"","disabled":false}]}]}`
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(patchBody))
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- notify ------------------------------------------------------------
|
||||
|
||||
func TestHandleNotify(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PUT", "/api/v1/servers/localhost/zones/example.com./notify", nil)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, body)
|
||||
}
|
||||
want := `{"result":"Notification queued"}` + "\n"
|
||||
if string(body) != want {
|
||||
t.Fatalf("body = %q, want %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleNotify_UnknownZone(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PUT", "/api/v1/servers/localhost/zones/nope.com./notify", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, body = %s, want 404", resp.StatusCode, body)
|
||||
}
|
||||
}
|
||||
|
||||
// --- error bodies --------------------------------------------------------
|
||||
|
||||
func TestUnknownZoneErrors(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
t.Run("GET", func(t *testing.T) {
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "GET", "/api/v1/servers/localhost/zones/nope.com.", nil)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
want := `{"error":"Not Found"}` + "\n"
|
||||
if string(body) != want {
|
||||
t.Fatalf("body = %q, want %q", body, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("PATCH", func(t *testing.T) {
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/nope.com.", []byte(`{"rrsets":[]}`))
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
want := `{"error":"Not Found"}` + "\n"
|
||||
if string(body) != want {
|
||||
t.Fatalf("body = %q, want %q", body, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestMalformedRRsets422(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
// ZoneExists (→404) is checked before body decode/validation (→422), so
|
||||
// every case here must target the seeded existing zone example.com.
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{"missing changetype", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"records":[{"content":"\"v\""}]}]}`},
|
||||
{"unknown changetype", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"changetype":"BOGUS","records":[{"content":"\"v\""}]}]}`},
|
||||
{"REPLACE with 0 records", `{"rrsets":[{"name":"www.example.com.","type":"TXT","ttl":120,"changetype":"REPLACE","records":[]}]}`},
|
||||
{"missing name", `{"rrsets":[{"type":"TXT","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\""}]}]}`},
|
||||
{"missing type", `{"rrsets":[{"name":"www.example.com.","ttl":120,"changetype":"REPLACE","records":[{"content":"\"v\""}]}]}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(tt.body))
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s, want 422", resp.StatusCode, body)
|
||||
}
|
||||
var errBody map[string]string
|
||||
if err := json.Unmarshal(body, &errBody); err != nil {
|
||||
t.Fatalf("error body not JSON: %v; body = %s", err, body)
|
||||
}
|
||||
if _, ok := errBody["error"]; !ok {
|
||||
t.Fatalf("error body missing \"error\" key: %s", body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedJSONBody(t *testing.T) {
|
||||
srv, cfg := dbTestServer(t)
|
||||
|
||||
// Current behavior: json.Decode failure -> 422 with {"error": "malformed
|
||||
// JSON body"}. Asserting current behavior per instructions.
|
||||
resp, body := doReq(t, srv, cfg.PDNSAPIKey, "PATCH", "/api/v1/servers/localhost/zones/example.com.", []byte(`{not valid json`))
|
||||
if resp.StatusCode != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body = %s, want 422 (current behavior)", resp.StatusCode, body)
|
||||
}
|
||||
var errBody map[string]string
|
||||
if err := json.Unmarshal(body, &errBody); err != nil {
|
||||
t.Fatalf("error body not JSON: %v; body = %s", err, body)
|
||||
}
|
||||
if _, ok := errBody["error"]; !ok {
|
||||
t.Fatalf("error body missing \"error\" key: %s", body)
|
||||
}
|
||||
}
|
||||
46
privdrop.go
Normal file
46
privdrop.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// dropPrivileges irrevocably drops from root to PUID/PGID before any
|
||||
// listener, goroutine, or DB connection is created. If already unprivileged
|
||||
// (dev runs), it skips the drop and logs a warning. Any failure to drop or
|
||||
// verify the drop is fatal — this process must never serve traffic as root.
|
||||
func dropPrivileges(logger *slog.Logger, puid, pgid int) {
|
||||
if os.Getuid() != 0 {
|
||||
logger.Warn("already running unprivileged, skipping privilege drop", "uid", os.Getuid(), "gid", os.Getgid())
|
||||
return
|
||||
}
|
||||
|
||||
if err := syscall.Setgroups([]int{}); err != nil {
|
||||
logger.Error("failed to clear supplementary groups", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := syscall.Setgid(pgid); err != nil {
|
||||
logger.Error("failed to setgid", "gid", pgid, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := syscall.Setuid(puid); err != nil {
|
||||
logger.Error("failed to setuid", "uid", puid, "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if os.Getuid() != puid || os.Getgid() != pgid {
|
||||
logger.Error("privilege drop verification failed", "want_uid", puid, "got_uid", os.Getuid(), "want_gid", pgid, "got_gid", os.Getgid())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := syscall.Setuid(0); err == nil {
|
||||
logger.Error("re-escalation to root succeeded, this must never happen")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
logger.Info("privileges dropped", "uid", os.Getuid(), "gid", os.Getgid())
|
||||
}
|
||||
30
privdrop_test.go
Normal file
30
privdrop_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestDropPrivilegesNonRootSkipsDrop covers the non-root half (Phase 6): when
|
||||
// the test binary is not running as root (the normal CI/dev case), dropPrivileges
|
||||
// must take the skip path — log a warning and return — without calling exit
|
||||
// or erroring. The root half (actual setuid/setgid) requires a root-owned
|
||||
// process and is exercised at the container level (Phase 6), not here.
|
||||
func TestDropPrivilegesNonRootSkipsDrop(t *testing.T) {
|
||||
if os.Getuid() == 0 {
|
||||
t.Skip("this test asserts the non-root skip path; running as root here")
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
|
||||
// If dropPrivileges took the root path here, it would call
|
||||
// syscall.Setgid/Setuid as non-root, fail, and os.Exit(1) — which would
|
||||
// kill the test binary. Reaching this point at all proves the skip path
|
||||
// was taken, without erroring or exiting.
|
||||
dropPrivileges(logger, 1000, 1000)
|
||||
}
|
||||
19
routes.go
Normal file
19
routes.go
Normal file
@@ -0,0 +1,19 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import "github.com/gorilla/mux"
|
||||
|
||||
func (s *WebServer) Routes(r *mux.Router) {
|
||||
r.HandleFunc("/version", handleVersion).Methods("GET")
|
||||
r.HandleFunc("/healthz", s.handleHealthz).Methods("GET")
|
||||
|
||||
api := r.PathPrefix("/api/v1").Subrouter()
|
||||
api.Use(s.authMiddleware)
|
||||
|
||||
api.HandleFunc("/servers/{server_id}/zones", s.handleListZones).Methods("GET")
|
||||
api.HandleFunc("/servers/{server_id}/zones/{zone_id}", s.handleZoneDetail).Methods("GET")
|
||||
api.HandleFunc("/servers/{server_id}/zones/{zone_id}", s.handleZonePatch).Methods("PATCH")
|
||||
api.HandleFunc("/servers/{server_id}/zones/{zone_id}/notify", s.handleZoneNotify).Methods("PUT")
|
||||
}
|
||||
123
store.go
Normal file
123
store.go
Normal file
@@ -0,0 +1,123 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Store wraps DB access against the {TablePrefix}records table (schema per
|
||||
// docs/SEMANTICS.md §1).
|
||||
type Store struct {
|
||||
db *sql.DB
|
||||
tableName string
|
||||
}
|
||||
|
||||
// NewStore builds a Store bound to {prefix}records.
|
||||
func NewStore(db *sql.DB, tablePrefix string) *Store {
|
||||
return &Store{
|
||||
db: db,
|
||||
tableName: tablePrefix + "records",
|
||||
}
|
||||
}
|
||||
|
||||
// Record is one row of {prefix}records.
|
||||
type Record struct {
|
||||
Name string
|
||||
TTL sql.NullInt64
|
||||
Content string
|
||||
RecordType string
|
||||
}
|
||||
|
||||
// ListZones returns distinct zones present in the table, ordered by name.
|
||||
func (st *Store) ListZones(ctx context.Context) ([]string, error) {
|
||||
query := fmt.Sprintf("SELECT DISTINCT zone FROM %s ORDER BY zone", st.tableName)
|
||||
rows, err := st.db.QueryContext(ctx, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var zones []string
|
||||
for rows.Next() {
|
||||
var zone string
|
||||
if err := rows.Scan(&zone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
zones = append(zones, zone)
|
||||
}
|
||||
return zones, rows.Err()
|
||||
}
|
||||
|
||||
// GetZoneRecords returns every row for the given zone.
|
||||
func (st *Store) GetZoneRecords(ctx context.Context, zone string) ([]Record, error) {
|
||||
query := fmt.Sprintf("SELECT name, ttl, content, record_type FROM %s WHERE zone = ?", st.tableName)
|
||||
rows, err := st.db.QueryContext(ctx, query, zone)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var records []Record
|
||||
for rows.Next() {
|
||||
var rec Record
|
||||
if err := rows.Scan(&rec.Name, &rec.TTL, &rec.Content, &rec.RecordType); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
records = append(records, rec)
|
||||
}
|
||||
return records, rows.Err()
|
||||
}
|
||||
|
||||
// ZoneExists reports whether at least one row exists for the zone.
|
||||
func (st *Store) ZoneExists(ctx context.Context, zone string) (bool, error) {
|
||||
query := fmt.Sprintf("SELECT EXISTS(SELECT 1 FROM %s WHERE zone = ?)", st.tableName)
|
||||
var exists bool
|
||||
if err := st.db.QueryRowContext(ctx, query, zone).Scan(&exists); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
// RRsetChange is one decomposed rrset change ready to apply to the DB.
|
||||
type RRsetChange struct {
|
||||
Zone string
|
||||
Name string // relative name, per SEMANTICS §1 (empty string at apex)
|
||||
RecordType string
|
||||
ChangeType string // REPLACE or DELETE
|
||||
TTL int
|
||||
Contents []string // marshalled JSON content, one per record; unused for DELETE
|
||||
}
|
||||
|
||||
// ApplyRRsets runs every rrset change of one PATCH inside a single
|
||||
// transaction. REPLACE deletes existing rows for (zone, name, record_type)
|
||||
// then inserts one row per record. DELETE just deletes. Any error rolls
|
||||
// back the entire transaction.
|
||||
func (st *Store) ApplyRRsets(ctx context.Context, changes []RRsetChange) error {
|
||||
tx, err := st.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
deleteQuery := fmt.Sprintf("DELETE FROM %s WHERE zone = ? AND name = ? AND record_type = ?", st.tableName)
|
||||
insertQuery := fmt.Sprintf("INSERT INTO %s (zone, name, ttl, content, record_type) VALUES (?, ?, ?, ?, ?)", st.tableName)
|
||||
|
||||
for _, ch := range changes {
|
||||
if _, err := tx.ExecContext(ctx, deleteQuery, ch.Zone, ch.Name, ch.RecordType); err != nil {
|
||||
return err
|
||||
}
|
||||
if ch.ChangeType == "REPLACE" {
|
||||
for _, content := range ch.Contents {
|
||||
if _, err := tx.ExecContext(ctx, insertQuery, ch.Zone, ch.Name, ch.TTL, content, ch.RecordType); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
269
store_test.go
Normal file
269
store_test.go
Normal file
@@ -0,0 +1,269 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// Isolated test schema, per PLAN.md Phase 1: a dedicated table
|
||||
// (test_pdnsapi_records) on the live MariaDB, never the real coredns_records
|
||||
// table. TABLE_PREFIX for the Store under test is "test_pdnsapi_".
|
||||
const testTablePrefix = "test_pdnsapi_"
|
||||
const testTableName = testTablePrefix + "records"
|
||||
|
||||
var (
|
||||
testDB *sql.DB
|
||||
testDBReason string // non-empty if the DB is unreachable; tests should t.Skip with this
|
||||
testDBReady bool
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dsn := os.Getenv("MYSQL_DSN")
|
||||
if dsn == "" {
|
||||
dsn = "coredns:coredns@tcp(127.0.0.1:3306)/coredns?parseTime=true"
|
||||
}
|
||||
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
testDBReason = fmt.Sprintf("mysql: sql.Open failed: %v", err)
|
||||
} else {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
pingErr := db.PingContext(ctx)
|
||||
cancel()
|
||||
if pingErr != nil {
|
||||
testDBReason = fmt.Sprintf("mysql: unreachable at %s: %v", dsn, pingErr)
|
||||
} else {
|
||||
testDB = db
|
||||
testDBReady = true
|
||||
}
|
||||
}
|
||||
|
||||
code := m.Run()
|
||||
|
||||
if testDB != nil {
|
||||
// Final safety net teardown in case an individual test's defer was
|
||||
// skipped (e.g. t.Fatal before cleanup registration). Never touches
|
||||
// coredns_records — only the isolated test_pdnsapi_records table.
|
||||
_, _ = testDB.Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", testTableName))
|
||||
testDB.Close()
|
||||
}
|
||||
|
||||
os.Exit(code)
|
||||
}
|
||||
|
||||
// requireDB skips the calling test if the live MariaDB is unreachable.
|
||||
func requireDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
if !testDBReady {
|
||||
t.Skipf("skipping: live MySQL/MariaDB not reachable (%s)", testDBReason)
|
||||
}
|
||||
return testDB
|
||||
}
|
||||
|
||||
// createTestSchema (re)creates test_pdnsapi_records with the exact live
|
||||
// schema from docs/SEMANTICS.md §1, and registers a cleanup to drop it.
|
||||
func createTestSchema(t *testing.T) {
|
||||
t.Helper()
|
||||
db := requireDB(t)
|
||||
|
||||
ctx := context.Background()
|
||||
_, err := db.ExecContext(ctx, fmt.Sprintf(`DROP TABLE IF EXISTS %s`, testTableName))
|
||||
if err != nil {
|
||||
t.Fatalf("drop existing test table: %v", err)
|
||||
}
|
||||
|
||||
_, err = db.ExecContext(ctx, fmt.Sprintf(`CREATE TABLE %s (
|
||||
id int(11) NOT NULL AUTO_INCREMENT,
|
||||
zone varchar(255) NOT NULL,
|
||||
name varchar(255) NOT NULL,
|
||||
ttl int(11) DEFAULT NULL,
|
||||
content text DEFAULT NULL,
|
||||
record_type varchar(255) NOT NULL,
|
||||
PRIMARY KEY (id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`, testTableName))
|
||||
if err != nil {
|
||||
t.Fatalf("create test table: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, err := db.ExecContext(context.Background(), fmt.Sprintf(`DROP TABLE IF EXISTS %s`, testTableName))
|
||||
if err != nil {
|
||||
t.Errorf("drop test table on cleanup: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedRow is one row to insert during reseed.
|
||||
type seedRow struct {
|
||||
zone string
|
||||
name string
|
||||
ttl sql.NullInt64
|
||||
content string
|
||||
recordType string
|
||||
}
|
||||
|
||||
// exampleComSeed is the SOA/NS/A/CNAME example.com. seed from
|
||||
// docs/SEMANTICS.md §1 (mirrors the live coredns_records example.com data),
|
||||
// plus two TXT rows on one name to exercise rrset aggregation.
|
||||
func exampleComSeed() []seedRow {
|
||||
nn := func(v int64) sql.NullInt64 { return sql.NullInt64{Int64: v, Valid: true} }
|
||||
return []seedRow{
|
||||
{"example.com.", "", nn(300), `{"ttl":300, "mbox":"hostmaster.example.com.", "ns":"ns1.example.com.", "refresh":44, "retry":55, "expire":66}`, "SOA"},
|
||||
{"example.com.", "", nn(300), `{"host":"ns1.example.com."}`, "NS"},
|
||||
{"example.com.", "", nn(300), `{"ip":"1.2.3.4"}`, "A"},
|
||||
{"example.com.", "www", nn(300), `{"host":"example.com."}`, "CNAME"},
|
||||
{"example.com.", "multi", nn(120), `{"text":"first"}`, "TXT"},
|
||||
{"example.com.", "multi", nn(120), `{"text":"second"}`, "TXT"},
|
||||
}
|
||||
}
|
||||
|
||||
// reseed truncates test_pdnsapi_records and inserts the known seed rows.
|
||||
// Call at the start of every DB-backed test that reads or mutates rows, since
|
||||
// the table is shared mutable state across tests in the same run.
|
||||
func reseed(t *testing.T, db *sql.DB, rows []seedRow) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := db.ExecContext(ctx, fmt.Sprintf("TRUNCATE TABLE %s", testTableName)); err != nil {
|
||||
t.Fatalf("truncate test table: %v", err)
|
||||
}
|
||||
|
||||
insert := fmt.Sprintf("INSERT INTO %s (zone, name, ttl, content, record_type) VALUES (?, ?, ?, ?, ?)", testTableName)
|
||||
for _, row := range rows {
|
||||
if _, err := db.ExecContext(ctx, insert, row.zone, row.name, row.ttl, row.content, row.recordType); err != nil {
|
||||
t.Fatalf("seed insert %+v: %v", row, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Store-level tests -------------------------------------------------
|
||||
|
||||
func TestStoreListZones(t *testing.T) {
|
||||
createTestSchema(t)
|
||||
db := requireDB(t)
|
||||
reseed(t, db, exampleComSeed())
|
||||
|
||||
st := NewStore(db, testTablePrefix)
|
||||
zones, err := st.ListZones(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(zones) != 1 || zones[0] != "example.com." {
|
||||
t.Fatalf("zones = %v, want [example.com.]", zones)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreZoneExists(t *testing.T) {
|
||||
createTestSchema(t)
|
||||
db := requireDB(t)
|
||||
reseed(t, db, exampleComSeed())
|
||||
|
||||
st := NewStore(db, testTablePrefix)
|
||||
ok, err := st.ZoneExists(context.Background(), "example.com.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected example.com. to exist")
|
||||
}
|
||||
|
||||
ok, err = st.ZoneExists(context.Background(), "nope.com.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("expected nope.com. to not exist")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetZoneRecords(t *testing.T) {
|
||||
createTestSchema(t)
|
||||
db := requireDB(t)
|
||||
reseed(t, db, exampleComSeed())
|
||||
|
||||
st := NewStore(db, testTablePrefix)
|
||||
records, err := st.GetZoneRecords(context.Background(), "example.com.")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(records) != len(exampleComSeed()) {
|
||||
t.Fatalf("got %d records, want %d", len(records), len(exampleComSeed()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreApplyRRsetsReplace(t *testing.T) {
|
||||
createTestSchema(t)
|
||||
db := requireDB(t)
|
||||
reseed(t, db, exampleComSeed())
|
||||
|
||||
st := NewStore(db, testTablePrefix)
|
||||
changes := []RRsetChange{
|
||||
{
|
||||
Zone: "example.com.",
|
||||
Name: "www",
|
||||
RecordType: "CNAME",
|
||||
ChangeType: "REPLACE",
|
||||
TTL: 60,
|
||||
Contents: []string{`{"host":"other.example.com."}`},
|
||||
},
|
||||
}
|
||||
if err := st.ApplyRRsets(context.Background(), changes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := db.QueryContext(context.Background(),
|
||||
fmt.Sprintf("SELECT content FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
|
||||
"example.com.", "www", "CNAME")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var contents []string
|
||||
for rows.Next() {
|
||||
var c string
|
||||
if err := rows.Scan(&c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
contents = append(contents, c)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(contents) != 1 || contents[0] != `{"host":"other.example.com."}` {
|
||||
t.Fatalf("contents = %v, want single other.example.com. row", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreApplyRRsetsDelete(t *testing.T) {
|
||||
createTestSchema(t)
|
||||
db := requireDB(t)
|
||||
reseed(t, db, exampleComSeed())
|
||||
|
||||
st := NewStore(db, testTablePrefix)
|
||||
changes := []RRsetChange{
|
||||
{Zone: "example.com.", Name: "www", RecordType: "CNAME", ChangeType: "DELETE"},
|
||||
}
|
||||
if err := st.ApplyRRsets(context.Background(), changes); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.QueryRowContext(context.Background(),
|
||||
fmt.Sprintf("SELECT COUNT(*) FROM %s WHERE zone=? AND name=? AND record_type=?", testTableName),
|
||||
"example.com.", "www", "CNAME").Scan(&count)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 0 {
|
||||
t.Fatalf("count = %d, want 0 after DELETE", count)
|
||||
}
|
||||
}
|
||||
14
templates/html/version.html
Normal file
14
templates/html/version.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!-- SPDX-License-Identifier: GPL-2.0-or-later -->
|
||||
<!-- Copyright (C) 2026 Bryan Joshua Pedini -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{.Name}}</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>
|
||||
Version: {{.Version}}<br />
|
||||
Commit ID: {{.CommitId}}
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
49
type_webserver.go
Normal file
49
type_webserver.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type WebServer struct {
|
||||
HTTPServer *http.Server
|
||||
AppName string
|
||||
Config *Config
|
||||
Logger *slog.Logger
|
||||
DB *sql.DB
|
||||
Store *Store
|
||||
}
|
||||
|
||||
func (s *WebServer) Initialize() {
|
||||
s.AppName = "PowerDNS API Simulator"
|
||||
s.Config = LoadConfig()
|
||||
s.Logger = NewLogger(s.Config.LogLevel)
|
||||
}
|
||||
|
||||
func (s *WebServer) Start() error {
|
||||
// Create a new MUX router and an HTTP server
|
||||
r := mux.NewRouter()
|
||||
r.Use(s.loggingMiddleware)
|
||||
s.HTTPServer = &http.Server{
|
||||
Addr: s.Config.ListenAddr,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// Associate the various handlers (routes)
|
||||
s.Routes(r)
|
||||
|
||||
// Start the server
|
||||
s.Logger.Info("listening", "addr", s.Config.ListenAddr)
|
||||
fmt.Println("Listening on", s.Config.ListenAddr)
|
||||
err := s.HTTPServer.ListenAndServe()
|
||||
|
||||
// Return error, or nil
|
||||
return err
|
||||
}
|
||||
17
vars.example
Normal file
17
vars.example
Normal file
@@ -0,0 +1,17 @@
|
||||
#/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
export GIT_HOST=git.bjphoster.com
|
||||
export DEPLOYMENT_HOST=deploy.example.com
|
||||
export DEPLOYMENT_PATHS=/srv/example
|
||||
export GO_BUILDER=bryanpedini/gobuilder
|
||||
export GO_VERSION=1.22.2-alpine3.19
|
||||
export GOOS=linux
|
||||
export GOARCH=amd64
|
||||
export REPO_ORG=source
|
||||
export REPO_NAME=coredns-powerdns-api-wrapper
|
||||
export CONTAINER_ORG=git.bjphoster.com/source
|
||||
export CONTAINER_IMAGE=coredns-powerdns-api-wrapper
|
||||
export CONTAINER_IP=127.0.0.1
|
||||
export CONTAINER_PORT=3000
|
||||
29
version.go
Normal file
29
version.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
// Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed templates/html/version.html
|
||||
var versionTemplate string
|
||||
|
||||
func handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
type SiteInfo struct {
|
||||
CommitId string
|
||||
Name string
|
||||
Version string
|
||||
}
|
||||
|
||||
tmpl, _ := template.New("version.html").Parse(versionTemplate)
|
||||
// Return (write) the version to the response body
|
||||
tmpl.Execute(w, SiteInfo{
|
||||
CommitId: COMMIT_ID,
|
||||
Name: ws.AppName,
|
||||
Version: APP_VERSION,
|
||||
})
|
||||
}
|
||||
33
version.sh
Executable file
33
version.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env bash
|
||||
# SPDX-License-Identifier: GPL-2.0-or-later
|
||||
# Copyright (C) 2026 Bryan Joshua Pedini
|
||||
|
||||
if [ -z ${APP_VERSION} ]; then
|
||||
read -p "Version [latest]: " VERSIONINPUT
|
||||
if [ -z ${VERSIONINPUT} ]; then
|
||||
APP_VERSION="latest"
|
||||
else
|
||||
APP_VERSION=${VERSIONINPUT}
|
||||
fi
|
||||
fi
|
||||
|
||||
# Get docker push option from user
|
||||
if [ -z ${DOCKERPUSH} ]; then
|
||||
read -p "Docker push? [n]: " DOCKERPUSH
|
||||
if [ -z ${DOCKERPUSH} ]; then
|
||||
DOCKERPUSH=n
|
||||
fi
|
||||
fi
|
||||
|
||||
# Create version tag (if provided)
|
||||
if [ ! -z ${VERSIONINPUT} ]; then
|
||||
git tag ${APP_VERSION}
|
||||
fi
|
||||
|
||||
# Build the app
|
||||
export APP_VERSION
|
||||
make docker
|
||||
# If wanted, push the docker image
|
||||
if [ ${DOCKERPUSH} = "y" ]; then
|
||||
make dockerpush
|
||||
fi
|
||||
Reference in New Issue
Block a user