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