Initial commit
This commit is contained in:
commit
542ebdea34
40 changed files with 4592 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-http
|
||||
checker-http.so
|
||||
17
Dockerfile
Normal file
17
Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM golang:1.25-alpine AS builder
|
||||
|
||||
ARG CHECKER_VERSION=custom-build
|
||||
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 go build -tags standalone -ldflags "-X main.Version=${CHECKER_VERSION}" -o /checker-http .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-http /checker-http
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker-http", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker-http"]
|
||||
682
LICENSE
Normal file
682
LICENSE
Normal file
|
|
@ -0,0 +1,682 @@
|
|||
happyDomain Licensing
|
||||
|
||||
SOFTWARE LICENSING
|
||||
|
||||
You may be licensed to use source code to create compiled versions not produced
|
||||
by the happyDNS team in one of two ways:
|
||||
|
||||
1. Under the AGPL v3, subject to the exceptions outlined in this policy;
|
||||
2. Under a commercial license available from the happyDNS team by contacting
|
||||
contact@happydns.org.
|
||||
|
||||
happyDNS and happyDomain TRADEMARK GUIDELINES
|
||||
|
||||
Your use of the mark happyDNS and happyDomain is subject to the happyDNS team's
|
||||
prior written approval and our organization’s Trademark Standards of Use at
|
||||
http://www.happydomain.org/trademark-standards-of-use/. For trademark approval
|
||||
or any questions you have about using these trademarks, please email
|
||||
contact@happydns.org
|
||||
|
||||
------------------------------------------------------------------------------------------------------------------------------
|
||||
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 2007
|
||||
|
||||
Copyright (C) 2007 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 GNU Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users.
|
||||
|
||||
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
|
||||
them 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.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey 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;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If 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 convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero 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 that a certain numbered version of the GNU Affero General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU Affero General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU Affero General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
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.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
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.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
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
|
||||
state 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 Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 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 Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
28
Makefile
Normal file
28
Makefile
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
CHECKER_NAME := checker-http
|
||||
CHECKER_IMAGE := happydomain/$(CHECKER_NAME)
|
||||
CHECKER_VERSION ?= custom-build
|
||||
|
||||
CHECKER_SOURCES := main.go $(wildcard checker/*.go)
|
||||
|
||||
GO_LDFLAGS := -X main.Version=$(CHECKER_VERSION)
|
||||
|
||||
.PHONY: all plugin docker test clean
|
||||
|
||||
all: $(CHECKER_NAME)
|
||||
|
||||
$(CHECKER_NAME): $(CHECKER_SOURCES)
|
||||
go build -tags standalone -ldflags "$(GO_LDFLAGS)" -o $@ .
|
||||
|
||||
plugin: $(CHECKER_NAME).so
|
||||
|
||||
$(CHECKER_NAME).so: $(CHECKER_SOURCES) $(wildcard plugin/*.go)
|
||||
go build -buildmode=plugin -ldflags "$(GO_LDFLAGS)" -o $@ ./plugin/
|
||||
|
||||
docker:
|
||||
docker build --build-arg CHECKER_VERSION=$(CHECKER_VERSION) -t $(CHECKER_IMAGE) .
|
||||
|
||||
test:
|
||||
go test -tags standalone ./...
|
||||
|
||||
clean:
|
||||
rm -f $(CHECKER_NAME) $(CHECKER_NAME).so
|
||||
24
NOTICE
Normal file
24
NOTICE
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
checker-http
|
||||
Copyright (c) 2020-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This product is currently licensed under the GNU Affero General Public
|
||||
License v3.0 (see LICENSE), because it imports types from the happyDomain
|
||||
server module which is itself licensed under AGPL-3.0.
|
||||
|
||||
A relicensing to the MIT License is planned once that dependency has been
|
||||
removed. See README.md for the licensing roadmap.
|
||||
|
||||
-------------------------------------------------------------------------------
|
||||
Third-party notices
|
||||
-------------------------------------------------------------------------------
|
||||
|
||||
This product includes software developed as part of the checker-sdk-go
|
||||
project (https://git.happydns.org/happyDomain/checker-sdk-go), licensed
|
||||
under the Apache License, Version 2.0:
|
||||
|
||||
checker-sdk-go
|
||||
Copyright 2020-2026 The happyDomain Authors
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
67
README.md
Normal file
67
README.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# checker-http
|
||||
|
||||
HTTP/HTTPS server checker plugin for [happyDomain](https://happydomain.org).
|
||||
|
||||
Probes the `abstract.Server` it is attached to over HTTP (port 80) and HTTPS
|
||||
(port 443) and evaluates a battery of independent rules on the response.
|
||||
|
||||
Deep TLS / certificate analysis is intentionally **delegated to
|
||||
[checker-tls](https://git.happydns.org/checker-tls)** - this checker only
|
||||
relies on TLS for transport.
|
||||
|
||||
## What it checks
|
||||
|
||||
| Rule | What it verifies |
|
||||
| --------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `http.tcp_reachable` | Port 80 accepts connections on every A/AAAA address. |
|
||||
| `https.tcp_reachable` | Port 443 accepts connections on every A/AAAA address. |
|
||||
| `http.https_redirect` | Plain HTTP redirects to HTTPS (warning if not). |
|
||||
| `http.hsts` | `Strict-Transport-Security` is present with a sufficient `max-age`. |
|
||||
| `http.csp` | `Content-Security-Policy` is set; flags `'unsafe-inline'` / `'unsafe-eval'`. |
|
||||
| `http.x_frame_options` | `X-Frame-Options` or CSP `frame-ancestors` provides clickjacking protection. |
|
||||
| `http.x_content_type_options` | `X-Content-Type-Options: nosniff` is set. |
|
||||
| `http.x_xss_protection` | Reports the legacy `X-XSS-Protection` header (recommendation: disable). |
|
||||
| `http.cookie_flags` | Every Set-Cookie has `Secure`, `HttpOnly`, and a `SameSite` attribute. |
|
||||
| `http.sri` | Cross-origin `<script>`/`<link>` tags carry `integrity=` (Subresource Integrity). |
|
||||
|
||||
## Options
|
||||
|
||||
User-configurable:
|
||||
|
||||
- `probeTimeoutMs`: per-request timeout (default: 10000)
|
||||
- `maxRedirects`: redirect hops to follow (default: 5)
|
||||
- `userAgent`: User-Agent header to send (default: `happyDomain-checker-http/1.0`)
|
||||
- `requireHTTPS`: flag plain HTTP that does not redirect (default: true)
|
||||
- `requireHSTS`: require Strict-Transport-Security on HTTPS (default: true)
|
||||
- `minHSTSMaxAgeDays`: minimum acceptable HSTS max-age in days (default: 180)
|
||||
- `requireCSP`: require Content-Security-Policy on HTTPS (default: false)
|
||||
|
||||
## Deployment
|
||||
|
||||
The `/collect` endpoint has no built-in authentication and will issue HTTP
|
||||
requests to whatever IP addresses the target `abstract.Server` advertises
|
||||
(including those discovered via the system resolver for additional A/AAAA
|
||||
records). Because those addresses are user-controlled, a domain pointing at
|
||||
`127.0.0.1`, an RFC1918 range, or a cloud metadata endpoint
|
||||
(`169.254.169.254`) will cause the checker to fetch internal resources and
|
||||
return their headers, cookies, and HTML body in the observation payload,
|
||||
a classic SSRF surface.
|
||||
|
||||
It is meant to run on a trusted network, reachable only by the happyDomain
|
||||
instance that drives it, and from a network position that cannot reach
|
||||
internal services or metadata endpoints. Restrict access via a reverse
|
||||
proxy with authentication, a network ACL, or by binding the listener to a
|
||||
private interface; do not expose it directly to the public internet, and
|
||||
prefer running it from an egress-restricted network segment.
|
||||
|
||||
## Build
|
||||
|
||||
```sh
|
||||
make # standalone binary: ./checker-http
|
||||
make plugin # Go plugin .so: ./checker-http.so
|
||||
make docker # Docker image: happydomain/checker-http
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
AGPL-3.0 (see [LICENSE](LICENSE) and [NOTICE](NOTICE)).
|
||||
400
checker/collect.go
Normal file
400
checker/collect.go
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// verboseLogging is enabled via the CHECKER_HTTP_VERBOSE environment variable;
|
||||
// when off, per-probe logging is silenced to keep production logs clean.
|
||||
var verboseLogging = os.Getenv("CHECKER_HTTP_VERBOSE") != ""
|
||||
|
||||
// Collect resolves the Target from CheckerOptions, runs the root
|
||||
// collector synchronously (its output is the canonical HTTPData), then
|
||||
// runs every registered Collector in parallel and merges their JSON
|
||||
// payloads into HTTPData.Extensions under their Key().
|
||||
func (p *httpProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
target, err := buildTarget(ctx, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rootOut, err := rootCollector{}.Collect(ctx, target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := rootOut.(*HTTPData)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("rootCollector returned %T, expected *HTTPData", rootOut)
|
||||
}
|
||||
|
||||
registry.mu.Lock()
|
||||
collectors := append([]Collector(nil), registry.collectors...)
|
||||
registry.mu.Unlock()
|
||||
if len(collectors) == 0 {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
type result struct {
|
||||
key string
|
||||
raw json.RawMessage
|
||||
err error
|
||||
}
|
||||
// Each collector may issue several probes (one per scheme × IP), so we
|
||||
// budget it as runProbe does (timeout × (maxRedirects+1)) multiplied by
|
||||
// a small factor for the fan-out. The deadline is shared so a single
|
||||
// hung collector cannot keep the caller waiting longer than the
|
||||
// slowest legitimate collector.
|
||||
collectorBudget := target.Timeout * time.Duration(target.MaxRedirects+1) * 4
|
||||
cctx, cancel := context.WithTimeout(ctx, collectorBudget)
|
||||
defer cancel()
|
||||
|
||||
results := make(chan result, len(collectors))
|
||||
for _, c := range collectors {
|
||||
go func(c Collector) {
|
||||
out, err := c.Collect(cctx, target)
|
||||
if err != nil {
|
||||
results <- result{key: c.Key(), err: err}
|
||||
return
|
||||
}
|
||||
raw, mErr := json.Marshal(out)
|
||||
results <- result{key: c.Key(), raw: raw, err: mErr}
|
||||
}(c)
|
||||
}
|
||||
|
||||
exts := make(map[string]json.RawMessage, len(collectors))
|
||||
pending := len(collectors)
|
||||
for pending > 0 {
|
||||
select {
|
||||
case r := <-results:
|
||||
pending--
|
||||
if r.err != nil {
|
||||
if verboseLogging {
|
||||
log.Printf("checker-http: collector %q failed: %v", r.key, r.err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
exts[r.key] = r.raw
|
||||
case <-cctx.Done():
|
||||
if verboseLogging {
|
||||
log.Printf("checker-http: %d collector(s) did not return before deadline (%v); abandoning", pending, cctx.Err())
|
||||
}
|
||||
pending = 0
|
||||
}
|
||||
}
|
||||
if len(exts) > 0 {
|
||||
data.Extensions = exts
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// LoadExtension decodes a sub-observation written by a Collector into the
|
||||
// caller-supplied typed value. Returns false (without error) when the
|
||||
// extension is absent — most rules treat that as "no_data" rather than
|
||||
// an error.
|
||||
func LoadExtension[T any](data *HTTPData, key string) (*T, bool, error) {
|
||||
raw, ok := data.Extensions[key]
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
var v T
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, true, fmt.Errorf("decode extension %q: %w", key, err)
|
||||
}
|
||||
return &v, true, nil
|
||||
}
|
||||
|
||||
// buildTarget centralises option parsing and IP discovery so every
|
||||
// Collector receives a fully resolved Target.
|
||||
func buildTarget(ctx context.Context, opts sdk.CheckerOptions) (Target, error) {
|
||||
server, err := resolveServer(opts)
|
||||
if err != nil {
|
||||
return Target{}, err
|
||||
}
|
||||
|
||||
timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs)
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = DefaultProbeTimeoutMs
|
||||
}
|
||||
maxRedirects := sdk.GetIntOption(opts, OptionMaxRedirects, DefaultMaxRedirects)
|
||||
if maxRedirects < 0 {
|
||||
maxRedirects = DefaultMaxRedirects
|
||||
}
|
||||
userAgent := DefaultUserAgent
|
||||
if v, ok := sdk.GetOption[string](opts, OptionUserAgent); ok && v != "" {
|
||||
userAgent = v
|
||||
}
|
||||
|
||||
host, ips := addressesFromServer(server)
|
||||
// abstract.Server only pins one A and one AAAA. Resolve the host to
|
||||
// pick up any additional records the authoritative DNS exposes, so
|
||||
// multi-IP deployments aren't silently under-probed. Failures are
|
||||
// non-fatal; the pinned IPs remain.
|
||||
seen := make(map[string]struct{}, len(ips)+4)
|
||||
for _, ip := range ips {
|
||||
seen[ip] = struct{}{}
|
||||
}
|
||||
ips = append(ips, discoverIPs(ctx, host, seen)...)
|
||||
if len(ips) == 0 {
|
||||
return Target{}, fmt.Errorf("abstract.Server has no A/AAAA records")
|
||||
}
|
||||
|
||||
return Target{
|
||||
Host: host,
|
||||
IPs: ips,
|
||||
Timeout: time.Duration(timeoutMs) * time.Millisecond,
|
||||
MaxRedirects: maxRedirects,
|
||||
UserAgent: userAgent,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func runProbe(ctx context.Context, host, ip, scheme string, port uint16, timeout time.Duration, maxRedirects int, ua string, parseHTML bool) HTTPProbe {
|
||||
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
|
||||
probe := HTTPProbe{
|
||||
Scheme: scheme,
|
||||
Host: host,
|
||||
IP: ip,
|
||||
Port: port,
|
||||
Address: addr,
|
||||
IsIPv6: strings.Contains(ip, ":"),
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: timeout}
|
||||
// tcpConnected is set the moment a dial succeeds, so we can
|
||||
// distinguish pure-TCP failures from later TLS/HTTP errors without
|
||||
// resorting to error-string matching.
|
||||
var tcpConnected atomic.Bool
|
||||
// Force every dial to the chosen IP, regardless of what hostname is
|
||||
// in the URL; that way we can attribute results to a specific A/AAAA
|
||||
// record and bypass local resolver oddities.
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
conn, err := dialer.DialContext(ctx, network, addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tcpConnected.Store(true)
|
||||
return conn, nil
|
||||
},
|
||||
TLSClientConfig: &tls.Config{
|
||||
ServerName: host,
|
||||
// Deep TLS posture is delegated to checker-tls. We still want
|
||||
// HTTPS errors (expired cert, bad chain, ...) to surface as
|
||||
// probe errors, so verification stays enabled.
|
||||
},
|
||||
TLSHandshakeTimeout: timeout,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
|
||||
// Bound the whole probe (dial + TLS + headers + body across all
|
||||
// redirect hops) by a single per-probe deadline derived from ctx, so
|
||||
// a slow target can't pin a worker beyond the parent's lifetime and
|
||||
// outer cancellation propagates to in-flight I/O.
|
||||
probeBudget := timeout * time.Duration(maxRedirects+1)
|
||||
probeCtx, cancel := context.WithTimeout(ctx, probeBudget)
|
||||
defer cancel()
|
||||
|
||||
var redirectChain []RedirectStep
|
||||
client := &http.Client{
|
||||
Transport: transport,
|
||||
// No client-level Timeout: probeCtx already bounds the request,
|
||||
// and a separate http.Client.Timeout would race with it.
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
prev := via[len(via)-1]
|
||||
redirectChain = append(redirectChain, RedirectStep{
|
||||
From: prev.URL.String(),
|
||||
To: req.URL.String(),
|
||||
Status: 0, // populated post-hoc below if available
|
||||
})
|
||||
// The transport's DialContext is pinned to the original
|
||||
// (ip, port) and TLS ServerName is pinned to the original
|
||||
// host. Following a redirect that changes host, scheme, or
|
||||
// port would silently route the request to the wrong
|
||||
// backend. Stop and return the 3xx so the caller can see
|
||||
// the Location, but don't follow it on this probe.
|
||||
if !strings.EqualFold(req.URL.Host, host) ||
|
||||
!strings.EqualFold(req.URL.Scheme, scheme) {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
if len(via) > maxRedirects {
|
||||
return fmt.Errorf("stopped after %d redirects", maxRedirects)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
target := &url.URL{Scheme: scheme, Host: host, Path: "/"}
|
||||
req, err := http.NewRequestWithContext(probeCtx, http.MethodGet, target.String(), nil)
|
||||
if err != nil {
|
||||
probe.Error = err.Error()
|
||||
return probe
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
req.Header.Set("Accept", "text/html,application/xhtml+xml;q=0.9,*/*;q=0.5")
|
||||
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
probe.ElapsedMS = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
probe.Error = err.Error()
|
||||
// The dialer wrapper sets tcpConnected the moment a TCP
|
||||
// connection is established, so we can attribute the failure
|
||||
// to a post-TCP layer (TLS, HTTP, redirect policy) without
|
||||
// any error-string heuristics.
|
||||
probe.TCPConnected = tcpConnected.Load()
|
||||
probe.RedirectChain = redirectChain
|
||||
return probe
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
probe.TCPConnected = true
|
||||
probe.StatusCode = resp.StatusCode
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
probe.FinalURL = resp.Request.URL.String()
|
||||
}
|
||||
|
||||
// Per RFC 7230 §3.2.2, repeated headers (other than Set-Cookie) are
|
||||
// semantically equivalent to a single header whose value is the
|
||||
// comma-joined list; folding here preserves directives like a second
|
||||
// CSP or HSTS header that would otherwise be dropped. Set-Cookie is
|
||||
// excluded from the map since cookies are surfaced via resp.Cookies().
|
||||
probe.Headers = make(map[string]string, len(resp.Header))
|
||||
for k, v := range resp.Header {
|
||||
if len(v) == 0 {
|
||||
continue
|
||||
}
|
||||
lk := strings.ToLower(k)
|
||||
if lk == "set-cookie" {
|
||||
continue
|
||||
}
|
||||
probe.Headers[lk] = strings.Join(v, ", ")
|
||||
}
|
||||
|
||||
for _, c := range resp.Cookies() {
|
||||
probe.Cookies = append(probe.Cookies, CookieInfo{
|
||||
Name: c.Name,
|
||||
Domain: c.Domain,
|
||||
Path: c.Path,
|
||||
Secure: c.Secure,
|
||||
HttpOnly: c.HttpOnly,
|
||||
SameSite: sameSiteString(c.SameSite),
|
||||
HasExpiry: !c.Expires.IsZero() || c.MaxAge > 0,
|
||||
})
|
||||
}
|
||||
probe.RedirectChain = redirectChain
|
||||
|
||||
// Read one extra byte to detect whether we hit the cap. Anything
|
||||
// beyond MaxBodyBytes is dropped, but the probe surfaces
|
||||
// BodyTruncated so callers know SRI/HTML rules saw a partial view.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodyBytes+1))
|
||||
if err == nil {
|
||||
if len(body) > MaxBodyBytes {
|
||||
body = body[:MaxBodyBytes]
|
||||
probe.BodyTruncated = true
|
||||
}
|
||||
probe.HTMLBytes = len(body)
|
||||
if parseHTML && isHTMLContent(probe.Headers["content-type"]) {
|
||||
probe.Resources = extractResources(body, host)
|
||||
}
|
||||
}
|
||||
|
||||
return probe
|
||||
}
|
||||
|
||||
func sameSiteString(s http.SameSite) string {
|
||||
switch s {
|
||||
case http.SameSiteLaxMode:
|
||||
return "Lax"
|
||||
case http.SameSiteStrictMode:
|
||||
return "Strict"
|
||||
case http.SameSiteNoneMode:
|
||||
return "None"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isHTMLContent(ct string) bool {
|
||||
ct = strings.ToLower(ct)
|
||||
return strings.Contains(ct, "text/html") || strings.Contains(ct, "application/xhtml")
|
||||
}
|
||||
|
||||
// extractResources walks the HTML body and collects <script src=...>,
|
||||
// <link href=... rel="stylesheet"|"preload"...> and inline-eligible <img>
|
||||
// references, with a flag for whether the resource is cross-origin
|
||||
// (different host than the page); SRI is only meaningful in that case.
|
||||
func extractResources(body []byte, pageHost string) []HTMLResource {
|
||||
doc, err := html.Parse(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []HTMLResource
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode {
|
||||
switch n.Data {
|
||||
case "script":
|
||||
if src, ok := attr(n, "src"); ok && src != "" {
|
||||
out = append(out, mkResource("script", src, n, pageHost))
|
||||
}
|
||||
case "link":
|
||||
rel, _ := attr(n, "rel")
|
||||
if href, ok := attr(n, "href"); ok && href != "" && relIsAsset(rel) {
|
||||
r := mkResource("link", href, n, pageHost)
|
||||
r.Rel = rel
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
return out
|
||||
}
|
||||
|
||||
func relIsAsset(rel string) bool {
|
||||
rel = strings.ToLower(rel)
|
||||
return strings.Contains(rel, "stylesheet") || strings.Contains(rel, "preload") || strings.Contains(rel, "modulepreload")
|
||||
}
|
||||
|
||||
func mkResource(tag, ref string, n *html.Node, pageHost string) HTMLResource {
|
||||
r := HTMLResource{Tag: tag, URL: ref}
|
||||
if integ, ok := attr(n, "integrity"); ok && integ != "" {
|
||||
r.Integrity = integ
|
||||
}
|
||||
if u, err := url.Parse(ref); err == nil && u.Host != "" && !strings.EqualFold(u.Host, pageHost) {
|
||||
r.CrossOrigin = true
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func attr(n *html.Node, key string) (string, bool) {
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, key) {
|
||||
return a.Val, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
305
checker/collect_test.go
Normal file
305
checker/collect_test.go
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
// splitHostPort parses an httptest server URL into ("ip", port).
|
||||
func splitHostPort(t *testing.T, raw string) (string, uint16) {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %q: %v", raw, err)
|
||||
}
|
||||
host, portStr, err := net.SplitHostPort(u.Host)
|
||||
if err != nil {
|
||||
t.Fatalf("split host port %q: %v", u.Host, err)
|
||||
}
|
||||
p, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
t.Fatalf("port %q: %v", portStr, err)
|
||||
}
|
||||
return host, uint16(p)
|
||||
}
|
||||
|
||||
func TestRunProbe_HTTPSuccess(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Frame-Options", "DENY")
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
http.SetCookie(w, &http.Cookie{Name: "sid", Value: "v", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
probe := runProbe(context.Background(), ip /* host=ip so default Host header matches */, ip, "http", port, 2*time.Second, 0, "test-ua", false)
|
||||
|
||||
if probe.Error != "" {
|
||||
t.Fatalf("unexpected error: %q", probe.Error)
|
||||
}
|
||||
if !probe.TCPConnected || probe.StatusCode != 200 {
|
||||
t.Fatalf("unexpected probe result: %+v", probe)
|
||||
}
|
||||
if probe.Headers["x-frame-options"] != "DENY" {
|
||||
t.Errorf("missing x-frame-options header: %+v", probe.Headers)
|
||||
}
|
||||
if len(probe.Cookies) != 1 || probe.Cookies[0].Name != "sid" || !probe.Cookies[0].Secure || !probe.Cookies[0].HttpOnly || probe.Cookies[0].SameSite != "Lax" {
|
||||
t.Errorf("unexpected cookies: %+v", probe.Cookies)
|
||||
}
|
||||
if probe.IsIPv6 {
|
||||
t.Errorf("IPv4 address mis-detected as IPv6")
|
||||
}
|
||||
if probe.Address != net.JoinHostPort(ip, fmt.Sprintf("%d", port)) {
|
||||
t.Errorf("address: %q", probe.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_TCPConnectionRefused(t *testing.T) {
|
||||
// Pick a port we know nothing listens on by binding then immediately closing.
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
_, portStr, _ := net.SplitHostPort(l.Addr().String())
|
||||
p, _ := strconv.ParseUint(portStr, 10, 16)
|
||||
_ = l.Close()
|
||||
|
||||
probe := runProbe(context.Background(), "127.0.0.1", "127.0.0.1", "http", uint16(p), 500*time.Millisecond, 0, "ua", false)
|
||||
if probe.Error == "" {
|
||||
t.Fatal("expected error from probing closed port")
|
||||
}
|
||||
if probe.TCPConnected {
|
||||
t.Errorf("TCPConnected should be false on dial failure: %+v", probe)
|
||||
}
|
||||
if probe.StatusCode != 0 {
|
||||
t.Errorf("StatusCode should be 0, got %d", probe.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_BodyTruncation(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
// Write more than MaxBodyBytes.
|
||||
buf := strings.Repeat("a", MaxBodyBytes+4096)
|
||||
_, _ = io.WriteString(w, buf)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 0, "ua", true)
|
||||
if !probe.BodyTruncated {
|
||||
t.Errorf("expected BodyTruncated=true, got probe=%+v", probe)
|
||||
}
|
||||
if probe.HTMLBytes != MaxBodyBytes {
|
||||
t.Errorf("HTMLBytes = %d, want %d", probe.HTMLBytes, MaxBodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_RedirectFollowedSameHost(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/dst" {
|
||||
w.WriteHeader(204)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/dst", http.StatusFound)
|
||||
})
|
||||
srv := httptest.NewServer(mux)
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 5, "ua", false)
|
||||
if probe.StatusCode != 204 {
|
||||
t.Errorf("status: got %d, want 204; chain=%+v err=%q", probe.StatusCode, probe.RedirectChain, probe.Error)
|
||||
}
|
||||
if len(probe.RedirectChain) != 1 {
|
||||
t.Errorf("redirect chain length: got %d, want 1: %+v", len(probe.RedirectChain), probe.RedirectChain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_RedirectStoppedCrossHost(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Redirect to a different host: the probe must NOT follow.
|
||||
http.Redirect(w, r, "https://elsewhere.invalid/", http.StatusMovedPermanently)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 5, "ua", false)
|
||||
if probe.StatusCode != http.StatusMovedPermanently {
|
||||
t.Errorf("status: got %d, want 301", probe.StatusCode)
|
||||
}
|
||||
if len(probe.RedirectChain) != 1 {
|
||||
t.Errorf("expected 1 recorded hop, got %d: %+v", len(probe.RedirectChain), probe.RedirectChain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_HTMLResourceExtraction(t *testing.T) {
|
||||
html := `<!doctype html><html><head>
|
||||
<script src="https://cdn.example/lib.js" integrity="sha384-abc"></script>
|
||||
<script src="/local.js"></script>
|
||||
<link rel="stylesheet" href="https://cdn.example/style.css">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
</head><body></body></html>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, html)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 0, "ua", true)
|
||||
if probe.Error != "" {
|
||||
t.Fatalf("error: %q", probe.Error)
|
||||
}
|
||||
if len(probe.Resources) != 3 {
|
||||
// Expect: cdn script, local script, cdn stylesheet. The icon link is ignored (rel=icon).
|
||||
t.Fatalf("got %d resources, want 3: %+v", len(probe.Resources), probe.Resources)
|
||||
}
|
||||
var cdnScript, localScript, stylesheet bool
|
||||
for _, r := range probe.Resources {
|
||||
switch r.URL {
|
||||
case "https://cdn.example/lib.js":
|
||||
cdnScript = true
|
||||
if !r.CrossOrigin || r.Integrity != "sha384-abc" {
|
||||
t.Errorf("cdn script: %+v", r)
|
||||
}
|
||||
case "/local.js":
|
||||
localScript = true
|
||||
if r.CrossOrigin {
|
||||
t.Errorf("local script flagged as cross-origin")
|
||||
}
|
||||
case "https://cdn.example/style.css":
|
||||
stylesheet = true
|
||||
if !r.CrossOrigin || r.Integrity != "" {
|
||||
t.Errorf("stylesheet: %+v", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cdnScript || !localScript || !stylesheet {
|
||||
t.Errorf("missing expected resources: cdnScript=%v local=%v stylesheet=%v", cdnScript, localScript, stylesheet)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_NonHTMLContentTypeSkipsExtraction(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = io.WriteString(w, `<script src="/x.js"></script>`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
probe := runProbe(context.Background(), ip, ip, "http", port, 5*time.Second, 0, "ua", true)
|
||||
if len(probe.Resources) != 0 {
|
||||
t.Errorf("non-HTML content-type should skip parsing, got %+v", probe.Resources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunProbe_ContextCancelled(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ip, port := splitHostPort(t, srv.URL)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
probe := runProbe(ctx, ip, ip, "http", port, 5*time.Second, 0, "ua", false)
|
||||
if probe.Error == "" {
|
||||
t.Errorf("expected error when context is already cancelled, got probe=%+v", probe)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsHTMLContent(t *testing.T) {
|
||||
yes := []string{"text/html", "TEXT/HTML; charset=utf-8", "application/xhtml+xml"}
|
||||
no := []string{"", "application/json", "text/plain", "image/png"}
|
||||
for _, ct := range yes {
|
||||
if !isHTMLContent(ct) {
|
||||
t.Errorf("isHTMLContent(%q) = false, want true", ct)
|
||||
}
|
||||
}
|
||||
for _, ct := range no {
|
||||
if isHTMLContent(ct) {
|
||||
t.Errorf("isHTMLContent(%q) = true, want false", ct)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelIsAsset(t *testing.T) {
|
||||
yes := []string{"stylesheet", "preload", "modulepreload", "STYLESHEET", "preload stylesheet"}
|
||||
no := []string{"", "icon", "alternate", "canonical"}
|
||||
for _, r := range yes {
|
||||
if !relIsAsset(r) {
|
||||
t.Errorf("relIsAsset(%q) = false, want true", r)
|
||||
}
|
||||
}
|
||||
for _, r := range no {
|
||||
if relIsAsset(r) {
|
||||
t.Errorf("relIsAsset(%q) = true, want false", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResources_EmptyAndMalformed(t *testing.T) {
|
||||
// The HTML parser is forgiving: even garbage produces no resources rather than panicking.
|
||||
if got := extractResources([]byte(""), "h"); got != nil {
|
||||
t.Errorf("empty body: got %+v, want nil", got)
|
||||
}
|
||||
if got := extractResources([]byte("<<<not really html>>>"), "h"); got != nil {
|
||||
t.Errorf("garbage: got %+v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractResources_SkipsScriptWithoutSrc(t *testing.T) {
|
||||
body := `<html><body><script>alert(1)</script><script src=""></script></body></html>`
|
||||
if got := extractResources([]byte(body), "h"); len(got) != 0 {
|
||||
t.Errorf("inline/empty-src scripts should not produce resources: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttrCaseInsensitive(t *testing.T) {
|
||||
doc, err := html.Parse(strings.NewReader(`<a HREF="x" Integrity="i"></a>`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found *html.Node
|
||||
var walk func(*html.Node)
|
||||
walk = func(n *html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "a" {
|
||||
found = n
|
||||
return
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
walk(c)
|
||||
}
|
||||
}
|
||||
walk(doc)
|
||||
if found == nil {
|
||||
t.Fatal("anchor not found")
|
||||
}
|
||||
if v, ok := attr(found, "href"); !ok || v != "x" {
|
||||
t.Errorf("href: got (%q,%v)", v, ok)
|
||||
}
|
||||
if v, ok := attr(found, "INTEGRITY"); !ok || v != "i" {
|
||||
t.Errorf("INTEGRITY: got (%q,%v)", v, ok)
|
||||
}
|
||||
if _, ok := attr(found, "missing"); ok {
|
||||
t.Errorf("missing attr should return ok=false")
|
||||
}
|
||||
}
|
||||
36
checker/collector.go
Normal file
36
checker/collector.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Target captures everything a Collector needs to probe one logical host.
|
||||
// It is built once by the orchestrator from CheckerOptions and passed to
|
||||
// every Collector, so individual collectors don't have to re-parse options
|
||||
// or re-resolve IPs.
|
||||
type Target struct {
|
||||
Host string
|
||||
IPs []string
|
||||
Timeout time.Duration
|
||||
MaxRedirects int
|
||||
UserAgent string
|
||||
}
|
||||
|
||||
// Collector contributes a typed observation about a Target. Each collector
|
||||
// owns one slice of the work (root probe, well-known endpoints, CORS
|
||||
// preflight, etc.) and writes its result under Key() in the final
|
||||
// payload's Extensions map.
|
||||
//
|
||||
// The current orchestrator wires only the root collector and writes its
|
||||
// result directly under ObservationKeyHTTP for backward compatibility.
|
||||
// Additional collectors are introduced in step 4; they will populate
|
||||
// HTTPData.Extensions[Key()] without disturbing existing rules.
|
||||
type Collector interface {
|
||||
Key() string
|
||||
Collect(ctx context.Context, t Target) (any, error)
|
||||
}
|
||||
73
checker/collector_root.go
Normal file
73
checker/collector_root.go
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// rootCollector probes the target host on HTTP/80 and HTTPS/443 for every
|
||||
// known IP, captures headers/cookies/redirects on each, and parses the
|
||||
// HTML body of the first successful HTTPS probe (so SRI-style rules have
|
||||
// something to evaluate). This is the original behaviour of Collect()
|
||||
// before the Collector interface was introduced.
|
||||
type rootCollector struct{}
|
||||
|
||||
func (rootCollector) Key() string { return ObservationKeyHTTP }
|
||||
|
||||
func (rootCollector) Collect(ctx context.Context, t Target) (any, error) {
|
||||
data := &HTTPData{
|
||||
Domain: t.Host,
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
type job struct {
|
||||
scheme string
|
||||
port uint16
|
||||
ip string
|
||||
// parseHTML controls whether the HTML body is parsed and its
|
||||
// references kept on the probe. Only the first HTTPS probe gets
|
||||
// it, to keep payload size bounded.
|
||||
parseHTML bool
|
||||
}
|
||||
|
||||
var jobs []job
|
||||
htmlPicked := false
|
||||
for _, ip := range t.IPs {
|
||||
jobs = append(jobs, job{scheme: "http", port: DefaultHTTPPort, ip: ip})
|
||||
j := job{scheme: "https", port: DefaultHTTPSPort, ip: ip}
|
||||
if !htmlPicked {
|
||||
j.parseHTML = true
|
||||
htmlPicked = true
|
||||
}
|
||||
jobs = append(jobs, j)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, MaxConcurrentProbes)
|
||||
for _, j := range jobs {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(j job) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
probe := runProbe(ctx, t.Host, j.ip, j.scheme, j.port, t.Timeout, t.MaxRedirects, t.UserAgent, j.parseHTML)
|
||||
if verboseLogging {
|
||||
log.Printf("checker-http: %s ip=%s status=%d redirects=%d err=%q",
|
||||
j.scheme, j.ip, probe.StatusCode, len(probe.RedirectChain), probe.Error)
|
||||
}
|
||||
mu.Lock()
|
||||
data.Probes = append(data.Probes, probe)
|
||||
mu.Unlock()
|
||||
}(j)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return data, nil
|
||||
}
|
||||
99
checker/collector_wellknown.go
Normal file
99
checker/collector_wellknown.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// ObservationKeyWellKnown is the Extensions[] key under which
|
||||
// wellknownCollector publishes its observation.
|
||||
const ObservationKeyWellKnown = "wellknown"
|
||||
|
||||
// WellKnownData captures whether each well-known URI returned a usable
|
||||
// document. It is intentionally narrow: per-URI presence and HTTP status
|
||||
// are enough for the current rule set; deeper parsing (e.g. PGP-signed
|
||||
// security.txt fields) is left to dedicated collectors when the need
|
||||
// arises.
|
||||
type WellKnownData struct {
|
||||
URIs map[string]WellKnownProbe `json:"uris"`
|
||||
}
|
||||
|
||||
// WellKnownProbe is a single (URI → outcome) entry.
|
||||
type WellKnownProbe struct {
|
||||
URL string `json:"url"`
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
Bytes int `json:"bytes,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// wellknownCollector probes a small, fixed set of standardised URIs
|
||||
// served at the apex of the host. Today it covers:
|
||||
//
|
||||
// - /.well-known/security.txt (RFC 9116) — security disclosure contact
|
||||
// - /robots.txt (RFC 9309) — crawler directives
|
||||
//
|
||||
// It uses the first IP only because these documents are expected to be
|
||||
// host-uniform: there is nothing to learn from probing every backend.
|
||||
type wellknownCollector struct{}
|
||||
|
||||
func (wellknownCollector) Key() string { return ObservationKeyWellKnown }
|
||||
|
||||
func (wellknownCollector) Collect(ctx context.Context, t Target) (any, error) {
|
||||
if len(t.IPs) == 0 {
|
||||
return nil, fmt.Errorf("no IPs to probe")
|
||||
}
|
||||
addr := net.JoinHostPort(t.IPs[0], "443")
|
||||
dialer := &net.Dialer{Timeout: t.Timeout}
|
||||
transport := &http.Transport{
|
||||
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
return dialer.DialContext(ctx, network, addr)
|
||||
},
|
||||
TLSClientConfig: &tls.Config{ServerName: t.Host},
|
||||
TLSHandshakeTimeout: t.Timeout,
|
||||
ResponseHeaderTimeout: t.Timeout,
|
||||
DisableKeepAlives: true,
|
||||
}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
uris := []string{"/.well-known/security.txt", "/robots.txt"}
|
||||
out := WellKnownData{URIs: make(map[string]WellKnownProbe, len(uris))}
|
||||
for _, path := range uris {
|
||||
out.URIs[path] = fetchOne(ctx, client, t.Host, path, t.UserAgent)
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func fetchOne(ctx context.Context, client *http.Client, host, path, ua string) WellKnownProbe {
|
||||
u := (&url.URL{Scheme: "https", Host: host, Path: path}).String()
|
||||
probe := WellKnownProbe{URL: u}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
probe.Error = err.Error()
|
||||
return probe
|
||||
}
|
||||
req.Header.Set("User-Agent", ua)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
probe.Error = err.Error()
|
||||
return probe
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
probe.StatusCode = resp.StatusCode
|
||||
// Cap the read so a misconfigured server can't pull megabytes for a
|
||||
// "did this exist?" probe.
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64<<10))
|
||||
probe.Bytes = len(body)
|
||||
return probe
|
||||
}
|
||||
|
||||
func init() { RegisterCollector(wellknownCollector{}) }
|
||||
94
checker/definition.go
Normal file
94
checker/definition.go
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is overridden at link time via -ldflags.
|
||||
var Version = "built-in"
|
||||
|
||||
func (p *httpProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "http",
|
||||
Name: "HTTP / HTTPS",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.Server"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeyHTTP},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionProbeTimeoutMs,
|
||||
Type: "number",
|
||||
Label: "Per-request timeout (ms)",
|
||||
Description: "Maximum time allowed for a single HTTP/HTTPS request.",
|
||||
Default: float64(DefaultProbeTimeoutMs),
|
||||
},
|
||||
{
|
||||
Id: OptionMaxRedirects,
|
||||
Type: "number",
|
||||
Label: "Max redirects to follow",
|
||||
Description: "Stop following redirects after this many hops.",
|
||||
Default: float64(DefaultMaxRedirects),
|
||||
},
|
||||
{
|
||||
Id: OptionUserAgent,
|
||||
Type: "string",
|
||||
Label: "User-Agent",
|
||||
Description: "User-Agent header sent with every request.",
|
||||
Default: DefaultUserAgent,
|
||||
},
|
||||
{
|
||||
Id: OptionRequireHTTPS,
|
||||
Type: "bool",
|
||||
Label: "Require HTTPS",
|
||||
Description: "Plain HTTP must redirect to HTTPS.",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: OptionRequireHSTS,
|
||||
Type: "bool",
|
||||
Label: "Require HSTS",
|
||||
Description: "HTTPS responses must include a Strict-Transport-Security header.",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: OptionMinHSTSMaxAgeDays,
|
||||
Type: "number",
|
||||
Label: "Min HSTS max-age (days)",
|
||||
Description: "Minimum acceptable max-age value (in days) for HSTS.",
|
||||
Default: float64(DefaultMinHSTSMaxAge),
|
||||
},
|
||||
{
|
||||
Id: OptionRequireCSP,
|
||||
Type: "bool",
|
||||
Label: "Require Content-Security-Policy",
|
||||
Description: "HTTPS responses must include a Content-Security-Policy header.",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
ServiceOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionService,
|
||||
Label: "Service",
|
||||
AutoFill: sdk.AutoFillService,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 15 * time.Minute,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 6 * time.Hour,
|
||||
},
|
||||
}
|
||||
}
|
||||
111
checker/header_rule.go
Normal file
111
checker/header_rule.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// HeaderRuleSpec declares a "presence + value validation" rule for one
|
||||
// HTTP response header. It covers the most common shape of security
|
||||
// header rule (one of Referrer-Policy, Permissions-Policy, COOP, COEP,
|
||||
// CORP, X-Content-Type-Options, …) without forcing the author to write
|
||||
// the load/iterate/build-state scaffolding.
|
||||
//
|
||||
// The DSL emits three CheckState codes derived from Code:
|
||||
// - Code+".missing" when the header is absent
|
||||
// - Code+".invalid" when Validate returns a non-OK status
|
||||
// - Code+".ok" when Validate accepts the value
|
||||
//
|
||||
// Rules with richer semantics (HSTS quality thresholds, CSP directive
|
||||
// inspection, cookie flag aggregation, legacy headers with reversed
|
||||
// "absent is fine" semantics) keep implementing sdk.CheckRule directly.
|
||||
type HeaderRuleSpec struct {
|
||||
// Code is the rule's Name() and the prefix for every CheckState
|
||||
// code it emits.
|
||||
Code string
|
||||
|
||||
// Description is returned by Description().
|
||||
Description string
|
||||
|
||||
// Header is the response header to inspect. Lookups go through the
|
||||
// lowercased map populated by the collector, so casing is flexible.
|
||||
Header string
|
||||
|
||||
// Required toggles the severity of an absent header: Warn when true,
|
||||
// Info when false.
|
||||
Required bool
|
||||
|
||||
// Validate, when set, inspects the trimmed header value. Return
|
||||
// (StatusOK, msg) to accept the value (emits ".ok" with msg) or any
|
||||
// other status to flag it (emits ".invalid" with msg). When nil,
|
||||
// presence alone is treated as OK with a generic message.
|
||||
Validate func(value string) (sdk.Status, string)
|
||||
|
||||
// FixHint, when set, is attached as Meta.fix on the ".missing"
|
||||
// state.
|
||||
FixHint string
|
||||
}
|
||||
|
||||
// HeaderRule constructs a self-contained sdk.CheckRule from a spec.
|
||||
// Intended to be wired in init() via RegisterRule.
|
||||
func HeaderRule(spec HeaderRuleSpec) sdk.CheckRule {
|
||||
return &headerRule{spec: spec}
|
||||
}
|
||||
|
||||
type headerRule struct{ spec HeaderRuleSpec }
|
||||
|
||||
func (r *headerRule) Name() string { return r.spec.Code }
|
||||
func (r *headerRule) Description() string { return r.spec.Description }
|
||||
|
||||
func (r *headerRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
headerKey := strings.ToLower(r.spec.Header)
|
||||
|
||||
return EvalPerHTTPS(data, r.spec.Code, func(p HTTPProbe) sdk.CheckState {
|
||||
v := strings.TrimSpace(p.Headers[headerKey])
|
||||
if v == "" {
|
||||
status := sdk.StatusWarn
|
||||
if !r.spec.Required {
|
||||
status = sdk.StatusInfo
|
||||
}
|
||||
st := sdk.CheckState{
|
||||
Status: status,
|
||||
Code: r.spec.Code + ".missing",
|
||||
Subject: p.Address,
|
||||
Message: r.spec.Header + " is not set.",
|
||||
}
|
||||
if r.spec.FixHint != "" {
|
||||
st.Meta = map[string]any{"fix": r.spec.FixHint}
|
||||
}
|
||||
return st
|
||||
}
|
||||
if r.spec.Validate == nil {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: r.spec.Code + ".ok",
|
||||
Subject: p.Address,
|
||||
Message: r.spec.Header + " is set.",
|
||||
}
|
||||
}
|
||||
status, msg := r.spec.Validate(v)
|
||||
suffix := ".invalid"
|
||||
if status == sdk.StatusOK {
|
||||
suffix = ".ok"
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Code: r.spec.Code + suffix,
|
||||
Subject: p.Address,
|
||||
Message: msg,
|
||||
}
|
||||
})
|
||||
}
|
||||
130
checker/headers.go
Normal file
130
checker/headers.go
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// HSTSDirectives is the parsed form of a Strict-Transport-Security header
|
||||
// (RFC 6797 §6.1).
|
||||
type HSTSDirectives struct {
|
||||
MaxAge int64
|
||||
IncludeSub bool
|
||||
Preload bool
|
||||
}
|
||||
|
||||
// ParseHSTS pulls max-age, includeSubDomains and preload out of an HSTS
|
||||
// value. Returns nil for an empty value so callers can distinguish "header
|
||||
// absent" from "header present with max-age=0".
|
||||
func ParseHSTS(v string) *HSTSDirectives {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
h := &HSTSDirectives{}
|
||||
for _, part := range strings.Split(v, ";") {
|
||||
part = strings.TrimSpace(part)
|
||||
switch {
|
||||
case strings.HasPrefix(strings.ToLower(part), "max-age="):
|
||||
val := strings.Trim(part[len("max-age="):], "\"")
|
||||
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
|
||||
h.MaxAge = n
|
||||
}
|
||||
case strings.EqualFold(part, "includeSubDomains"):
|
||||
h.IncludeSub = true
|
||||
case strings.EqualFold(part, "preload"):
|
||||
h.Preload = true
|
||||
}
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// CSPDirectives is the parsed form of a Content-Security-Policy header
|
||||
// (W3C CSP3). Directive names are lowercased; source tokens keep their
|
||||
// original casing because keywords like 'unsafe-inline' must round-trip
|
||||
// verbatim when reported back to the user.
|
||||
type CSPDirectives struct {
|
||||
Raw string
|
||||
Directives map[string][]string
|
||||
}
|
||||
|
||||
// ParseCSP splits a CSP header into its directive → sources map.
|
||||
func ParseCSP(v string) *CSPDirectives {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
c := &CSPDirectives{Raw: v, Directives: map[string][]string{}}
|
||||
for _, d := range strings.Split(v, ";") {
|
||||
d = strings.TrimSpace(d)
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(d)
|
||||
name := strings.ToLower(fields[0])
|
||||
c.Directives[name] = fields[1:]
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// HasDirective reports whether the named directive is declared at all.
|
||||
func (c *CSPDirectives) HasDirective(name string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
_, ok := c.Directives[strings.ToLower(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// HasSource reports whether the named directive lists the given source
|
||||
// token (case-insensitive comparison; pass keywords with their quotes,
|
||||
// e.g. "'unsafe-inline'").
|
||||
func (c *CSPDirectives) HasSource(directive, source string) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for _, s := range c.Directives[strings.ToLower(directive)] {
|
||||
if strings.EqualFold(s, source) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasUnsafe reports whether any directive uses 'unsafe-inline' or
|
||||
// 'unsafe-eval' — the two keywords that nullify most of CSP's value.
|
||||
func (c *CSPDirectives) HasUnsafe() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
for _, sources := range c.Directives {
|
||||
for _, s := range sources {
|
||||
ls := strings.ToLower(s)
|
||||
if ls == "'unsafe-inline'" || ls == "'unsafe-eval'" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ParsedHeaders bundles the structured headers we parse repeatedly. Fields
|
||||
// are nil when the underlying header is absent on the probe; rules can
|
||||
// nil-check or rely on the typed accessors which already handle nil.
|
||||
type ParsedHeaders struct {
|
||||
HSTS *HSTSDirectives
|
||||
CSP *CSPDirectives
|
||||
}
|
||||
|
||||
// ParseHeaders builds a ParsedHeaders from a probe's raw header map.
|
||||
// Header lookups use the lowercase keys produced by the collector.
|
||||
func ParseHeaders(p HTTPProbe) ParsedHeaders {
|
||||
return ParsedHeaders{
|
||||
HSTS: ParseHSTS(p.Headers["strict-transport-security"]),
|
||||
CSP: ParseCSP(p.Headers["content-security-policy"]),
|
||||
}
|
||||
}
|
||||
254
checker/interactive.go
Normal file
254
checker/interactive.go
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
//go:build standalone
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/services/abstract"
|
||||
)
|
||||
|
||||
// RenderForm implements server.Interactive: the human-facing form
|
||||
// exposed at GET /check when the checker runs as a standalone binary.
|
||||
func (p *httpProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Host name",
|
||||
Placeholder: "www.example.com",
|
||||
Required: true,
|
||||
Description: "The HTTP/HTTPS server hostname to probe. A/AAAA records are looked up live.",
|
||||
},
|
||||
{
|
||||
Id: OptionProbeTimeoutMs,
|
||||
Type: "number",
|
||||
Label: "Per-request timeout (ms)",
|
||||
Description: "Maximum time allowed for a single HTTP/HTTPS request.",
|
||||
Default: float64(DefaultProbeTimeoutMs),
|
||||
},
|
||||
{
|
||||
Id: OptionMaxRedirects,
|
||||
Type: "number",
|
||||
Label: "Max redirects to follow",
|
||||
Description: "Stop following redirects after this many hops.",
|
||||
Default: float64(DefaultMaxRedirects),
|
||||
},
|
||||
{
|
||||
Id: OptionUserAgent,
|
||||
Type: "string",
|
||||
Label: "User-Agent",
|
||||
Description: "User-Agent header sent with every request.",
|
||||
Default: DefaultUserAgent,
|
||||
},
|
||||
{
|
||||
Id: OptionRequireHTTPS,
|
||||
Type: "bool",
|
||||
Label: "Require HTTPS",
|
||||
Description: "Plain HTTP must redirect to HTTPS.",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: OptionRequireHSTS,
|
||||
Type: "bool",
|
||||
Label: "Require HSTS",
|
||||
Description: "HTTPS responses must include a Strict-Transport-Security header.",
|
||||
Default: true,
|
||||
},
|
||||
{
|
||||
Id: OptionMinHSTSMaxAgeDays,
|
||||
Type: "number",
|
||||
Label: "Min HSTS max-age (days)",
|
||||
Description: "Minimum acceptable max-age value (in days) for HSTS.",
|
||||
Default: float64(DefaultMinHSTSMaxAge),
|
||||
},
|
||||
{
|
||||
Id: OptionRequireCSP,
|
||||
Type: "bool",
|
||||
Label: "Require Content-Security-Policy",
|
||||
Description: "HTTPS responses must include a Content-Security-Policy header.",
|
||||
Default: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseForm implements server.Interactive: resolves the submitted
|
||||
// hostname into an abstract.Server payload and wraps it in the
|
||||
// ServiceMessage shape that Collect expects.
|
||||
func (p *httpProvider) ParseForm(r *http.Request) (sdk.CheckerOptions, error) {
|
||||
domain := strings.TrimSpace(r.FormValue("domain"))
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
if domain == "" {
|
||||
return nil, errors.New("host name is required")
|
||||
}
|
||||
fqdn := dns.Fqdn(domain)
|
||||
|
||||
resolver, err := systemResolver()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolver: %w", err)
|
||||
}
|
||||
|
||||
server := &abstract.Server{}
|
||||
if a, err := lookupA(resolver, fqdn); err != nil {
|
||||
return nil, fmt.Errorf("A lookup for %s: %w", domain, err)
|
||||
} else if a != nil {
|
||||
server.A = a
|
||||
}
|
||||
if aaaa, err := lookupAAAA(resolver, fqdn); err != nil {
|
||||
return nil, fmt.Errorf("AAAA lookup for %s: %w", domain, err)
|
||||
} else if aaaa != nil {
|
||||
server.AAAA = aaaa
|
||||
}
|
||||
if server.A == nil && server.AAAA == nil {
|
||||
return nil, fmt.Errorf("no A/AAAA records found for %s", domain)
|
||||
}
|
||||
|
||||
svcBody, err := json.Marshal(server)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal abstract.Server: %w", err)
|
||||
}
|
||||
|
||||
opts := sdk.CheckerOptions{
|
||||
OptionService: happydns.ServiceMessage{
|
||||
ServiceMeta: happydns.ServiceMeta{
|
||||
Type: "abstract.Server",
|
||||
Domain: domain,
|
||||
},
|
||||
Service: svcBody,
|
||||
},
|
||||
}
|
||||
|
||||
if raw := strings.TrimSpace(r.FormValue(OptionProbeTimeoutMs)); raw != "" {
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("timeout must be a number")
|
||||
}
|
||||
opts[OptionProbeTimeoutMs] = v
|
||||
}
|
||||
if raw := strings.TrimSpace(r.FormValue(OptionMaxRedirects)); raw != "" {
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("max redirects must be a number")
|
||||
}
|
||||
opts[OptionMaxRedirects] = v
|
||||
}
|
||||
if v := strings.TrimSpace(r.FormValue(OptionUserAgent)); v != "" {
|
||||
opts[OptionUserAgent] = v
|
||||
}
|
||||
if raw := strings.TrimSpace(r.FormValue(OptionMinHSTSMaxAgeDays)); raw != "" {
|
||||
v, err := strconv.ParseFloat(raw, 64)
|
||||
if err != nil {
|
||||
return nil, errors.New("HSTS max-age must be a number")
|
||||
}
|
||||
opts[OptionMinHSTSMaxAgeDays] = v
|
||||
}
|
||||
opts[OptionRequireHTTPS] = parseInteractiveBool(r, OptionRequireHTTPS, true)
|
||||
opts[OptionRequireHSTS] = parseInteractiveBool(r, OptionRequireHSTS, true)
|
||||
opts[OptionRequireCSP] = parseInteractiveBool(r, OptionRequireCSP, false)
|
||||
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
// parseInteractiveBool reads a checkbox-style field. HTML forms omit
|
||||
// unchecked checkboxes entirely, so a missing key means false if the
|
||||
// form was submitted (detected via the required "domain" field).
|
||||
func parseInteractiveBool(r *http.Request, key string, def bool) bool {
|
||||
if _, ok := r.Form[key]; !ok {
|
||||
if _, submitted := r.Form["domain"]; submitted {
|
||||
return false
|
||||
}
|
||||
return def
|
||||
}
|
||||
v := strings.ToLower(strings.TrimSpace(r.FormValue(key)))
|
||||
switch v {
|
||||
case "", "0", "false", "off", "no":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// systemResolver picks a DNS server to send explicit A/AAAA queries to.
|
||||
// Resolution order:
|
||||
// 1. CHECKER_DNS_RESOLVER env var (host or host:port)
|
||||
// 2. The OS resolver config when one exists (resolvConfPath)
|
||||
// 3. 1.1.1.1:53 as a last-resort public fallback
|
||||
func systemResolver() (string, error) {
|
||||
if env := strings.TrimSpace(os.Getenv("CHECKER_DNS_RESOLVER")); env != "" {
|
||||
if _, _, err := net.SplitHostPort(env); err != nil {
|
||||
env = net.JoinHostPort(env, "53")
|
||||
}
|
||||
return env, nil
|
||||
}
|
||||
if path := resolvConfPath(); path != "" {
|
||||
if cfg, err := dns.ClientConfigFromFile(path); err == nil && len(cfg.Servers) > 0 {
|
||||
return net.JoinHostPort(cfg.Servers[0], cfg.Port), nil
|
||||
}
|
||||
}
|
||||
return net.JoinHostPort("1.1.1.1", "53"), nil
|
||||
}
|
||||
|
||||
func resolvConfPath() string {
|
||||
for _, p := range []string{"/etc/resolv.conf"} {
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func dnsExchange(resolver, name string, qtype uint16) (*dns.Msg, error) {
|
||||
msg := new(dns.Msg)
|
||||
msg.SetQuestion(name, qtype)
|
||||
msg.RecursionDesired = true
|
||||
c := new(dns.Client)
|
||||
in, _, err := c.Exchange(msg, resolver)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if in.Rcode != dns.RcodeSuccess && in.Rcode != dns.RcodeNameError {
|
||||
return nil, fmt.Errorf("rcode %s", dns.RcodeToString[in.Rcode])
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func lookupA(resolver, fqdn string) (*dns.A, error) {
|
||||
in, err := dnsExchange(resolver, fqdn, dns.TypeA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rr := range in.Answer {
|
||||
if a, ok := rr.(*dns.A); ok {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func lookupAAAA(resolver, fqdn string) (*dns.AAAA, error) {
|
||||
in, err := dnsExchange(resolver, fqdn, dns.TypeAAAA)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, rr := range in.Answer {
|
||||
if aaaa, ok := rr.(*dns.AAAA); ok {
|
||||
return aaaa, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
48
checker/iter.go
Normal file
48
checker/iter.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
|
||||
// EvalAggregateByScheme runs fn on the subset of probes matching scheme.
|
||||
// If no probe was attempted, returns a single Unknown state with
|
||||
// code+".no_probes". Otherwise it returns the per-probe states emitted by
|
||||
// fn, falling back to a single OK state (code+".ok" with okMsg) when fn
|
||||
// emitted nothing — the conventional "everything is fine" shape used by
|
||||
// reachability and redirect rules.
|
||||
func EvalAggregateByScheme(data *HTTPData, scheme, code, okMsg string, fn func(p HTTPProbe, emit func(sdk.CheckState))) []sdk.CheckState {
|
||||
probes := probesByScheme(data.Probes, scheme)
|
||||
if len(probes) == 0 {
|
||||
return []sdk.CheckState{unknownState(code+".no_probes", "No probes were attempted.")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
emit := func(s sdk.CheckState) { states = append(states, s) }
|
||||
for _, p := range probes {
|
||||
fn(p, emit)
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState(code+".ok", okMsg)}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// EvalPerHTTPS calls fn for each successful HTTPS probe and returns the
|
||||
// concatenated states. If no HTTPS probe succeeded, returns a single
|
||||
// Unknown state with code+".no_https".
|
||||
//
|
||||
// Use this for rules that emit one CheckState per probe — the most common
|
||||
// shape. Rules that need access to all probes at once (aggregation,
|
||||
// cross-probe comparisons) should call successfulHTTPSProbes directly.
|
||||
func EvalPerHTTPS(data *HTTPData, code string, fn func(p HTTPProbe) sdk.CheckState) []sdk.CheckState {
|
||||
probes := successfulHTTPSProbes(data.Probes)
|
||||
if len(probes) == 0 {
|
||||
return []sdk.CheckState{unknownState(code+".no_https", "No successful HTTPS probe to evaluate.")}
|
||||
}
|
||||
out := make([]sdk.CheckState, 0, len(probes))
|
||||
for _, p := range probes {
|
||||
out = append(out, fn(p))
|
||||
}
|
||||
return out
|
||||
}
|
||||
20
checker/provider.go
Normal file
20
checker/provider.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns a new HTTP/HTTPS observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &httpProvider{}
|
||||
}
|
||||
|
||||
type httpProvider struct{}
|
||||
|
||||
func (p *httpProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeyHTTP
|
||||
}
|
||||
244
checker/provider_test.go
Normal file
244
checker/provider_test.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/services/abstract"
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func mkServer(t *testing.T, name string, ipv4, ipv6 string) *abstract.Server {
|
||||
t.Helper()
|
||||
s := &abstract.Server{}
|
||||
if ipv4 != "" {
|
||||
s.A = &dns.A{
|
||||
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 300},
|
||||
A: net.ParseIP(ipv4),
|
||||
}
|
||||
}
|
||||
if ipv6 != "" {
|
||||
s.AAAA = &dns.AAAA{
|
||||
Hdr: dns.RR_Header{Name: name, Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 300},
|
||||
AAAA: net.ParseIP(ipv6),
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func mkServiceMessage(t *testing.T, srv *abstract.Server) happydns.ServiceMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(srv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return happydns.ServiceMessage{
|
||||
ServiceMeta: happydns.ServiceMeta{Type: "abstract.Server"},
|
||||
Service: raw,
|
||||
}
|
||||
}
|
||||
|
||||
func TestProvider_KeyAndDefinition(t *testing.T) {
|
||||
p := Provider()
|
||||
if p.Key() != ObservationKeyHTTP {
|
||||
t.Errorf("Key() = %q, want %q", p.Key(), ObservationKeyHTTP)
|
||||
}
|
||||
dp, ok := p.(sdk.CheckerDefinitionProvider)
|
||||
if !ok {
|
||||
t.Fatal("provider does not implement CheckerDefinitionProvider")
|
||||
}
|
||||
def := dp.Definition()
|
||||
if def == nil || def.ID != "http" {
|
||||
t.Fatalf("unexpected definition: %+v", def)
|
||||
}
|
||||
if !def.Availability.ApplyToService {
|
||||
t.Errorf("ApplyToService should be true")
|
||||
}
|
||||
if len(def.Availability.LimitToServices) != 1 || def.Availability.LimitToServices[0] != "abstract.Server" {
|
||||
t.Errorf("LimitToServices: %+v", def.Availability.LimitToServices)
|
||||
}
|
||||
if len(def.Rules) == 0 {
|
||||
t.Error("Rules slice empty")
|
||||
}
|
||||
if def.Interval == nil || def.Interval.Default <= 0 {
|
||||
t.Error("Interval default not set")
|
||||
}
|
||||
// User options must include expected keys.
|
||||
idx := map[string]bool{}
|
||||
for _, o := range def.Options.UserOpts {
|
||||
idx[o.Id] = true
|
||||
}
|
||||
for _, want := range []string{OptionProbeTimeoutMs, OptionMaxRedirects, OptionUserAgent, OptionRequireHTTPS, OptionRequireHSTS, OptionMinHSTSMaxAgeDays, OptionRequireCSP} {
|
||||
if !idx[want] {
|
||||
t.Errorf("UserOpts missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveServer_Success(t *testing.T) {
|
||||
srv := mkServer(t, "example.test.", "203.0.113.10", "")
|
||||
opts := sdk.CheckerOptions{OptionService: mkServiceMessage(t, srv)}
|
||||
got, err := resolveServer(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveServer: %v", err)
|
||||
}
|
||||
if got.A == nil || got.A.A.String() != "203.0.113.10" {
|
||||
t.Errorf("unexpected server: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveServer_MissingService(t *testing.T) {
|
||||
if _, err := resolveServer(sdk.CheckerOptions{}); err == nil {
|
||||
t.Fatal("expected error for missing service option")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveServer_WrongType(t *testing.T) {
|
||||
msg := happydns.ServiceMessage{
|
||||
ServiceMeta: happydns.ServiceMeta{Type: "abstract.NotServer"},
|
||||
Service: json.RawMessage(`{}`),
|
||||
}
|
||||
if _, err := resolveServer(sdk.CheckerOptions{OptionService: msg}); err == nil {
|
||||
t.Fatal("expected error for wrong service type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveServer_BadJSON(t *testing.T) {
|
||||
msg := happydns.ServiceMessage{
|
||||
ServiceMeta: happydns.ServiceMeta{Type: "abstract.Server"},
|
||||
Service: json.RawMessage(`{not json`),
|
||||
}
|
||||
if _, err := resolveServer(sdk.CheckerOptions{OptionService: msg}); err == nil {
|
||||
t.Fatal("expected error for malformed service payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverIPs_DedupesAndMerges(t *testing.T) {
|
||||
// Stand up a loopback DNS server that returns multiple A and AAAA
|
||||
// records, then point a custom resolver at it.
|
||||
mux := dns.NewServeMux()
|
||||
mux.HandleFunc("multi.test.", func(w dns.ResponseWriter, r *dns.Msg) {
|
||||
m := new(dns.Msg)
|
||||
m.SetReply(r)
|
||||
switch r.Question[0].Qtype {
|
||||
case dns.TypeA:
|
||||
for _, ip := range []string{"203.0.113.10", "203.0.113.11"} {
|
||||
m.Answer = append(m.Answer, &dns.A{
|
||||
Hdr: dns.RR_Header{Name: "multi.test.", Rrtype: dns.TypeA, Class: dns.ClassINET, Ttl: 60},
|
||||
A: net.ParseIP(ip),
|
||||
})
|
||||
}
|
||||
case dns.TypeAAAA:
|
||||
m.Answer = append(m.Answer, &dns.AAAA{
|
||||
Hdr: dns.RR_Header{Name: "multi.test.", Rrtype: dns.TypeAAAA, Class: dns.ClassINET, Ttl: 60},
|
||||
AAAA: net.ParseIP("2001:db8::a"),
|
||||
})
|
||||
}
|
||||
_ = w.WriteMsg(m)
|
||||
})
|
||||
|
||||
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv := &dns.Server{PacketConn: pc, Handler: mux}
|
||||
go func() { _ = srv.ActivateAndServe() }()
|
||||
defer srv.Shutdown()
|
||||
|
||||
prev := resolver
|
||||
resolver = &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, "udp", pc.LocalAddr().String())
|
||||
},
|
||||
}
|
||||
defer func() { resolver = prev }()
|
||||
|
||||
seen := map[string]struct{}{"203.0.113.10": {}} // already pinned
|
||||
got := discoverIPs(context.Background(), "multi.test", seen)
|
||||
sort.Strings(got)
|
||||
want := []string{"2001:db8::a", "203.0.113.11"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %v, want %v", got, want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("ip[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverIPs_LookupFailureIsNonFatal(t *testing.T) {
|
||||
prev := resolver
|
||||
resolver = &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
return nil, net.ErrClosed
|
||||
},
|
||||
}
|
||||
defer func() { resolver = prev }()
|
||||
|
||||
if got := discoverIPs(context.Background(), "nope.test", map[string]struct{}{}); got != nil {
|
||||
t.Errorf("expected nil on resolver failure, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddressesFromServer(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
srv *abstract.Server
|
||||
wantHost string
|
||||
wantIPs []string
|
||||
}{
|
||||
{
|
||||
name: "v4 only",
|
||||
srv: mkServer(t, "example.test.", "203.0.113.1", ""),
|
||||
wantHost: "example.test",
|
||||
wantIPs: []string{"203.0.113.1"},
|
||||
},
|
||||
{
|
||||
name: "v6 only",
|
||||
srv: mkServer(t, "v6.example.test.", "", "2001:db8::1"),
|
||||
wantHost: "v6.example.test",
|
||||
wantIPs: []string{"2001:db8::1"},
|
||||
},
|
||||
{
|
||||
name: "dual stack",
|
||||
srv: mkServer(t, "dual.example.test.", "203.0.113.2", "2001:db8::2"),
|
||||
wantHost: "dual.example.test",
|
||||
wantIPs: []string{"203.0.113.2", "2001:db8::2"},
|
||||
},
|
||||
{
|
||||
name: "empty",
|
||||
srv: &abstract.Server{},
|
||||
wantHost: "",
|
||||
wantIPs: nil,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
host, ips := addressesFromServer(c.srv)
|
||||
if host != c.wantHost {
|
||||
t.Errorf("host = %q, want %q", host, c.wantHost)
|
||||
}
|
||||
if len(ips) != len(c.wantIPs) {
|
||||
t.Fatalf("ips = %+v, want %+v", ips, c.wantIPs)
|
||||
}
|
||||
for i, ip := range ips {
|
||||
if ip != c.wantIPs[i] {
|
||||
t.Errorf("ip[%d] = %q, want %q", i, ip, c.wantIPs[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
50
checker/registry.go
Normal file
50
checker/registry.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// registry holds the rules and collectors that ship with the checker.
|
||||
// Each rule/collector registers itself in an init() so that adding a new
|
||||
// one is a single-file change — no central list to maintain.
|
||||
var registry = struct {
|
||||
mu sync.Mutex
|
||||
rules []sdk.CheckRule
|
||||
collectors []Collector
|
||||
}{}
|
||||
|
||||
// RegisterRule appends a rule to the global registry. Intended to be
|
||||
// called from init() in each rule file.
|
||||
func RegisterRule(r sdk.CheckRule) {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
registry.rules = append(registry.rules, r)
|
||||
}
|
||||
|
||||
// RegisterCollector appends a collector to the global registry. Reserved
|
||||
// for step 4; the orchestrator currently wires only rootCollector
|
||||
// directly.
|
||||
func RegisterCollector(c Collector) {
|
||||
registry.mu.Lock()
|
||||
defer registry.mu.Unlock()
|
||||
registry.collectors = append(registry.collectors, c)
|
||||
}
|
||||
|
||||
// Rules returns every registered rule, sorted by Name() so the output is
|
||||
// stable across init-order changes (which Go does not guarantee between
|
||||
// files).
|
||||
func Rules() []sdk.CheckRule {
|
||||
registry.mu.Lock()
|
||||
out := make([]sdk.CheckRule, len(registry.rules))
|
||||
copy(out, registry.rules)
|
||||
registry.mu.Unlock()
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name() < out[j].Name() })
|
||||
return out
|
||||
}
|
||||
58
checker/rules.go
Normal file
58
checker/rules.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// loadHTTPData fetches the HTTPData observation. On failure, returns a
|
||||
// single error CheckState the caller should emit and bail out.
|
||||
func loadHTTPData(ctx context.Context, obs sdk.ObservationGetter) (*HTTPData, *sdk.CheckState) {
|
||||
var data HTTPData
|
||||
if err := obs.Get(ctx, ObservationKeyHTTP, &data); err != nil {
|
||||
return nil, &sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load HTTP observation: %v", err),
|
||||
Code: "http.observation_error",
|
||||
}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
func passState(code, msg string) sdk.CheckState {
|
||||
return sdk.CheckState{Status: sdk.StatusOK, Code: code, Message: msg}
|
||||
}
|
||||
|
||||
func unknownState(code, msg string) sdk.CheckState {
|
||||
return sdk.CheckState{Status: sdk.StatusUnknown, Code: code, Message: msg}
|
||||
}
|
||||
|
||||
// probesByScheme returns the subset of probes for a given scheme.
|
||||
func probesByScheme(probes []HTTPProbe, scheme string) []HTTPProbe {
|
||||
var out []HTTPProbe
|
||||
for _, p := range probes {
|
||||
if p.Scheme == scheme {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// successfulHTTPSProbes returns HTTPS probes that completed an HTTP
|
||||
// transaction (status code != 0). These are the probes whose headers we
|
||||
// can meaningfully inspect.
|
||||
func successfulHTTPSProbes(probes []HTTPProbe) []HTTPProbe {
|
||||
var out []HTTPProbe
|
||||
for _, p := range probes {
|
||||
if p.Scheme == "https" && p.StatusCode != 0 {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
70
checker/rules_cookies.go
Normal file
70
checker/rules_cookies.go
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() { RegisterRule(&cookieFlagsRule{}) }
|
||||
|
||||
// cookieFlagsRule audits Set-Cookie attributes on HTTPS responses: every
|
||||
// cookie should be Secure and HttpOnly, and SameSite should be set.
|
||||
type cookieFlagsRule struct{}
|
||||
|
||||
func (r *cookieFlagsRule) Name() string { return "http.cookie_flags" }
|
||||
func (r *cookieFlagsRule) Description() string {
|
||||
return "Verifies that cookies set over HTTPS use the Secure, HttpOnly and SameSite attributes."
|
||||
}
|
||||
|
||||
func (r *cookieFlagsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
probes := successfulHTTPSProbes(data.Probes)
|
||||
if len(probes) == 0 {
|
||||
return []sdk.CheckState{unknownState("http.cookie_flags.no_https", "No successful HTTPS probe to evaluate.")}
|
||||
}
|
||||
|
||||
var states []sdk.CheckState
|
||||
totalCookies := 0
|
||||
for _, p := range probes {
|
||||
for _, c := range p.Cookies {
|
||||
totalCookies++
|
||||
var issues []string
|
||||
if !c.Secure {
|
||||
issues = append(issues, "missing Secure")
|
||||
}
|
||||
if !c.HttpOnly {
|
||||
issues = append(issues, "missing HttpOnly")
|
||||
}
|
||||
if c.SameSite == "" {
|
||||
issues = append(issues, "missing SameSite")
|
||||
} else if strings.EqualFold(c.SameSite, "None") && !c.Secure {
|
||||
issues = append(issues, "SameSite=None requires Secure")
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.cookie_flags.weak",
|
||||
Subject: fmt.Sprintf("%s :: %s", p.Address, c.Name),
|
||||
Message: fmt.Sprintf("Cookie %q on %s: %s", c.Name, p.Address, strings.Join(issues, ", ")),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
if totalCookies == 0 {
|
||||
return []sdk.CheckState{passState("http.cookie_flags.none", "No cookies were set on the inspected responses.")}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("http.cookie_flags.ok", fmt.Sprintf("All %d cookies have proper Secure/HttpOnly/SameSite flags.", totalCookies))}
|
||||
}
|
||||
return states
|
||||
}
|
||||
85
checker/rules_cookies_test.go
Normal file
85
checker/rules_cookies_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestCookieFlagsRule_NoHTTPS(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
||||
states := runRule(t, &cookieFlagsRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
}
|
||||
|
||||
func TestCookieFlagsRule_NoCookies(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &cookieFlagsRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.cookie_flags.none") {
|
||||
t.Errorf("missing 'none' code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieFlagsRule_AllOK(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Cookies = []CookieInfo{
|
||||
{Name: "sid", Secure: true, HttpOnly: true, SameSite: "Strict"},
|
||||
{Name: "tok", Secure: true, HttpOnly: true, SameSite: "Lax"},
|
||||
}
|
||||
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.cookie_flags.ok") {
|
||||
t.Errorf("missing ok code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieFlagsRule_Issues(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Cookies = []CookieInfo{
|
||||
{Name: "no-secure", Secure: false, HttpOnly: true, SameSite: "Lax"},
|
||||
{Name: "no-httponly", Secure: true, HttpOnly: false, SameSite: "Lax"},
|
||||
{Name: "no-samesite", Secure: true, HttpOnly: true, SameSite: ""},
|
||||
{Name: "none-without-secure", Secure: false, HttpOnly: true, SameSite: "None"},
|
||||
}
|
||||
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
if len(states) != len(p.Cookies) {
|
||||
t.Fatalf("got %d states, want %d", len(states), len(p.Cookies))
|
||||
}
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
|
||||
// Check each diagnostic mentions the cookie name and a relevant phrase.
|
||||
wantSubstr := map[string]string{
|
||||
"no-secure": "missing Secure",
|
||||
"no-httponly": "missing HttpOnly",
|
||||
"no-samesite": "missing SameSite",
|
||||
"none-without-secure": "SameSite=None requires Secure",
|
||||
}
|
||||
for _, st := range states {
|
||||
matched := false
|
||||
for name, phrase := range wantSubstr {
|
||||
if strings.Contains(st.Message, name) && strings.Contains(st.Message, phrase) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
t.Errorf("unexpected state message: %q", st.Message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieFlagsRule_SameSiteNoneCaseInsensitive(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Cookies = []CookieInfo{{Name: "x", Secure: false, HttpOnly: true, SameSite: "none"}}
|
||||
states := runRule(t, &cookieFlagsRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !strings.Contains(states[0].Message, "SameSite=None requires Secure") {
|
||||
t.Errorf("expected SameSite=None warning regardless of casing, got %q", states[0].Message)
|
||||
}
|
||||
}
|
||||
62
checker/rules_reachability.go
Normal file
62
checker/rules_reachability.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterRule(&reachabilityRule{scheme: "http", code: "http.tcp_reachable"})
|
||||
RegisterRule(&reachabilityRule{scheme: "https", code: "https.tcp_reachable"})
|
||||
}
|
||||
|
||||
// reachabilityRule reports per-IP reachability for one scheme.
|
||||
type reachabilityRule struct {
|
||||
scheme string // "http" or "https"
|
||||
code string
|
||||
}
|
||||
|
||||
func (r *reachabilityRule) Name() string { return r.code }
|
||||
func (r *reachabilityRule) Description() string {
|
||||
return fmt.Sprintf("Verifies that every probed IP accepts a %s connection on the standard port.", r.scheme)
|
||||
}
|
||||
|
||||
func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
|
||||
okMsg := fmt.Sprintf("All %s probes responded successfully.", r.scheme)
|
||||
return EvalAggregateByScheme(data, r.scheme, r.code, okMsg, func(p HTTPProbe, emit func(sdk.CheckState)) {
|
||||
switch {
|
||||
case !p.TCPConnected:
|
||||
emit(sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: r.code + ".unreachable",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("Cannot reach %s://%s on %s: %s", r.scheme, p.Host, p.Address, p.Error),
|
||||
})
|
||||
case p.StatusCode == 0:
|
||||
emit(sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: r.code + ".no_response",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("TCP open but no HTTP response from %s: %s", p.Address, p.Error),
|
||||
})
|
||||
case p.StatusCode >= 500:
|
||||
emit(sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: r.code + ".server_error",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("%s returned %d", p.Address, p.StatusCode),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
79
checker/rules_reachability_test.go
Normal file
79
checker/rules_reachability_test.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestReachabilityRule_NoProbes(t *testing.T) {
|
||||
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
|
||||
states := runRule(t, r, &HTTPData{}, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
if !hasCode(states, "https.tcp_reachable.no_probes") {
|
||||
t.Errorf("expected no_probes code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReachabilityRule_AllOK(t *testing.T) {
|
||||
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443"), httpsProbe("b:443")}}
|
||||
states := runRule(t, r, data, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "https.tcp_reachable.ok") {
|
||||
t.Errorf("expected ok code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReachabilityRule_Unreachable(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.TCPConnected = false
|
||||
p.StatusCode = 0
|
||||
p.Error = "i/o timeout"
|
||||
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
|
||||
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusCrit)
|
||||
if !hasCode(states, "https.tcp_reachable.unreachable") {
|
||||
t.Errorf("expected unreachable: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReachabilityRule_NoResponse(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.StatusCode = 0
|
||||
p.Error = "EOF"
|
||||
// TCPConnected stays true (connection accepted, no HTTP response).
|
||||
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
|
||||
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusCrit)
|
||||
if !hasCode(states, "https.tcp_reachable.no_response") {
|
||||
t.Errorf("expected no_response: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReachabilityRule_5xx(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.StatusCode = 502
|
||||
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
|
||||
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "https.tcp_reachable.server_error") {
|
||||
t.Errorf("expected server_error: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReachabilityRule_FiltersByScheme(t *testing.T) {
|
||||
// An HTTP failure should not surface in the HTTPS reachability rule.
|
||||
pHTTPS := httpsProbe("a:443")
|
||||
pHTTP := httpProbe("a:80")
|
||||
pHTTP.TCPConnected = false
|
||||
pHTTP.StatusCode = 0
|
||||
|
||||
r := &reachabilityRule{scheme: "https", code: "https.tcp_reachable"}
|
||||
states := runRule(t, r, &HTTPData{Probes: []HTTPProbe{pHTTPS, pHTTP}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
}
|
||||
69
checker/rules_redirect.go
Normal file
69
checker/rules_redirect.go
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() { RegisterRule(&httpsRedirectRule{}) }
|
||||
|
||||
// httpsRedirectRule verifies that plain HTTP either redirects to HTTPS or
|
||||
// fails (which is acceptable when HTTP is intentionally not served).
|
||||
type httpsRedirectRule struct{}
|
||||
|
||||
func (r *httpsRedirectRule) Name() string { return "http.https_redirect" }
|
||||
func (r *httpsRedirectRule) Description() string {
|
||||
return "Plain HTTP responses must redirect to an HTTPS URL on the same host."
|
||||
}
|
||||
|
||||
func (r *httpsRedirectRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
require := sdk.GetBoolOption(opts, OptionRequireHTTPS, true)
|
||||
|
||||
const okMsg = "HTTP redirects to HTTPS on every reachable IP."
|
||||
return EvalAggregateByScheme(data, "http", "http.https_redirect", okMsg, func(p HTTPProbe, emit func(sdk.CheckState)) {
|
||||
if !p.TCPConnected || p.StatusCode == 0 {
|
||||
// Reachability rule handles this; an HTTP server that is
|
||||
// simply not running is fine for redirect-purposes.
|
||||
return
|
||||
}
|
||||
final := p.FinalURL
|
||||
if final == "" && len(p.RedirectChain) > 0 {
|
||||
final = p.RedirectChain[len(p.RedirectChain)-1].To
|
||||
}
|
||||
isHTTPS := false
|
||||
if u, err := url.Parse(final); err == nil {
|
||||
isHTTPS = strings.EqualFold(u.Scheme, "https")
|
||||
}
|
||||
switch {
|
||||
case isHTTPS:
|
||||
// Good. Aggregated below as a single OK.
|
||||
case require:
|
||||
emit(sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.no_https_redirect",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("HTTP response on %s did not redirect to HTTPS (final URL: %s, status %d)", p.Address, final, p.StatusCode),
|
||||
Meta: map[string]any{"fix": "Configure your web server to redirect every plain-HTTP request to https://."},
|
||||
})
|
||||
default:
|
||||
emit(sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.plain_http_served",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("HTTP responded directly without redirect (status %d)", p.StatusCode),
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
72
checker/rules_redirect_test.go
Normal file
72
checker/rules_redirect_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestHTTPSRedirectRule_NoProbes(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &httpsRedirectRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
}
|
||||
|
||||
func TestHTTPSRedirectRule_OKViaFinalURL(t *testing.T) {
|
||||
p := httpProbe("a:80")
|
||||
p.StatusCode = 301
|
||||
p.FinalURL = "https://example.test/"
|
||||
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.https_redirect.ok") {
|
||||
t.Errorf("expected redirect.ok: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSRedirectRule_OKViaRedirectChain(t *testing.T) {
|
||||
// FinalURL empty (non-following CheckRedirect path): fallback to last redirect step.
|
||||
p := httpProbe("a:80")
|
||||
p.StatusCode = 308
|
||||
p.FinalURL = ""
|
||||
p.RedirectChain = []RedirectStep{{From: "http://example.test/", To: "https://example.test/"}}
|
||||
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
}
|
||||
|
||||
func TestHTTPSRedirectRule_NoRedirect_Required(t *testing.T) {
|
||||
p := httpProbe("a:80")
|
||||
p.StatusCode = 200
|
||||
p.FinalURL = "http://example.test/"
|
||||
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, sdk.CheckerOptions{OptionRequireHTTPS: true})
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "http.no_https_redirect") {
|
||||
t.Errorf("expected no_https_redirect: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSRedirectRule_NoRedirect_NotRequired(t *testing.T) {
|
||||
p := httpProbe("a:80")
|
||||
p.StatusCode = 200
|
||||
p.FinalURL = "http://example.test/"
|
||||
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, sdk.CheckerOptions{OptionRequireHTTPS: false})
|
||||
mustStatus(t, states, sdk.StatusInfo)
|
||||
if !hasCode(states, "http.plain_http_served") {
|
||||
t.Errorf("expected plain_http_served: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHTTPSRedirectRule_SkipsUnreachable(t *testing.T) {
|
||||
// HTTP not running on this IP: reachability rule handles it; redirect rule must skip.
|
||||
p := httpProbe("a:80")
|
||||
p.TCPConnected = false
|
||||
p.StatusCode = 0
|
||||
states := runRule(t, &httpsRedirectRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.https_redirect.ok") {
|
||||
t.Errorf("unreachable HTTP should leave redirect rule with summary OK, got %+v", states)
|
||||
}
|
||||
}
|
||||
231
checker/rules_security_headers.go
Normal file
231
checker/rules_security_headers.go
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterRule(&hstsRule{})
|
||||
RegisterRule(&cspRule{})
|
||||
RegisterRule(&xFrameOptionsRule{})
|
||||
RegisterRule(&xXSSProtectionRule{})
|
||||
}
|
||||
|
||||
// hstsRule checks the Strict-Transport-Security header on HTTPS responses.
|
||||
type hstsRule struct{}
|
||||
|
||||
func (r *hstsRule) Name() string { return "http.hsts" }
|
||||
func (r *hstsRule) Description() string {
|
||||
return "Verifies the presence and quality of the Strict-Transport-Security header on HTTPS responses."
|
||||
}
|
||||
|
||||
func (r *hstsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
require := sdk.GetBoolOption(opts, OptionRequireHSTS, true)
|
||||
minDays := sdk.GetIntOption(opts, OptionMinHSTSMaxAgeDays, DefaultMinHSTSMaxAge)
|
||||
minSeconds := int64(minDays) * 86400
|
||||
|
||||
return EvalPerHTTPS(data, "http.hsts", func(p HTTPProbe) sdk.CheckState {
|
||||
h := ParseHSTS(p.Headers["strict-transport-security"])
|
||||
if h == nil {
|
||||
status := sdk.StatusWarn
|
||||
if !require {
|
||||
status = sdk.StatusInfo
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Code: "http.hsts.missing",
|
||||
Subject: p.Address,
|
||||
Message: "Strict-Transport-Security header is missing.",
|
||||
Meta: map[string]any{"fix": "Send `Strict-Transport-Security: max-age=15552000; includeSubDomains` from HTTPS responses."},
|
||||
}
|
||||
}
|
||||
if h.MaxAge < minSeconds {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.hsts.short_max_age",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("HSTS max-age=%d is below the recommended %d seconds (%d days).", h.MaxAge, minSeconds, minDays),
|
||||
}
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.hsts.ok",
|
||||
Subject: p.Address,
|
||||
Message: fmt.Sprintf("HSTS present (max-age=%d, includeSubDomains=%v, preload=%v).", h.MaxAge, h.IncludeSub, h.Preload),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// cspRule checks for the presence of a Content-Security-Policy header.
|
||||
type cspRule struct{}
|
||||
|
||||
func (r *cspRule) Name() string { return "http.csp" }
|
||||
func (r *cspRule) Description() string {
|
||||
return "Verifies the presence of a Content-Security-Policy header on HTTPS responses."
|
||||
}
|
||||
|
||||
func (r *cspRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
require := sdk.GetBoolOption(opts, OptionRequireCSP, false)
|
||||
|
||||
return EvalPerHTTPS(data, "http.csp", func(p HTTPProbe) sdk.CheckState {
|
||||
csp := ParseCSP(p.Headers["content-security-policy"])
|
||||
if csp == nil {
|
||||
status := sdk.StatusInfo
|
||||
if require {
|
||||
status = sdk.StatusWarn
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: status,
|
||||
Code: "http.csp.missing",
|
||||
Subject: p.Address,
|
||||
Message: "Content-Security-Policy header is missing.",
|
||||
Meta: map[string]any{"fix": "Define a CSP appropriate for your application (e.g. default-src 'self')."},
|
||||
}
|
||||
}
|
||||
if csp.HasUnsafe() {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.csp.unsafe",
|
||||
Subject: p.Address,
|
||||
Message: "Content-Security-Policy uses 'unsafe-inline' or 'unsafe-eval'.",
|
||||
}
|
||||
}
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.csp.ok",
|
||||
Subject: p.Address,
|
||||
Message: "Content-Security-Policy is set.",
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// xFrameOptionsRule checks X-Frame-Options (or frame-ancestors in CSP as
|
||||
// an acceptable substitute).
|
||||
type xFrameOptionsRule struct{}
|
||||
|
||||
func (r *xFrameOptionsRule) Name() string { return "http.x_frame_options" }
|
||||
func (r *xFrameOptionsRule) Description() string {
|
||||
return "Verifies that responses set X-Frame-Options or a CSP frame-ancestors directive."
|
||||
}
|
||||
|
||||
func (r *xFrameOptionsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
|
||||
return EvalPerHTTPS(data, "http.x_frame_options", func(p HTTPProbe) sdk.CheckState {
|
||||
xfo := strings.ToUpper(strings.TrimSpace(p.Headers["x-frame-options"]))
|
||||
hasFrameAncestors := ParseCSP(p.Headers["content-security-policy"]).HasDirective("frame-ancestors")
|
||||
|
||||
switch {
|
||||
case xfo == "DENY" || xfo == "SAMEORIGIN" || hasFrameAncestors:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.x_frame_options.ok",
|
||||
Subject: p.Address,
|
||||
Message: "Clickjacking protection is in place.",
|
||||
}
|
||||
case xfo != "":
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.x_frame_options.invalid",
|
||||
Subject: p.Address,
|
||||
Message: "X-Frame-Options has an unrecognised value: " + xfo,
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.x_frame_options.missing",
|
||||
Subject: p.Address,
|
||||
Message: "Neither X-Frame-Options nor CSP frame-ancestors is set.",
|
||||
Meta: map[string]any{"fix": "Send `X-Frame-Options: DENY` (or SAMEORIGIN) or use CSP frame-ancestors."},
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Showcase: a rule expressed entirely as a HeaderRuleSpec. Compare
|
||||
// with the hand-rolled rules above — the boilerplate vanishes once
|
||||
// the only logic is "is this header present and well-formed?".
|
||||
RegisterRule(HeaderRule(HeaderRuleSpec{
|
||||
Code: "http.x_content_type_options",
|
||||
Description: "Verifies that responses set X-Content-Type-Options: nosniff.",
|
||||
Header: "X-Content-Type-Options",
|
||||
Required: true,
|
||||
FixHint: "Add `X-Content-Type-Options: nosniff` to all responses.",
|
||||
Validate: func(v string) (sdk.Status, string) {
|
||||
if strings.EqualFold(v, "nosniff") {
|
||||
return sdk.StatusOK, "X-Content-Type-Options: nosniff is set."
|
||||
}
|
||||
return sdk.StatusWarn, "X-Content-Type-Options has an unexpected value: " + strings.ToLower(v)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// xXSSProtectionRule checks the legacy X-XSS-Protection header. Modern
|
||||
// browsers ignore it, but if present we want it to be sane.
|
||||
type xXSSProtectionRule struct{}
|
||||
|
||||
func (r *xXSSProtectionRule) Name() string { return "http.x_xss_protection" }
|
||||
func (r *xXSSProtectionRule) Description() string {
|
||||
return "Reports the value of the legacy X-XSS-Protection header (disabled is preferred on modern browsers; CSP is the proper replacement)."
|
||||
}
|
||||
|
||||
func (r *xXSSProtectionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
|
||||
return EvalPerHTTPS(data, "http.x_xss_protection", func(p HTTPProbe) sdk.CheckState {
|
||||
v := strings.TrimSpace(p.Headers["x-xss-protection"])
|
||||
switch {
|
||||
case v == "":
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.x_xss_protection.absent",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is not set; CSP is the recommended replacement.",
|
||||
}
|
||||
case strings.HasPrefix(v, "0"):
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.x_xss_protection.disabled",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is explicitly disabled (recommended).",
|
||||
}
|
||||
case strings.Contains(strings.ToLower(v), "mode=block"):
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.x_xss_protection.enabled",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is set to the historically recommended `1; mode=block`. Modern browsers ignore this header; CSP is the proper replacement.",
|
||||
}
|
||||
default:
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.x_xss_protection.enabled",
|
||||
Subject: p.Address,
|
||||
Message: "X-XSS-Protection is enabled. Modern browsers ignore this header; CSP is the proper replacement.",
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
223
checker/rules_security_headers_test.go
Normal file
223
checker/rules_security_headers_test.go
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestParseHSTS(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
maxAge int64
|
||||
includeSub bool
|
||||
preload bool
|
||||
}{
|
||||
{"empty", "", 0, false, false},
|
||||
{"max-age only", "max-age=31536000", 31536000, false, false},
|
||||
{"includeSubDomains", "max-age=15552000; includeSubDomains", 15552000, true, false},
|
||||
{"all flags", "max-age=63072000; includeSubDomains; preload", 63072000, true, true},
|
||||
{"quoted max-age", `max-age="3600"`, 3600, false, false},
|
||||
{"case-insensitive directive", "MAX-AGE=42; INCLUDESUBDOMAINS; PRELOAD", 42, true, true},
|
||||
{"messy spaces", " max-age=10 ; includeSubDomains ", 10, true, false},
|
||||
{"unparseable max-age", "max-age=not-a-number", 0, false, false},
|
||||
{"no max-age, only flags", "includeSubDomains; preload", 0, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := ParseHSTS(c.in)
|
||||
if c.in == "" {
|
||||
if h != nil {
|
||||
t.Errorf("ParseHSTS(%q) = %+v, want nil", c.in, h)
|
||||
}
|
||||
return
|
||||
}
|
||||
if h == nil {
|
||||
t.Fatalf("ParseHSTS(%q) returned nil", c.in)
|
||||
}
|
||||
if h.MaxAge != c.maxAge || h.IncludeSub != c.includeSub || h.Preload != c.preload {
|
||||
t.Errorf("ParseHSTS(%q) = (%d, %v, %v), want (%d, %v, %v)",
|
||||
c.in, h.MaxAge, h.IncludeSub, h.Preload, c.maxAge, c.includeSub, c.preload)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSRule_NoHTTPSProbes(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
||||
states := runRule(t, &hstsRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
if !hasCode(states, "http.hsts.no_https") {
|
||||
t.Errorf("missing no_https code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSRule_MissingRequired(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: true})
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "http.hsts.missing") {
|
||||
t.Errorf("missing 'http.hsts.missing': %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSRule_MissingNotRequired(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &hstsRule{}, data, sdk.CheckerOptions{OptionRequireHSTS: false})
|
||||
mustStatus(t, states, sdk.StatusInfo)
|
||||
}
|
||||
|
||||
func TestHSTSRule_ShortMaxAge(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Headers["strict-transport-security"] = "max-age=60"
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &hstsRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "http.hsts.short_max_age") {
|
||||
t.Errorf("missing short_max_age code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSRule_OK(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Headers["strict-transport-security"] = "max-age=63072000; includeSubDomains; preload"
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &hstsRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.hsts.ok") {
|
||||
t.Errorf("missing ok code: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHSTSRule_LoadFailure(t *testing.T) {
|
||||
states := (&hstsRule{}).Evaluate(t.Context(), &fakeObs{failGet: true}, nil)
|
||||
if len(states) != 1 || states[0].Status != sdk.StatusError {
|
||||
t.Fatalf("expected single error state, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPRule_Missing(t *testing.T) {
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
// Default: not required → Info.
|
||||
states := runRule(t, &cspRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusInfo)
|
||||
// Required → Warn.
|
||||
states = runRule(t, &cspRule{}, data, sdk.CheckerOptions{OptionRequireCSP: true})
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
}
|
||||
|
||||
func TestCSPRule_Unsafe(t *testing.T) {
|
||||
for _, csp := range []string{"default-src 'self'; script-src 'unsafe-inline'", "default-src 'unsafe-eval'"} {
|
||||
p := httpsProbe("a:443")
|
||||
p.Headers["content-security-policy"] = csp
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &cspRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "http.csp.unsafe") {
|
||||
t.Errorf("csp=%q: missing unsafe code: %+v", csp, states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSPRule_OK(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Headers["content-security-policy"] = "default-src 'self'"
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &cspRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
}
|
||||
|
||||
func TestXFrameOptionsRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
xfo string
|
||||
csp string
|
||||
want sdk.Status
|
||||
wantSub string
|
||||
}{
|
||||
{"DENY", "DENY", "", sdk.StatusOK, "http.x_frame_options.ok"},
|
||||
{"SAMEORIGIN lower", "sameorigin", "", sdk.StatusOK, "http.x_frame_options.ok"},
|
||||
{"frame-ancestors via CSP", "", "default-src 'self'; frame-ancestors 'none'", sdk.StatusOK, "http.x_frame_options.ok"},
|
||||
{"invalid value", "ALLOWALL", "", sdk.StatusWarn, "http.x_frame_options.invalid"},
|
||||
{"missing", "", "", sdk.StatusWarn, "http.x_frame_options.missing"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
if c.xfo != "" {
|
||||
p.Headers["x-frame-options"] = c.xfo
|
||||
}
|
||||
if c.csp != "" {
|
||||
p.Headers["content-security-policy"] = c.csp
|
||||
}
|
||||
data := &HTTPData{Probes: []HTTPProbe{p}}
|
||||
states := runRule(t, &xFrameOptionsRule{}, data, nil)
|
||||
mustStatus(t, states, c.want)
|
||||
if !hasCode(states, c.wantSub) {
|
||||
t.Errorf("missing code %q in %+v", c.wantSub, states)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestXContentTypeOptionsRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want sdk.Status
|
||||
code string
|
||||
}{
|
||||
{"nosniff", sdk.StatusOK, "http.x_content_type_options.ok"},
|
||||
{"NoSniff", sdk.StatusOK, "http.x_content_type_options.ok"},
|
||||
{"sniff", sdk.StatusWarn, "http.x_content_type_options.invalid"},
|
||||
{"", sdk.StatusWarn, "http.x_content_type_options.missing"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p := httpsProbe("a:443")
|
||||
if c.val != "" {
|
||||
p.Headers["x-content-type-options"] = c.val
|
||||
}
|
||||
states := runRule(t, ruleByName(t, "http.x_content_type_options"), &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, c.want)
|
||||
if !hasCode(states, c.code) {
|
||||
t.Errorf("val=%q: missing code %q in %+v", c.val, c.code, states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestXXSSProtectionRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
val string
|
||||
want sdk.Status
|
||||
code string
|
||||
}{
|
||||
{"", sdk.StatusInfo, "http.x_xss_protection.absent"},
|
||||
{"0", sdk.StatusOK, "http.x_xss_protection.disabled"},
|
||||
{"1; mode=block", sdk.StatusInfo, "http.x_xss_protection.enabled"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
p := httpsProbe("a:443")
|
||||
if c.val != "" {
|
||||
p.Headers["x-xss-protection"] = c.val
|
||||
}
|
||||
states := runRule(t, &xXSSProtectionRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, c.want)
|
||||
if !hasCode(states, c.code) {
|
||||
t.Errorf("val=%q: want code %q, got %+v", c.val, c.code, states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_NoHTTPS(t *testing.T) {
|
||||
// Each header rule must emit Unknown when there are no successful HTTPS probes.
|
||||
rules := []sdk.CheckRule{&hstsRule{}, &cspRule{}, &xFrameOptionsRule{}, ruleByName(t, "http.x_content_type_options"), &xXSSProtectionRule{}}
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpProbe("a:80")}}
|
||||
for _, r := range rules {
|
||||
states := runRule(t, r, data, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
}
|
||||
}
|
||||
81
checker/rules_sri.go
Normal file
81
checker/rules_sri.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() { RegisterRule(&sriRule{}) }
|
||||
|
||||
// sriRule reports cross-origin <script>/<link> tags that lack an
|
||||
// integrity= attribute. Same-origin assets don't need SRI (the user
|
||||
// already trusts the origin to deliver them).
|
||||
type sriRule struct{}
|
||||
|
||||
func (r *sriRule) Name() string { return "http.sri" }
|
||||
func (r *sriRule) Description() string {
|
||||
return "Reports cross-origin script and stylesheet tags that are missing Subresource Integrity (integrity=) attributes."
|
||||
}
|
||||
|
||||
func (r *sriRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
|
||||
// Only the first HTTPS probe is parsed for HTML; that's the one we
|
||||
// evaluate here.
|
||||
var resources []HTMLResource
|
||||
var subject string
|
||||
for _, p := range data.Probes {
|
||||
if p.Scheme == "https" && len(p.Resources) > 0 {
|
||||
resources = p.Resources
|
||||
subject = p.Address
|
||||
break
|
||||
}
|
||||
}
|
||||
if subject == "" {
|
||||
return []sdk.CheckState{unknownState("http.sri.no_html", "No HTML body could be parsed for SRI evaluation.")}
|
||||
}
|
||||
|
||||
var missing []HTMLResource
|
||||
crossOriginTotal := 0
|
||||
for _, res := range resources {
|
||||
if !res.CrossOrigin {
|
||||
continue
|
||||
}
|
||||
crossOriginTotal++
|
||||
if res.Integrity == "" {
|
||||
missing = append(missing, res)
|
||||
}
|
||||
}
|
||||
|
||||
if crossOriginTotal == 0 {
|
||||
return []sdk.CheckState{passState("http.sri.no_cross_origin", "No cross-origin assets reference the page.")}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return []sdk.CheckState{passState("http.sri.ok", fmt.Sprintf("All %d cross-origin assets carry integrity attributes.", crossOriginTotal))}
|
||||
}
|
||||
|
||||
var states []sdk.CheckState
|
||||
for _, res := range missing {
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.sri.missing",
|
||||
Subject: subject,
|
||||
Message: fmt.Sprintf("<%s> from %s lacks integrity= attribute", res.Tag, res.URL),
|
||||
Meta: map[string]any{
|
||||
"tag": res.Tag,
|
||||
"url": res.URL,
|
||||
"fix": "Generate an SRI hash and add `integrity=\"sha384-...\" crossorigin=\"anonymous\"`.",
|
||||
},
|
||||
})
|
||||
}
|
||||
return states
|
||||
}
|
||||
78
checker/rules_sri_test.go
Normal file
78
checker/rules_sri_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestSRIRule_NoHTML(t *testing.T) {
|
||||
// A probe without Resources is treated as "no parsed body".
|
||||
data := &HTTPData{Probes: []HTTPProbe{httpsProbe("a:443")}}
|
||||
states := runRule(t, &sriRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
if !hasCode(states, "http.sri.no_html") {
|
||||
t.Errorf("expected no_html: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSRIRule_NoCrossOrigin(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Resources = []HTMLResource{
|
||||
{Tag: "script", URL: "/local.js", CrossOrigin: false},
|
||||
{Tag: "link", URL: "/style.css", CrossOrigin: false, Rel: "stylesheet"},
|
||||
}
|
||||
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.sri.no_cross_origin") {
|
||||
t.Errorf("expected no_cross_origin: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSRIRule_AllCovered(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Resources = []HTMLResource{
|
||||
{Tag: "script", URL: "https://cdn.example/lib.js", CrossOrigin: true, Integrity: "sha384-abc"},
|
||||
{Tag: "link", URL: "https://cdn.example/style.css", CrossOrigin: true, Integrity: "sha384-def"},
|
||||
}
|
||||
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.sri.ok") {
|
||||
t.Errorf("expected ok: %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSRIRule_SomeMissing(t *testing.T) {
|
||||
p := httpsProbe("a:443")
|
||||
p.Resources = []HTMLResource{
|
||||
{Tag: "script", URL: "https://cdn.example/lib.js", CrossOrigin: true},
|
||||
{Tag: "link", URL: "https://cdn.example/style.css", CrossOrigin: true, Integrity: "sha384-def"},
|
||||
{Tag: "script", URL: "/local.js", CrossOrigin: false},
|
||||
}
|
||||
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{p}}, nil)
|
||||
if len(states) != 1 {
|
||||
t.Fatalf("expected 1 missing-state, got %d: %+v", len(states), states)
|
||||
}
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if states[0].Code != "http.sri.missing" {
|
||||
t.Errorf("unexpected code: %q", states[0].Code)
|
||||
}
|
||||
if states[0].Meta["url"] != "https://cdn.example/lib.js" {
|
||||
t.Errorf("meta.url = %v, want lib.js", states[0].Meta["url"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSRIRule_PicksFirstHTTPSWithResources(t *testing.T) {
|
||||
a := httpsProbe("a:443")
|
||||
b := httpsProbe("b:443")
|
||||
b.Resources = []HTMLResource{{Tag: "script", URL: "https://cdn/x.js", CrossOrigin: true, Integrity: "sha384-abc"}}
|
||||
states := runRule(t, &sriRule{}, &HTTPData{Probes: []HTTPProbe{a, b}}, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.sri.ok") {
|
||||
t.Errorf("expected ok with resources from second probe, got %+v", states)
|
||||
}
|
||||
}
|
||||
128
checker/rules_test.go
Normal file
128
checker/rules_test.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func TestRulesShape(t *testing.T) {
|
||||
rs := Rules()
|
||||
if len(rs) == 0 {
|
||||
t.Fatal("Rules() returned no rules")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, r := range rs {
|
||||
name := r.Name()
|
||||
if name == "" {
|
||||
t.Errorf("rule with empty name: %T", r)
|
||||
}
|
||||
if r.Description() == "" {
|
||||
t.Errorf("rule %q has empty description", name)
|
||||
}
|
||||
if seen[name] {
|
||||
t.Errorf("duplicate rule name: %q", name)
|
||||
}
|
||||
seen[name] = true
|
||||
}
|
||||
|
||||
// The two reachability rules must exist with distinct codes per scheme.
|
||||
for _, want := range []string{"http.tcp_reachable", "https.tcp_reachable", "http.https_redirect", "http.hsts", "http.csp", "http.x_frame_options", "http.x_content_type_options", "http.x_xss_protection", "http.cookie_flags", "http.sri"} {
|
||||
if !seen[want] {
|
||||
t.Errorf("missing rule %q in Rules()", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHTTPDataFailure(t *testing.T) {
|
||||
obs := &fakeObs{failGet: true}
|
||||
data, errSt := loadHTTPData(context.Background(), obs)
|
||||
if data != nil {
|
||||
t.Fatal("expected nil data on failure")
|
||||
}
|
||||
if errSt == nil || errSt.Status != sdk.StatusError {
|
||||
t.Fatalf("expected StatusError, got %+v", errSt)
|
||||
}
|
||||
if errSt.Code != "http.observation_error" {
|
||||
t.Errorf("unexpected code: %q", errSt.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadHTTPDataSuccess(t *testing.T) {
|
||||
want := &HTTPData{Domain: "example.test", Probes: []HTTPProbe{httpsProbe("203.0.113.1:443")}}
|
||||
data, errSt := loadHTTPData(context.Background(), &fakeObs{data: want})
|
||||
if errSt != nil {
|
||||
t.Fatalf("unexpected error state: %+v", errSt)
|
||||
}
|
||||
if data == nil || data.Domain != "example.test" || len(data.Probes) != 1 {
|
||||
t.Fatalf("unexpected data: %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbesByScheme(t *testing.T) {
|
||||
probes := []HTTPProbe{
|
||||
httpProbe("a:80"),
|
||||
httpsProbe("a:443"),
|
||||
httpProbe("b:80"),
|
||||
}
|
||||
if got := probesByScheme(probes, "http"); len(got) != 2 {
|
||||
t.Errorf("http: got %d, want 2", len(got))
|
||||
}
|
||||
if got := probesByScheme(probes, "https"); len(got) != 1 {
|
||||
t.Errorf("https: got %d, want 1", len(got))
|
||||
}
|
||||
if got := probesByScheme(probes, "gopher"); got != nil {
|
||||
t.Errorf("unknown scheme should return nil, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessfulHTTPSProbes(t *testing.T) {
|
||||
failed := httpsProbe("a:443")
|
||||
failed.StatusCode = 0
|
||||
failed.TCPConnected = false
|
||||
probes := []HTTPProbe{
|
||||
httpProbe("a:80"),
|
||||
httpsProbe("a:443"),
|
||||
failed,
|
||||
}
|
||||
got := successfulHTTPSProbes(probes)
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("got %d successful HTTPS probes, want 1", len(got))
|
||||
}
|
||||
if got[0].Address != "a:443" || got[0].StatusCode != 200 {
|
||||
t.Errorf("unexpected probe: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPassAndUnknownStateBuilders(t *testing.T) {
|
||||
if s := passState("c", "m"); s.Status != sdk.StatusOK || s.Code != "c" || s.Message != "m" {
|
||||
t.Errorf("passState wrong: %+v", s)
|
||||
}
|
||||
if s := unknownState("c", "m"); s.Status != sdk.StatusUnknown || s.Code != "c" || s.Message != "m" {
|
||||
t.Errorf("unknownState wrong: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSameSiteString(t *testing.T) {
|
||||
cases := []struct {
|
||||
in http.SameSite
|
||||
want string
|
||||
}{
|
||||
{http.SameSiteLaxMode, "Lax"},
|
||||
{http.SameSiteStrictMode, "Strict"},
|
||||
{http.SameSiteNoneMode, "None"},
|
||||
{http.SameSiteDefaultMode, ""},
|
||||
{http.SameSite(99), ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := sameSiteString(c.in); got != c.want {
|
||||
t.Errorf("sameSiteString(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
64
checker/rules_wellknown.go
Normal file
64
checker/rules_wellknown.go
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func init() { RegisterRule(&securityTxtRule{}) }
|
||||
|
||||
// securityTxtRule reports whether /.well-known/security.txt is published
|
||||
// (RFC 9116). Absence is an Info, not a Warn: many sites legitimately
|
||||
// have no security disclosure pipeline, but it is now the expected place
|
||||
// for researchers to look first.
|
||||
type securityTxtRule struct{}
|
||||
|
||||
func (r *securityTxtRule) Name() string { return "http.security_txt" }
|
||||
func (r *securityTxtRule) Description() string {
|
||||
return "Reports whether /.well-known/security.txt (RFC 9116) is published."
|
||||
}
|
||||
|
||||
func (r *securityTxtRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadHTTPData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
wk, ok, err := LoadExtension[WellKnownData](data, ObservationKeyWellKnown)
|
||||
if err != nil {
|
||||
return []sdk.CheckState{{Status: sdk.StatusError, Code: "http.security_txt.decode_error", Message: err.Error()}}
|
||||
}
|
||||
if !ok {
|
||||
return []sdk.CheckState{unknownState("http.security_txt.no_data", "Well-known collector did not run.")}
|
||||
}
|
||||
probe := wk.URIs["/.well-known/security.txt"]
|
||||
switch {
|
||||
case probe.StatusCode == 200 && probe.Bytes > 0:
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusOK,
|
||||
Code: "http.security_txt.ok",
|
||||
Subject: data.Domain,
|
||||
Message: fmt.Sprintf("/.well-known/security.txt is published (%d bytes).", probe.Bytes),
|
||||
}}
|
||||
case probe.StatusCode == 200:
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusWarn,
|
||||
Code: "http.security_txt.empty",
|
||||
Subject: data.Domain,
|
||||
Message: "/.well-known/security.txt responded 200 but is empty.",
|
||||
}}
|
||||
default:
|
||||
return []sdk.CheckState{{
|
||||
Status: sdk.StatusInfo,
|
||||
Code: "http.security_txt.missing",
|
||||
Subject: data.Domain,
|
||||
Message: fmt.Sprintf("/.well-known/security.txt is not published (status %d).", probe.StatusCode),
|
||||
Meta: map[string]any{"fix": "Publish /.well-known/security.txt per RFC 9116 (Contact:, Expires:, …)."},
|
||||
}}
|
||||
}
|
||||
}
|
||||
96
checker/rules_wellknown_test.go
Normal file
96
checker/rules_wellknown_test.go
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
func wellKnownData(t *testing.T, probes map[string]WellKnownProbe) map[string]json.RawMessage {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(WellKnownData{URIs: probes})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
return map[string]json.RawMessage{ObservationKeyWellKnown: raw}
|
||||
}
|
||||
|
||||
func TestSecurityTxtRule_OK(t *testing.T) {
|
||||
data := &HTTPData{
|
||||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
Extensions: wellKnownData(t, map[string]WellKnownProbe{
|
||||
"/.well-known/security.txt": {StatusCode: 200, Bytes: 128},
|
||||
"/robots.txt": {StatusCode: 200, Bytes: 42},
|
||||
}),
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusOK)
|
||||
if !hasCode(states, "http.security_txt.ok") {
|
||||
t.Errorf("expected ok, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityTxtRule_Empty(t *testing.T) {
|
||||
data := &HTTPData{
|
||||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
Extensions: wellKnownData(t, map[string]WellKnownProbe{
|
||||
"/.well-known/security.txt": {StatusCode: 200, Bytes: 0},
|
||||
}),
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusWarn)
|
||||
if !hasCode(states, "http.security_txt.empty") {
|
||||
t.Errorf("expected empty, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityTxtRule_Missing(t *testing.T) {
|
||||
data := &HTTPData{
|
||||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
Extensions: wellKnownData(t, map[string]WellKnownProbe{
|
||||
"/.well-known/security.txt": {StatusCode: 404},
|
||||
}),
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusInfo)
|
||||
if !hasCode(states, "http.security_txt.missing") {
|
||||
t.Errorf("expected missing, got %+v", states)
|
||||
}
|
||||
if states[0].Meta["fix"] == nil {
|
||||
t.Errorf("expected fix hint in meta, got %+v", states[0].Meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityTxtRule_NoCollectorData(t *testing.T) {
|
||||
data := &HTTPData{
|
||||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
mustStatus(t, states, sdk.StatusUnknown)
|
||||
if !hasCode(states, "http.security_txt.no_data") {
|
||||
t.Errorf("expected no_data, got %+v", states)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityTxtRule_DecodeError(t *testing.T) {
|
||||
data := &HTTPData{
|
||||
Domain: "example.test",
|
||||
Probes: []HTTPProbe{httpsProbe("a:443")},
|
||||
Extensions: map[string]json.RawMessage{
|
||||
ObservationKeyWellKnown: json.RawMessage(`"not an object"`),
|
||||
},
|
||||
}
|
||||
states := runRule(t, &securityTxtRule{}, data, nil)
|
||||
if states[0].Status != sdk.StatusError || states[0].Code != "http.security_txt.decode_error" {
|
||||
t.Errorf("expected decode_error, got %+v", states)
|
||||
}
|
||||
}
|
||||
79
checker/service.go
Normal file
79
checker/service.go
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/services/abstract"
|
||||
)
|
||||
|
||||
// resolver is the *net.Resolver used to discover additional A/AAAA records
|
||||
// beyond what abstract.Server pins. Overridable in tests.
|
||||
var resolver = net.DefaultResolver
|
||||
|
||||
// resolveServer extracts the *abstract.Server payload from the options.
|
||||
func resolveServer(opts sdk.CheckerOptions) (*abstract.Server, error) {
|
||||
svc, ok := sdk.GetOption[happydns.ServiceMessage](opts, OptionService)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no service in options: did the host wire AutoFillService?")
|
||||
}
|
||||
if svc.Type != "abstract.Server" {
|
||||
return nil, fmt.Errorf("service is %q, expected abstract.Server", svc.Type)
|
||||
}
|
||||
var server abstract.Server
|
||||
if err := json.Unmarshal(svc.Service, &server); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal abstract.Server: %w", err)
|
||||
}
|
||||
return &server, nil
|
||||
}
|
||||
|
||||
// addressesFromServer returns the (host, ips) tuple to probe.
|
||||
func addressesFromServer(server *abstract.Server) (host string, ips []string) {
|
||||
if server.A != nil && len(server.A.A) > 0 {
|
||||
host = strings.TrimSuffix(server.A.Hdr.Name, ".")
|
||||
ips = append(ips, server.A.A.String())
|
||||
}
|
||||
if server.AAAA != nil && len(server.AAAA.AAAA) > 0 {
|
||||
if host == "" {
|
||||
host = strings.TrimSuffix(server.AAAA.Hdr.Name, ".")
|
||||
}
|
||||
ips = append(ips, server.AAAA.AAAA.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// discoverIPs resolves host through the system resolver and returns every
|
||||
// A/AAAA address it knows about. abstract.Server only carries one pinned A
|
||||
// and one pinned AAAA, so a domain backed by multiple records would only
|
||||
// be partially probed without this.
|
||||
//
|
||||
// Failures are non-fatal: callers fall back to the pinned IPs from
|
||||
// addressesFromServer. Returned IPs are deduped against `seen`.
|
||||
func discoverIPs(ctx context.Context, host string, seen map[string]struct{}) []string {
|
||||
if host == "" {
|
||||
return nil
|
||||
}
|
||||
addrs, err := resolver.LookupIP(ctx, "ip", host)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, ip := range addrs {
|
||||
s := ip.String()
|
||||
if _, dup := seen[s]; dup {
|
||||
continue
|
||||
}
|
||||
seen[s] = struct{}{}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
113
checker/testhelpers_test.go
Normal file
113
checker/testhelpers_test.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// fakeObs is an in-memory ObservationGetter backed by a single HTTPData
|
||||
// payload stored under ObservationKeyHTTP. A nil payload makes Get return
|
||||
// an error, which lets tests cover the loadHTTPData failure branch.
|
||||
type fakeObs struct {
|
||||
data *HTTPData
|
||||
failGet bool
|
||||
}
|
||||
|
||||
func (f *fakeObs) Get(_ context.Context, key sdk.ObservationKey, dest any) error {
|
||||
if f.failGet {
|
||||
return errString("forced get failure")
|
||||
}
|
||||
if key != ObservationKeyHTTP {
|
||||
return errString("unexpected key: " + key)
|
||||
}
|
||||
if f.data == nil {
|
||||
return errString("no data")
|
||||
}
|
||||
raw, err := json.Marshal(f.data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, dest)
|
||||
}
|
||||
|
||||
func (f *fakeObs) GetRelated(_ context.Context, _ sdk.ObservationKey) ([]sdk.RelatedObservation, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
// runRule is a small wrapper that builds a fakeObs around the supplied
|
||||
// HTTPData and evaluates the rule with the given options. It returns the
|
||||
// states verbatim; assertion is left to the caller.
|
||||
func runRule(t *testing.T, r sdk.CheckRule, data *HTTPData, opts sdk.CheckerOptions) []sdk.CheckState {
|
||||
t.Helper()
|
||||
if opts == nil {
|
||||
opts = sdk.CheckerOptions{}
|
||||
}
|
||||
states := r.Evaluate(context.Background(), &fakeObs{data: data}, opts)
|
||||
if len(states) == 0 {
|
||||
t.Fatalf("rule %q returned no states (must always return at least one)", r.Name())
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
func httpsProbe(addr string) HTTPProbe {
|
||||
return HTTPProbe{
|
||||
Scheme: "https",
|
||||
Host: "example.test",
|
||||
IP: "203.0.113.1",
|
||||
Port: 443,
|
||||
Address: addr,
|
||||
TCPConnected: true,
|
||||
StatusCode: 200,
|
||||
Headers: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func httpProbe(addr string) HTTPProbe {
|
||||
p := httpsProbe(addr)
|
||||
p.Scheme = "http"
|
||||
p.Port = 80
|
||||
return p
|
||||
}
|
||||
|
||||
func mustStatus(t *testing.T, states []sdk.CheckState, want sdk.Status) {
|
||||
t.Helper()
|
||||
for _, s := range states {
|
||||
if s.Status != want {
|
||||
t.Fatalf("status: got %s (%q), want %s; full states: %+v", s.Status, s.Code, want, states)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ruleByName looks a rule up in the global registry by Name(). It exists
|
||||
// so tests can drive rules wired declaratively (HeaderRule and friends)
|
||||
// without depending on a concrete type.
|
||||
func ruleByName(t *testing.T, name string) sdk.CheckRule {
|
||||
t.Helper()
|
||||
for _, r := range Rules() {
|
||||
if r.Name() == name {
|
||||
return r
|
||||
}
|
||||
}
|
||||
t.Fatalf("rule %q not found in registry", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasCode(states []sdk.CheckState, code string) bool {
|
||||
for _, s := range states {
|
||||
if s.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
125
checker/types.go
Normal file
125
checker/types.go
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
// Package checker implements an HTTP/HTTPS server checker for happyDomain.
|
||||
// It probes the abstract.Server it is attached to over HTTP (80) and HTTPS
|
||||
// (443), captures response headers, cookies and the (parsed) HTML body, then
|
||||
// evaluates a set of independent rules covering reachability, the HTTP→HTTPS
|
||||
// upgrade path, modern transport-security headers (HSTS, CSP), application
|
||||
// security headers (X-Frame-Options, X-Content-Type-Options, X-XSS-Protection)
|
||||
// and Subresource Integrity. Deep TLS / certificate analysis is intentionally
|
||||
// delegated to checker-tls.
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
const ObservationKeyHTTP = "http"
|
||||
|
||||
const (
|
||||
OptionService = "service"
|
||||
OptionProbeTimeoutMs = "probeTimeoutMs"
|
||||
OptionMaxRedirects = "maxRedirects"
|
||||
OptionUserAgent = "userAgent"
|
||||
OptionRequireHTTPS = "requireHTTPS"
|
||||
OptionRequireHSTS = "requireHSTS"
|
||||
OptionMinHSTSMaxAgeDays = "minHSTSMaxAgeDays"
|
||||
OptionRequireCSP = "requireCSP"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultHTTPPort uint16 = 80
|
||||
DefaultHTTPSPort uint16 = 443
|
||||
DefaultProbeTimeoutMs = 10000
|
||||
DefaultMaxRedirects = 5
|
||||
DefaultUserAgent = "happyDomain-checker-http/1.0"
|
||||
DefaultMinHSTSMaxAge = 180 // days; 180d ≈ 15552000s, the commonly recommended minimum
|
||||
MaxConcurrentProbes = 8
|
||||
MaxBodyBytes = 1 << 20 // 1 MiB cap on HTML body to keep memory bounded
|
||||
)
|
||||
|
||||
// HTTPData is the full collected payload written under ObservationKeyHTTP.
|
||||
//
|
||||
// Probes/Domain/CollectedAt come from the root collector and are kept at
|
||||
// the top level for backward compatibility with the rules that have
|
||||
// always read them directly.
|
||||
//
|
||||
// Extensions holds the JSON-encoded outputs of every additional Collector
|
||||
// registered via RegisterCollector, keyed by Collector.Key(). Rules
|
||||
// access them via LoadExtension[T] to get a typed view.
|
||||
type HTTPData struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Probes []HTTPProbe `json:"probes"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
|
||||
Extensions map[string]json.RawMessage `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPProbe is the outcome of a single (scheme, ip, port) probe.
|
||||
type HTTPProbe struct {
|
||||
Scheme string `json:"scheme"` // "http" or "https"
|
||||
Host string `json:"host"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
IsIPv6 bool `json:"ipv6,omitempty"`
|
||||
|
||||
TCPConnected bool `json:"tcp_connected"`
|
||||
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Final response after following redirects (if any).
|
||||
StatusCode int `json:"status_code,omitempty"`
|
||||
FinalURL string `json:"final_url,omitempty"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Cookies []CookieInfo `json:"cookies,omitempty"`
|
||||
RedirectChain []RedirectStep `json:"redirect_chain,omitempty"`
|
||||
|
||||
// Parsed HTML resource references (only populated for the primary
|
||||
// HTTPS probe, to keep payloads small).
|
||||
Resources []HTMLResource `json:"resources,omitempty"`
|
||||
HTMLBytes int `json:"html_bytes,omitempty"`
|
||||
BodyTruncated bool `json:"body_truncated,omitempty"`
|
||||
}
|
||||
|
||||
// RedirectStep records one hop of a redirect chain.
|
||||
type RedirectStep struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// CookieInfo summarises one Set-Cookie header.
|
||||
type CookieInfo struct {
|
||||
Name string `json:"name"`
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Secure bool `json:"secure"`
|
||||
HttpOnly bool `json:"http_only"`
|
||||
SameSite string `json:"same_site,omitempty"` // "Strict", "Lax", "None", or ""
|
||||
HasExpiry bool `json:"has_expiry,omitempty"`
|
||||
}
|
||||
|
||||
// HTMLResource is a <script src=...> or <link href=...> reference extracted
|
||||
// from the HTML body, used to evaluate Subresource Integrity coverage.
|
||||
type HTMLResource struct {
|
||||
Tag string `json:"tag"` // "script" or "link"
|
||||
URL string `json:"url"`
|
||||
CrossOrigin bool `json:"cross_origin"`
|
||||
Integrity string `json:"integrity,omitempty"`
|
||||
Rel string `json:"rel,omitempty"`
|
||||
}
|
||||
|
||||
// Severity levels used internally by rules.
|
||||
const (
|
||||
SeverityCrit = "crit"
|
||||
SeverityWarn = "warn"
|
||||
SeverityInfo = "info"
|
||||
SeverityOK = "ok"
|
||||
)
|
||||
45
go.mod
Normal file
45
go.mod
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
module git.happydns.org/checker-http
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-sdk-go v1.5.0
|
||||
git.happydns.org/happyDomain v0.7.0
|
||||
github.com/miekg/dns v1.1.72
|
||||
golang.org/x/net v0.51.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.12.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
golang.org/x/arch v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.49.0 // indirect
|
||||
golang.org/x/mod v0.33.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
golang.org/x/tools v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
102
go.sum
Normal file
102
go.sum
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
git.happydns.org/checker-sdk-go v1.5.0 h1:5uD5Cm6xJ+lwnhbJ09iCXGHbYS9zRh+Yh0NeBHkAPBY=
|
||||
git.happydns.org/checker-sdk-go v1.5.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
git.happydns.org/happyDomain v0.7.0 h1:NV82/NbcSeRm0+IUZqaK3Vu9Ovl5+vv4AigUJZMdwws=
|
||||
git.happydns.org/happyDomain v0.7.0/go.mod h1:5tgkmqFE65kK359rY49V++49wgZ0gco+Gh9X6tbL+bY=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12 h1:9Nu54bhS/H/Kgo2/7xNSUuC5G28VR8ljfrLKU2G4IjU=
|
||||
github.com/json-iterator/go v1.1.13-0.20220915233716-71ac16282d12/go.mod h1:TBzl5BIHNXfS9+C35ZyJaklL7mLDbgUkcgXzSLa8Tk0=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
30
main.go
Normal file
30
main.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
httpchk "git.happydns.org/checker-http/checker"
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
httpchk.Version = Version
|
||||
|
||||
srv := server.New(httpchk.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
18
plugin/plugin.go
Normal file
18
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
httpchk "git.happydns.org/checker-http/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
var Version = "custom-build"
|
||||
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
httpchk.Version = Version
|
||||
prvd := httpchk.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue