`redirect_root_handler()` only redirects / to the web home when the Host
header is webserver.domain. For any other Host - the Pi-hole's IP
address, a missing Host header, "pi.hole." with a trailing dot - it
returned 0, which makes CivetWeb skip the remaining handlers and serve
the file itself. `request_handler()`, registered for "**", never ran, so
with webserver.serve_all=false (the default) an index.html, index.htm or
index.lp in the webroot was still served, and an index.lp executed,
while every other path outside the web home got 404.
Hand those requests to `request_handler()` instead. With serve_all off
/ now gets 404 like any other path outside the web home; with serve_all
on it returns 0 for / and CivetWeb serves the index as before. The
redirect for webserver.domain is unchanged.
Signed-off-by: 010011110 <duckenheim@posteo.de>
`redirect_lp_handler()` copied `local_uri_raw` minus ".lp" into the
Location header. CivetWeb URL-decodes that string but does not collapse
repeated slashes or turn backslashes into slashes, so with
webserver.serve_all enabled or webserver.paths.webhome set to "/" a
request for //evil.example/x.lp, /%2Fevil.example/x.lp or
/%5Cevil.example/x.lp was answered with a 301 to //evil.example/x or
/\evil.example/x, which browsers follow to another host and cache.
Use `local_uri` instead, the cleaned path CivetWeb also matched the
"**.lp$" handler against and `request_handler()` already checks
webserver.serve_all with. Those requests now redirect to
/evil.example/x on the Pi-hole itself.
Signed-off-by: 010011110 <duckenheim@posteo.de>
`process_received_tar_gz()` imports the Pi-hole v5 Teleporter files in
the order of `teleporter_v5_files[]`, and `domainlist_by_group.json`
came before `whitelist.exact.json` and `whitelist.regex.json`. The
`tr_domainlist_add` trigger adds every row inserted into `domainlist` to
the Default group (0), so the allowlist entries imported after the group
assignments kept that extra row on top of their archived groups. An
allow entry that v5 scoped to one group therefore applied to every
client in the Default group after the import, i.e. to all clients not
assigned elsewhere. Denylist entries were not affected because the
`domainlist_by_group` import replaced their trigger rows.
Move `domainlist_by_group.json` to the end of the list so the archived
mapping is applied after all four domain lists, like `adlist_by_group`
and `client_by_group` already are after their primary tables. The
`files` array in the reply now lists `domainlist_by_group.json` last.
Add a test that imports a small v5 archive and checks the groups of all
four domain list types. `test_teleporter_import` runs right after it and
restores `gravity.db` and `pihole.toml` from the exported ZIP archive.
The v5 import migrates the config and the ZIP import then has a changed
config to write back, which adds three `pihole.toml` writes to the count
in `test_final.bats`.
The same problem was reported for the v5 web Teleporter in
pi-hole/web#3020.
Signed-off-by: 010011110 <duckenheim@posteo.de>
Covers a cached decision after the client's groups change, an EDNS(0)
MAC that differs from the network table, the allow-regex check right
after a MAC change with an empty exact allowlist, and a TCP worker that
outlives a list reload.
Signed-off-by: 010011110 <duckenheim@posteo.de>
`api_list_write()` writes every item of a request inside one transaction
and commits it whenever the transaction is still open, also when items
failed. `gravityDB_edit_groups()` deletes all existing group links of an
item before it inserts the new ones, so a "groups" array naming a group
that does not exist (deleted in another tab, for example) failed on the
foreign key only after the row was written and its old links were gone.
That was committed, yet the item was listed under errors, and when no
item succeeded the request answered 400 without raising
`RELOAD_GRAVITY`. A PUT changed the row and dropped its links, a POST
left a new row behind that a retry then reported as already present.
"groups" given as anything but an array of numbers (null, ["1"], 5)
deleted the links, added none and was reported as a success.
Each item now runs under its own savepoint inside the batch transaction
and is rolled back to it when it fails, so an item reported as failed
leaves nothing behind. "groups" is rejected with 400 before any write
unless it is an array of numbers. When the batch transaction could not
be started there is nothing to roll back to, so an all-failed request
then still tells the resolver to reload.
Signed-off-by: 010011110 <duckenheim@posteo.de>
`printTOMLstring()` escapes strings with `escape_json()`, which leaves
U+007F as a raw byte. TOML forbids a raw DEL in basic strings and
tomlc17 rejects the file with "invalid char in string". A config string
containing DEL, which the API accepts, thus made `pihole.toml`
unparsable for FTL itself. Every further write rotated another broken
copy into `config_backups`, so FTL either fell back to an older backup
on the next start, losing all later changes, or, once all backups were
broken, started with the default config and an empty password.
Write DEL as `\u007F` in TOML output. The CLI output is unchanged.
Signed-off-by: 010011110 <duckenheim@posteo.de>
A group PUT carrying a `name` renames the group and echoes the new name
in the `Location` response header. The CR/LF check that guards that
header only looked at the items, so a name such as
"x\r\nSet-Cookie: ..." was stored in gravity.db and civetweb sent the
part after the line break as a separate response header. POST
/api/groups already rejected the same string because there the name is
the item.
Apply the same check to `name` before anything is written.
Signed-off-by: 010011110 <duckenheim@posteo.de>
For TCP queries dnsmasq passes a negative id (`daemon->log_display_id =
-(++log_id)` in `tcp_request()`). `FTL_new_query()` and `FTL_hook()` make
it positive before storing or looking up the query, but `FTL_CNAME()` and
`_FTL_check_reply()` handed it to `findQueryID()` unchanged, so the lookup
never matched. This has been the case since adcccfc1 adapted only
`FTL_hook()` and `FTL_new_query()` to the negative TCP ids.
As a result, deep CNAME inspection was skipped for every query that
reaches dnsmasq over TCP, including all queries from the DoT/DoH server,
which hands off over loopback TCP: the client got the full CNAME chain
with the real address of a denied, gravity or regex-blocked target. Upstream
blocks (0.0.0.0, NXDOMAIN without RA, EDE 15) received over TCP were still
rewritten on the wire, but the query stayed FORWARDED with reply BLOB and
was not counted as blocked.
Normalize the id in both functions. `FTL_CNAME()` keeps passing the raw id
to `update_pihole_cache_record()` because `pihole_dest_id` is captured from
the raw id and compared against the raw `daemon->log_display_id`.
Add CNAME and NULL-upstream records for TCP-only tests and adjust the
query counts asserted by the API tests for the two new queries.
Signed-off-by: 010011110 <duckenheim@posteo.de>
The NTP server is also started when FTL's NTP client does not run: with
`ntp.sync.active=false` (or no server or interval), without
CAP_SYS_TIME (the usual Docker setup), or when the client thread cannot
be created. `ntp_stratum`, `ntp_last_sync` and the root delay and
dispersion are only written after a successful client sync, so on these
paths every reply carried stratum 0 and a zero reference timestamp with
LI 0. Stratum 0 is invalid in a server reply, and chrony,
systemd-timesyncd and FTL's own client all reject it, so the server
served no usable time. Before `ntp_stratum` was introduced the reply
used a fixed stratum 2.
While the client has not set the clock, ask the kernel through
`adjtimex()` whether the system clock is synchronized. If it is, answer
as a stratum 2 server with the receive time as reference timestamp;
otherwise answer LI 3, stratum 16, which tells clients we are
unsynchronized.
Signed-off-by: 010011110 <duckenheim@posteo.de>
Each item now carries a `write_only` flag, so clients can tell a placeholder `value` from a real one without parsing the description. It is set for items with `FLAG_WRITE_ONLY` and for the password pseudo-items, which are write-only by type.
Signed-off-by: DL6ER <dl6er@dl6er.de>
`verify_password()` permits `MAX_PASSWORD_ATTEMPTS_PER_SECOND` attempts per
wall-clock second, so reaching the limit needs four attempts inside the same
second. The test sent them sequentially, and each attempt costs one deliberately
expensive balloon hash: on a slow or contended machine fewer than four fit into
any one second, the counter resets before the limit is reached, and the test
fails through no fault of the code under test. The retry in the workflow cannot
help, as all three attempts run on the same runner.
Send the attempts in parallel instead. FTL counts an attempt before it hashes
it, so a burst of eight exceeds a limit of three whatever a hash costs, even
when the burst straddles a second boundary.
The limiter answers with a plain 429 and does not drop the connection, so a
transport failure is now reported as one instead of standing in for a
rate-limit response, and the reply's error key is asserted so a 429 from
anywhere else cannot satisfy the test.
Verified in the build container: with `--cpus=0.25` the previous test fails in
98 s and this one passes in 11 s, and unthrottled it passes in 4 s.
Signed-off-by: DL6ER <dl6er@dl6er.de>
A position alone does not say what is wrong with the path. dnsmasq prints the
character itself in the comparable case (`Invalid DNS name: Invalid character
%c.`), which is not an option here: every value reaching this branch is
unprintable by definition, so a `0x0a` would break the line reporting it. Hex
keeps the diagnostic value and tells a stray newline apart from a UTF-8 lead
byte.
Signed-off-by: DL6ER <dl6er@dl6er.de>
The accepted set was alphanumerics plus `/`, `.`, `-`, `_` and space, which
turns away perfectly ordinary file names - a certificate bundle called
`domain_cert+interm+key.pem` is rejected over the `+`. Since v6.7.1 validates
`pihole.toml` as well, such a path is no longer merely refused by the API: it
is reset to its default on startup, taking a working TLS certificate with it.
The range stops at printable ASCII rather than at everything a Linux file name
may carry, as these values are handed out as JSON, which has to be UTF-8, and
are written into the generated dnsmasq config, where a control character would
start a second directive. The offending byte is named by position rather than
echoed, which would otherwise break the very log line reporting it.
The tests picked their invalid paths from the old set - `*123#./test/pcap` and
`%gh4b` are printable and are accepted now - so both move to a control
character, which is what the rule still rejects.
Signed-off-by: DL6ER <dl6er@dl6er.de>
query_blocked() incremented the blockedcount of whichever domain it
was handed. From FTL_CNAME() that is the CNAME target that hit a list,
and FTL_CNAME() then also increments the queried domain's blockedcount,
so a blocked CNAME chain credited two domains. runGC() hands the count
back to query->domainID only, and the same holds for the startup import
from the database and for later queries answered from the DNS cache
entry, which only ever credit the queried domain. The CNAME target's
blockedcount was therefore never debited: it grew by one on every
cache miss on the parent (the first query per client and type, and
again after every list reload), had no matching count, and kept the
target in /api/stats/top_domains?blocked=true after the whole history
had been garbage-collected.
Increment the domain's blockedcount in query_blocked() only when it is
the queried domain, so the increments match what runGC() decrements.
Removing the double count leaves gravity.ftl and denied.ftl tied at four
blocked hits in the test suite, where /api/padd top_blocked would then
turn on which of the two the suite queries first. Give denied.ftl a
second, different-type hit so it stands at five and the top blocked
domain follows from the count, and adjust the affected expectations in
test_api.py.
Signed-off-by: 010011110 <duckenheim@posteo.de>
PUT /api/domains/{type}/{kind}/{domain} accepts an optional type/kind in
the payload naming the list the domain currently sits on, so the row can
be moved to the list the URI names. The upsert bound that payload type as
the INSERT value:
INSERT INTO domainlist (domain,type,...) VALUES (:item,:oldtype,...)
ON CONFLICT(domain,type) DO UPDATE SET type = :type, ...
so the move only worked when the (domain, oldtype) row existed and the
conflict clause fired. When it did not, the INSERT itself went through
and created the domain on the payload's list rather than the URI's: a
PUT to allow/exact with type deny produced a deny entry that blocked the
domain, while `api_list_read()` queried the URI list and answered 200
with "domains": []. When the URI row existed but the payload row did
not, the INSERT did not conflict either and the domain ended up on both
lists.
The INSERT value is now the payload type only when that row exists, and
the URI type otherwise:
VALUES (:item, CASE WHEN EXISTS (SELECT 1 FROM domainlist
WHERE domain = :item AND type = :oldtype)
THEN :oldtype ELSE :type END, ...)
The resulting semantics: without a payload type, or with one equal to the
URI, the row at the URI type is created or updated as before. With a
payload type whose row exists, the conflict clause moves that row to the
URI type, keeping its id. With a payload type whose row does not exist,
the row at the URI type is created, or updated if it is already there. A
domain present on both lists still fails the UNIQUE constraint and is
reported as already present.
Signed-off-by: 010011110 <duckenheim@posteo.de>
PUT /api/groups/{name} with a different "name" in the payload runs
UPDATE "group" SET name = :name ... WHERE name = :item, so the row is
renamed, but `api_list_write()` handed the URI name on as the reply item
and built the Location header from it. `api_list_read()` then selected
WHERE name = :item, found nothing, and the client got a 200 with an empty
groups array and a Location pointing at a group that no longer exists,
although groups.yaml promises a body identical to GET.
The same UPDATE was counted as a success whenever it stepped to
SQLITE_DONE, including when it matched no row, so PUT on a nonexistent
group with a name in the payload was a silent no-op reported as
processed.success. And since any non-empty "name" took that branch, even
one equal to the URI item, the create-or-update the spec documents for
PUT only ever worked for a payload without a "name" - which the spec
marks as required.
`addToTable()` now takes the upsert whenever the name is absent or equal
to the URI item, and reports "Group not found" when the rename changed no
row; `api_list_write()` turns that into the usual 400 database_error.
After a successful write the reply item and the Location header carry the
name the group has now.
Signed-off-by: 010011110 <duckenheim@posteo.de>
The clients object was keyed by the address from `client_by_id` and
carried only the name, while history[].data was keyed by the raw
`query_storage.client` column, which holds the `client_by_id` id. The two
halves of the response could not be joined, unlike the in-memory
`/api/history/clients` that shares the client_history schema and reports
{name, total} per address.
The slot query also grouped by `(timestamp/:interval)*:interval`, which
is a real division since the timestamps are stored with a fractional
part, so nothing was grouped: the data object received one entry per
query, repeating the same key with a count of 1.
Join `client_by_id` in both statements and group by the address (an
address can have several ids, one per name it was seen with), add the
per-client total, and truncate the timestamp before the integer division
so the slots form.
Signed-off-by: 010011110 <duckenheim@posteo.de>
The endpoint counted "cache" as `status = 3` only, "blocklist" as
`status NOT IN (0,2,3)`, and the per-upstream counts as every row with a
forward id. CACHE_STALE rows (17) therefore landed under "blocklist"
instead of "cache", RETRIED (12), RETRIED_DNSSEC (13) and IN_PROGRESS (14)
rows were reported as blocked, and an EXTERNAL_BLOCKED_* row, which keeps
its upstream for the record, was counted both under that upstream and
under "blocklist", so total_queries exceeded sum_queries of
`/api/stats/database/summary` for the same range.
Use the same sets as `is_blocked()`, `is_cached()` and `is_forwarded()`:
"cache" is `status IN (3,17)`, "blocklist" is FILTER_STATUS_BLOCKED, and
an upstream is credited with its `status IN (2,12,13)` rows only, which
is what the in-memory upstream counters hold. total_queries is now the
plain count over the range, as the summary endpoint reports it, and
forwarded_queries the sum of the upstream counts.
The upstream rows are also joined against `forward_by_id`, since
`query_storage.forward` holds the id of that table and the endpoint
returned it verbatim as the "ip" (`{"ip":"1","port":-1}`) instead of the
address and port.
Signed-off-by: 010011110 <duckenheim@posteo.de>
The loop runs from `TYPE_A` but binds `i + 1`, while `query_storage.type`
holds the enum value unchanged (and `100 + qtype` for `TYPE_OTHER`). Every
key therefore carries the count of the next type: "A" shows the AAAA
count, "PTR" the TXT count, "NS" (which binds `TYPE_OTHER`) and "HTTPS"
are always 0, and neither the A rows nor the rows stored as `100 + qtype`
are counted at all. The in-memory `/api/stats/query_types` and the type
filter of `/api/queries` use the enum value directly, so the two views
disagree on the same range.
Replace the per-type statements with one `GROUP BY type` pass, fold every
type >= 100 into `TYPE_OTHER`, and emit the keys in the same order as the
in-memory endpoint with OTHER last.
Signed-off-by: 010011110 <duckenheim@posteo.de>
Since the port of the local interface parsing to Netlink,
`add_local_interfaces_to_network_table()`
looks up `ifname`, `mac` and `addresses` on the objects `nllinks()`
returns. Those objects name the interface `name` and the MAC `address`
(`ifname` is only added in detailed mode), and the IP addresses are only
attached by `nladdrs()`, which this caller never invoked. Both lookups
return NULL for every link, the completeness guard skips each one, and
the host's own interfaces, MACs and IPs have not been written to the
network table since. With `debug.arp` enabled every run logs
"Successfully read links with N entries" directly followed by "Cleaning
up mock-devices".
Call `nladdrs()` after `nllinks()`, as `api_network_interfaces()` does,
and read `name` and `address`. The loop body is unchanged. The host
interfaces show up again, and 127.0.0.1 belongs to the loopback device
(00:00:00:00:00:00) instead of the mock device ip-127.0.0.1, which is
also what the old `ip address show` parser produced; the pytest
expectation that encoded the mock device is adjusted accordingly. A bats
test checks that the interface's MAC and address reach the network
table after a SIGRTMIN+5.
Signed-off-by: 010011110 <duckenheim@posteo.de>
A trailing slash can be part of a value in any string array, e.g., `Location: /` in `webserver.headers`, not only in `misc.dnsmasq_lines`. The value is no longer trimmed. A `DELETE` that finds nothing tries again without the trailing slash, as the URI may simply end in one.
Signed-off-by: DL6ER <dl6er@dl6er.de>
`PATCH /api/config/<element>` is accepted, but only the row without parameters carries `PATCH`, and that row was not taken to fit a URI with an element in it. All rows of `/api/config` fit a URI that is at least as long as they expect now.
Signed-off-by: DL6ER <dl6er@dl6er.de>
Bring v6.7.1 back into `development`. Most of its fixes are cherry-picks that are here already, so the conflicts are resolved in favor of the `development` form wherever both sides carry the same change (gravity write connection, `log_web()`, OpenSSL instead of mbedTLS, install paths). What is new on `master` is taken over: the fixes from the v6.7.1 review and the four security advisories.
The mbedTLS debug threshold is dropped as there is no mbedTLS here, `validate_filepath_dash()` stays removed, the webserver option list carries `tcp_nodelay` next to the pinned script patterns, and the expected number of `pihole.toml` writes in the tests is 31.
Signed-off-by: DL6ER <dl6er@dl6er.de>
The Teleporter import ran the validators in its own caller, the config file ran
none at all, and the legacy migration had a third copy - three loops over
`CONFIG_ELEMENTS` disagreeing on when a parsed configuration is acceptable.
Fold them into `validate_config()` and call it from `readFTLtoml()`, which both
TOML paths go through.
It runs once the whole file has been read rather than per item, because
`migrate_config()` assigns values of its own afterwards: `dns.revServer` becomes
`dns.revServers[0]` by joining four strings, and nothing checked the result. The
rules spanning several items need the assembled configuration anyway, so the
separate path check folds in as well.
An archive is still refused outright, naming the offending item, and the error
buffer carries that name out to the API. Refusing to start over one bad value in
the config file would take DNS down for the whole network, so there the item
goes back to its default and the reason is logged.
Signed-off-by: DL6ER <dl6er@dl6er.de>
(cherry picked from commit ccd269440a0ca0226e62bdbf9205a00e36efeca6)
`readFTLtoml()` parses, it does not validate. The API, the CLI and environment
variables all run the validator each config item declares, so the Teleporter
import was the one way into the running configuration that ran none of them -
and it is reachable by anyone holding an admin session.
That is not a theoretical gap. The checks rejecting embedded newlines in
`dns.hostRecord`, `dhcp.hosts`, `dhcp.leaseTime`, `dns.cnameRecords`,
`dns.upstreams` and `misc.dnsmasq_lines` are what stops a value from carrying a
second, arbitrary directive into the generated dnsmasq configuration. Sending
such a value to `PATCH /api/config` is refused; putting the same value in an
archive was accepted, and the injected directive appeared in `dnsmasq.conf`.
Run every item's validator on the parsed configuration before installing it, and
reject the archive naming the offending item. A configuration written through
any supported path already satisfies these checks, so a genuine backup imports
unchanged; one carrying a value the API would refuse no longer gets in through
the side door.
The whole-configuration path check stays, as the rules spanning several items
cannot be expressed by a validator that sees one value at a time.
Signed-off-by: DL6ER <dl6er@dl6er.de>
(cherry picked from commit 66ed82f8daf2b7e17c8bcedf2456ef2938630d99)
`DELETE /api/config/dns/upstreams/8.8.8.8/` has always addressed the entry `8.8.8.8`, and nothing strips the slash from a host or a client later on. The slash stays part of the value only where it can mean something, i.e., for lines such as `local=/lan/`.
Signed-off-by: DL6ER <dl6er@dl6er.de>
The `Allow` header was the union of all rows that fit the URI in some way. A domain with a slash in it also picked up the methods of the shorter `/api/domains` rows, `/api/info/messages/count` also matched the `{message_id}` row, and a path below `/api/docs` matched no row at all.
`row_rank()` now ranks the rows: a row taking exactly this URI beats one whose last parameter takes the rest of it, a longer endpoint beats a shorter one, and among the latter the row with the most parameters wins. `/api/docs` takes every path below it.
Signed-off-by: DL6ER <dl6er@dl6er.de>
The URI is decoded before `parameters_match()` counts its components, so a list address or a client subnet looked like several components and matched no row. `OPTIONS` came back with an empty `Allow` header and a wrong method got a 404 instead of a 405.
Rows that expect fewer components than the URI has are now used when no row matches exactly.
Signed-off-by: DL6ER <dl6er@dl6er.de>
`api_config_put_delete()` rebuilt the value from the components `gen_config_path()` returns. This function stops at `MAX_CONFIG_PATH_DEPTH`, so a value with many slashes lost everything behind the sixth component of the path. A trailing slash was lost as well, although it is part of entries such as `local=/lan/` in `misc.dnsmasq_lines`.
The value is now everything that follows the path of the config item in the request.
Signed-off-by: DL6ER <dl6er@dl6er.de>
`parameters_match()` compares the number of path components of the URI with the number of parameters a table row expects. This does not work for `/api/config`, where an element such as `dns/cache/size` is a path of its own. `OPTIONS` on such a URI came back with an empty `Allow` header and a wrong method got a 404 instead of a 405.
The rows of `/api/config` now take every URI that has at least as many components as they expect.
Signed-off-by: DL6ER <dl6er@dl6er.de>
A `DELETE` on the GET-only `/api/stats/summary` is a 405 whose `Allow` names
`GET` and `OPTIONS`, a `PATCH` on `/api/dns/blocking` adds `POST`, and a URI that
does not exist at all still answers 404 with no `Allow` header.
Three more for the ways the header can be wrong rather than absent:
`/api/domains` without arguments must not advertise the `DELETE` that only its
three-parameter row takes, while `/api/domains/deny/exact` must advertise the
`POST` that its two-parameter row does; `/api/domains/deny/` has to answer the
same as `/api/domains/deny`; and `GET` on a documentation file that does not
exist has to stay a 404 instead of becoming a 405 that refuses `GET`.
Signed-off-by: 010011110 <duckenheim@posteo.de>
(cherry picked from commit 16558dacc6)
(cherry picked from commit 92084c3291aee1b0341db5f917a7b2af956ea6be)
Add an API regression test that excludes the four highest-count domains
and then requests `top_domains` with `count=1`, where the heap capacity is
exactly four. Before the fix the excluded domains filled the heap and were
dropped at output, so the endpoint returned nothing; now the filter runs
before the heap and the request still yields the first non-excluded domain.
References #2946
Signed-off-by: DL6ER <dl6er@dl6er.de>
(cherry picked from commit a2c93b2eee)
(cherry picked from commit 69f354c89aa57898df07c856309056062541240c)
Add an API regression test that requests `top_domains` with small `count`
values and asserts the result is exactly the top-K prefix of the full list,
in the right order and without dropping legitimate domains. A small count
shrinks the heap capacity to `count*4`, so this exercises the bounded top-K
eviction path guarded by the preceding fix.
References #2946
Signed-off-by: DL6ER <dl6er@dl6er.de>
(cherry picked from commit 8dcaf4f40d)
(cherry picked from commit fb3b767fc1791c23c37fd524900cc3bb1b2da023)
Signed-off-by: Henry Sowell <henrysowell@gmail.com>
(cherry picked from commit 8d4b1d3d0e)
(cherry picked from commit c35c6de40839164dda0a409f1d02a7ca632ce0de)
The API test suite asserts exact DNS query counters. Several of them
(`TOTAL`, `DNSKEY`, `DS`, `TOP_DOMAIN`) depend on how many DNSKEY/DS
lookups dnsmasq issues while validating DNSSEC. Until now those lookups
recursed to the live ICANN root, because FTL configures the real root
trust anchors whenever `dns.dnssec` is enabled and the local PowerDNS
recursor had no root zone of its own. The number of root DNSKEY queries
therefore tracked ICANN's published root key set, so an ongoing
key-signing-key rollover silently shifted the counters (9 -> 7 DNSKEY)
and broke the suite even on unrelated PRs.
We make the whole suite hermetic:
1. Serve a locally-signed root zone from PowerDNS, forward `.` to it and
trust its key, so root DNSKEY validation resolves inside the test
environment instead of reaching the internet.
2. Mark the locally-served *unsigned* zones (`icloud.com`,
`apple-dns.net`, `in-addr.arpa`, `ip6.arpa`) as local domains, so
dnsmasq no longer proves them unsigned by walking up to the real root.
3. Give the `bogus` zone a deliberately mismatched local trust anchor so
it fails validation locally rather than by failing to find a secure
delegation at the root.
4. Drop the root-key pre-warm `dig`, an internet round-trip that no
longer serves any purpose.
With no query leaving for the real root the counters are stable and
independent of ICANN key rollovers, so they are recalibrated
accordingly. Marking the extra zones as local emits the same "negative
DS reply without NS record" warning already whitelisted for `ftl`, so
the `test_final` whitelist is broadened to match it for any zone.
Signed-off-by: DL6ER <dl6er@dl6er.de>
(cherry picked from commit 45561054c11d2386210a6e9810b7d64aa647cea8)
Cross-origin web apps could not use DELETE endpoints. Their 204 No Content
answer, like every error response, goes through
`my_send_http_error_headers()`, which did not call `send_cors_header()`, so
the browser discarded it for lack of `Access-Control-Allow-Origin`. The 200
responses sent via `mg_send_http_ok()` always carried it.
Preflights need no change in FTL: CivetWeb answers every request carrying
both `Origin` and `Access-Control-Request-Method` itself before it reaches
`api_handler()`. The tests cover both the preflight and the CORS header on a
non-200 response.
Closes#2261
Signed-off-by: DL6ER <dl6er@dl6er.de>