Initial commit
This commit is contained in:
commit
06036c89d9
29 changed files with 4891 additions and 0 deletions
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
checker-ssh
|
||||
checker-ssh.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-ssh .
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /checker-ssh /checker-ssh
|
||||
USER 65534:65534
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["/checker-ssh", "-healthcheck"]
|
||||
ENTRYPOINT ["/checker-ssh"]
|
||||
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-ssh
|
||||
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
|
||||
32
NOTICE
Normal file
32
NOTICE
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
checker-ping
|
||||
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
|
||||
|
||||
This product includes software developed as part of the happyDomain
|
||||
project (https://happydomain.org).
|
||||
|
||||
Portions of this code were originally written for the happyDomain
|
||||
server (licensed under AGPL-3.0 and a commercial license) and are
|
||||
made available there under the Apache License, Version 2.0 to enable
|
||||
a permissively licensed ecosystem of checker plugins.
|
||||
|
||||
You may obtain a copy of the Apache License 2.0 at:
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
157
README.md
Normal file
157
README.md
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
# checker-ssh
|
||||
|
||||
Deep SSH security checker for [happyDomain](https://www.happydomain.org/).
|
||||
|
||||
Given an `abstract.Server` service (A / AAAA / SSHFP records), the checker
|
||||
connects to the advertised SSH port(s) and produces a comprehensive
|
||||
audit: reachability, banner-to-CVE matches, full algorithm posture
|
||||
(KEX / host-key / cipher / MAC / compression), observed host keys,
|
||||
SSHFP fingerprint validation, and authentication method exposure.
|
||||
|
||||
## What it checks
|
||||
|
||||
### Reachability
|
||||
- TCP connect to port 22 (and optionally extra ports) on every A/AAAA
|
||||
address of the service.
|
||||
- SSH-2.0 protocol banner read.
|
||||
|
||||
### Banner / CVE
|
||||
The banner is parsed into an `OpenSSH_X.Ypz` tuple and matched against
|
||||
a bundled subset of the [ssh-audit](https://github.com/jtesta/ssh-audit)
|
||||
vulnerability database, including:
|
||||
|
||||
| CVE | Issue |
|
||||
| --- | --- |
|
||||
| CVE-2024-6387 | regreSSHion - unauth RCE as root (8.5p1 <= v < 9.8p1) |
|
||||
| CVE-2023-38408 | ssh-agent PKCS#11 RCE (5.5 ≤ v < 9.3p2) |
|
||||
| CVE-2023-48795 | Terrapin prefix-truncation (v < 9.6p1) |
|
||||
| CVE-2021-41617 | AuthorizedKeysCommand privdrop (6.2 ≤ v < 8.8p1) |
|
||||
| CVE-2020-15778 | scp command injection (v < 8.4p1) |
|
||||
| CVE-2018-15473 | username enumeration (v < 7.8p1) |
|
||||
|
||||
### Algorithm posture
|
||||
A raw `SSH_MSG_KEXINIT` is exchanged with the server to enumerate every
|
||||
algorithm it advertises. Each entry is graded against a curated table
|
||||
inspired by ssh-audit:
|
||||
|
||||
- **crit**: `diffie-hellman-group1-sha1` (Logjam), `3des-cbc` (Sweet32),
|
||||
`arcfour*`, `hmac-md5*`, `hmac-sha1-96`, `ssh-dss`, `none`.
|
||||
- **warn**: `ssh-rsa` (RFC 8332 deprecated), `diffie-hellman-group14-sha1`,
|
||||
AES-CBC, non-ETM MACs, `hmac-sha1`, missing strict-KEX marker
|
||||
(CVE-2023-48795 mitigation).
|
||||
- **ok**: `curve25519-sha256`, `sntrup761x25519-sha512@openssh.com`,
|
||||
`mlkem768x25519-sha256`, `ssh-ed25519`, AES-GCM/ChaCha20-Poly1305,
|
||||
SHA-2 ETM MACs.
|
||||
|
||||
### SSHFP validation
|
||||
For each observed host key, the checker:
|
||||
|
||||
- computes SHA-1 and SHA-256 fingerprints,
|
||||
- matches them against the `abstract.Server.SSHFP` records declared in
|
||||
the zone,
|
||||
- flags `sshfp_missing`, `sshfp_not_covered`, `sshfp_only_sha1` or
|
||||
`sshfp_mismatch` as appropriate, with copy-pasteable fix snippets.
|
||||
|
||||
### Authentication methods
|
||||
A second connection is opened with a dummy user and no credentials. The
|
||||
server replies with the auth-method list, which is surfaced as
|
||||
`password`, `publickey`, `keyboard-interactive` chips. Password
|
||||
authentication triggers a `password_auth_enabled` warning.
|
||||
|
||||
## HTML report
|
||||
|
||||
The iframe report is structured for "fix me fast":
|
||||
|
||||
1. **Overall status** banner + SSHFP verdict chips.
|
||||
2. **What to fix**: top issues (crit -> warn), each with a
|
||||
copy-pasteable remediation snippet (sshd_config lines, SSHFP DNS
|
||||
records, ssh-keygen invocations).
|
||||
3. **SSHFP table** with per-record match status.
|
||||
4. **Per-endpoint details**: expandable sections with host-key
|
||||
fingerprints, algorithm tables (broken entries highlighted), and
|
||||
advertised auth methods.
|
||||
|
||||
## Usage
|
||||
|
||||
### Standalone HTTP server
|
||||
```bash
|
||||
make
|
||||
./checker-ssh -listen :8080
|
||||
```
|
||||
Endpoints:
|
||||
- `GET /health`
|
||||
- `GET /definition`
|
||||
- `POST /collect`
|
||||
- `POST /evaluate`
|
||||
- `POST /report`
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
make docker
|
||||
docker run -p 8080:8080 happydomain/checker-ssh
|
||||
```
|
||||
|
||||
### happyDomain plugin
|
||||
```bash
|
||||
make plugin
|
||||
# produces checker-ssh.so, loadable as a Go plugin
|
||||
```
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --- | --- | --- | --- |
|
||||
| `ports` | string | `""` | Comma-separated extra ports (port 22 is always probed). |
|
||||
| `probeTimeoutMs` | number | `10000` | Per-endpoint dial + handshake timeout. |
|
||||
| `includeAuthProbe` | bool | `true` | Open a second connection to enumerate auth methods. |
|
||||
|
||||
## Observation key
|
||||
|
||||
Writes a single observation under `ssh`:
|
||||
```json
|
||||
{
|
||||
"domain": "...",
|
||||
"endpoints": [
|
||||
{
|
||||
"host": "...", "port": 22, "address": "...",
|
||||
"banner": "SSH-2.0-OpenSSH_9.3p1",
|
||||
"kex_algorithms": ["curve25519-sha256", "..."],
|
||||
"host_keys": [{"type": "ssh-ed25519", "sha256": "abc..."}],
|
||||
"auth_methods": ["publickey"],
|
||||
"issues": [ { "code": "...", "severity": "warn", ... } ]
|
||||
}
|
||||
],
|
||||
"sshfp": { "present": true, "records": [...] },
|
||||
"collected_at": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## License & licensing roadmap
|
||||
|
||||
This project is currently licensed under the **GNU Affero General Public
|
||||
License v3.0** (see `LICENSE`), because it still imports
|
||||
`happydns.ServiceMessage` and `abstract.Server` from the happyDomain
|
||||
server module (`git.happydns.org/happyDomain/model` and
|
||||
`git.happydns.org/happyDomain/services/abstract`), which are themselves
|
||||
distributed under AGPL-3.0 and a commercial license.
|
||||
|
||||
The core checker types (`CheckerOptions`, `CheckerDefinition`,
|
||||
`ObservationProvider`, `CheckRule`, …) have already been migrated to
|
||||
[`checker-sdk-go`](https://git.happydns.org/checker-sdk-go); only the
|
||||
service-message types remain on the AGPL side.
|
||||
|
||||
**Planned relicensing:** as soon as the remaining `ServiceMessage` /
|
||||
`abstract.Server` dependency has been removed (moved into a dedicated
|
||||
permissively licensed module), this project will be relicensed under the
|
||||
**MIT License**, in line with the rest of the happyDomain checker
|
||||
ecosystem (see `checker-dummy` for the target shape).
|
||||
|
||||
**Contributors notice:** by submitting a contribution to this repository,
|
||||
you accept that your contribution will be relicensed from AGPL-3.0 to MIT
|
||||
at the time of the relicensing described above. If you do not agree with
|
||||
this, please do not submit contributions until the relicensing has taken
|
||||
place.
|
||||
|
||||
The third-party Apache-2.0 attributions for `checker-sdk-go` are recorded
|
||||
in `NOTICE` and must accompany any binary or source redistribution of this
|
||||
project.
|
||||
285
checker/algorithms.go
Normal file
285
checker/algorithms.go
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The tables below encode the safety verdict for each common SSH
|
||||
// algorithm name. They are a condensed, hand-curated view of the
|
||||
// ssh-audit algorithm database
|
||||
// (https://github.com/jtesta/ssh-audit/blob/master/src/ssh_audit/ssh2_kexdb.py)
|
||||
// reduced to the severities this checker surfaces.
|
||||
//
|
||||
// The logic we apply is: an algorithm advertised by the server is OK
|
||||
// if it is in safeAlgos, suspicious (warn) if in weakAlgos, and
|
||||
// critical if in brokenAlgos. Anything unknown is silently passed
|
||||
// through: SSH is extensible, we prefer false negatives to noise.
|
||||
|
||||
type algoVerdict struct {
|
||||
severity string // "crit", "warn", "info"
|
||||
reason string // short human-readable reason
|
||||
}
|
||||
|
||||
// KEX (key exchange) algorithms.
|
||||
var kexAlgos = map[string]algoVerdict{
|
||||
"curve25519-sha256": {},
|
||||
"curve25519-sha256@libssh.org": {},
|
||||
"sntrup761x25519-sha512@openssh.com": {severity: SeverityOK, reason: "hybrid post-quantum"},
|
||||
"mlkem768x25519-sha256": {severity: SeverityOK, reason: "hybrid post-quantum (ML-KEM)"},
|
||||
"ecdh-sha2-nistp256": {},
|
||||
"ecdh-sha2-nistp384": {},
|
||||
"ecdh-sha2-nistp521": {},
|
||||
"diffie-hellman-group-exchange-sha256": {},
|
||||
"diffie-hellman-group14-sha256": {},
|
||||
"diffie-hellman-group16-sha512": {},
|
||||
"diffie-hellman-group18-sha512": {},
|
||||
"kex-strict-s-v00@openssh.com": {}, // not a real KEX, advertises "strict-kex" per CVE-2023-48795
|
||||
|
||||
// Deprecated / suspicious.
|
||||
"diffie-hellman-group14-sha1": {severity: SeverityWarn, reason: "SHA-1 hash; upgrade to -sha256 variant"},
|
||||
"diffie-hellman-group-exchange-sha1": {severity: SeverityWarn, reason: "SHA-1 hash; group-exchange with SHA-1 is discouraged"},
|
||||
"rsa1024-sha1": {severity: SeverityCrit, reason: "1024-bit RSA KEX with SHA-1"},
|
||||
"rsa2048-sha256": {severity: SeverityWarn, reason: "RSA key transport is deprecated"},
|
||||
|
||||
// Broken.
|
||||
"diffie-hellman-group1-sha1": {severity: SeverityCrit, reason: "weak 1024-bit MODP group; vulnerable to Logjam"},
|
||||
"gss-group1-sha1-toWM5Slw5Ew8Mqkay+al2g==": {severity: SeverityCrit, reason: "weak 1024-bit MODP group"},
|
||||
}
|
||||
|
||||
// Server host-key algorithms.
|
||||
var hostKeyAlgos = map[string]algoVerdict{
|
||||
"ssh-ed25519": {},
|
||||
"ssh-ed25519-cert-v01@openssh.com": {},
|
||||
"ecdsa-sha2-nistp256": {},
|
||||
"ecdsa-sha2-nistp384": {},
|
||||
"ecdsa-sha2-nistp521": {},
|
||||
"rsa-sha2-512": {},
|
||||
"rsa-sha2-256": {},
|
||||
|
||||
"ssh-rsa": {severity: SeverityWarn, reason: "RSA with SHA-1 signatures (RFC 8332 marks as deprecated)"},
|
||||
"ssh-dss": {severity: SeverityCrit, reason: "DSA is obsolete (weak 1024-bit signatures)"},
|
||||
"ssh-rsa-cert-v01@openssh.com": {severity: SeverityWarn, reason: "RSA/SHA-1 certificate signatures are deprecated"},
|
||||
"ssh-dss-cert-v01@openssh.com": {severity: SeverityCrit, reason: "DSA certificate signatures are obsolete"},
|
||||
}
|
||||
|
||||
// Symmetric encryption algorithms (ciphers).
|
||||
var cipherAlgos = map[string]algoVerdict{
|
||||
"chacha20-poly1305@openssh.com": {},
|
||||
"aes256-gcm@openssh.com": {},
|
||||
"aes128-gcm@openssh.com": {},
|
||||
"aes256-ctr": {},
|
||||
"aes192-ctr": {},
|
||||
"aes128-ctr": {},
|
||||
|
||||
"aes256-cbc": {severity: SeverityWarn, reason: "CBC mode is vulnerable to oracle attacks; prefer CTR or GCM"},
|
||||
"aes192-cbc": {severity: SeverityWarn, reason: "CBC mode; prefer CTR or GCM"},
|
||||
"aes128-cbc": {severity: SeverityWarn, reason: "CBC mode; prefer CTR or GCM"},
|
||||
"rijndael-cbc@lysator.liu.se": {severity: SeverityWarn, reason: "legacy AES-CBC alias"},
|
||||
|
||||
"3des-cbc": {severity: SeverityCrit, reason: "Triple-DES is obsolete (Sweet32 birthday attack)"},
|
||||
"blowfish-cbc": {severity: SeverityCrit, reason: "Blowfish-CBC; 64-bit block size (Sweet32)"},
|
||||
"cast128-cbc": {severity: SeverityCrit, reason: "64-bit block; Sweet32"},
|
||||
"arcfour": {severity: SeverityCrit, reason: "RC4 is broken"},
|
||||
"arcfour128": {severity: SeverityCrit, reason: "RC4 is broken"},
|
||||
"arcfour256": {severity: SeverityCrit, reason: "RC4 is broken"},
|
||||
"none": {severity: SeverityCrit, reason: "No encryption"},
|
||||
"des-cbc@ssh.com": {severity: SeverityCrit, reason: "DES is broken"},
|
||||
}
|
||||
|
||||
// MAC algorithms.
|
||||
var macAlgos = map[string]algoVerdict{
|
||||
"hmac-sha2-512-etm@openssh.com": {},
|
||||
"hmac-sha2-256-etm@openssh.com": {},
|
||||
"umac-128-etm@openssh.com": {},
|
||||
"hmac-sha2-512": {severity: SeverityWarn, reason: "non-ETM MAC; prefer -etm@openssh.com variants"},
|
||||
"hmac-sha2-256": {severity: SeverityWarn, reason: "non-ETM MAC; prefer -etm@openssh.com variants"},
|
||||
"umac-128@openssh.com": {severity: SeverityWarn, reason: "non-ETM MAC; prefer umac-128-etm@openssh.com"},
|
||||
"umac-64@openssh.com": {severity: SeverityWarn, reason: "64-bit tag; prefer umac-128-etm@openssh.com"},
|
||||
|
||||
"hmac-sha1": {severity: SeverityWarn, reason: "SHA-1 MAC; prefer SHA-2 ETM"},
|
||||
"hmac-sha1-etm@openssh.com": {severity: SeverityWarn, reason: "SHA-1 MAC (ETM); prefer SHA-2 ETM"},
|
||||
"hmac-sha1-96": {severity: SeverityCrit, reason: "truncated SHA-1 MAC; forbidden"},
|
||||
"hmac-md5": {severity: SeverityCrit, reason: "MD5 MAC; broken"},
|
||||
"hmac-md5-96": {severity: SeverityCrit, reason: "truncated MD5 MAC; broken"},
|
||||
"hmac-ripemd160": {severity: SeverityWarn, reason: "RIPEMD-160 MAC; seldom used"},
|
||||
"none": {severity: SeverityCrit, reason: "No MAC"},
|
||||
}
|
||||
|
||||
// Unknown names produce an empty verdict (no severity); callers treat that as "don't report"
|
||||
// to avoid noise from SSH extensions we can't classify.
|
||||
func verdictFor(table map[string]algoVerdict, name string) algoVerdict {
|
||||
if v, ok := table[name]; ok {
|
||||
return v
|
||||
}
|
||||
return algoVerdict{}
|
||||
}
|
||||
|
||||
// analyseWeakAlgos emits one Issue per weak/broken algorithm in values
|
||||
// using the verdict table.
|
||||
func analyseWeakAlgos(addr, family string, values []string, table map[string]algoVerdict) []Issue {
|
||||
var issues []Issue
|
||||
for _, a := range values {
|
||||
v := verdictFor(table, a)
|
||||
if v.severity == "" || v.severity == SeverityOK {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, Issue{
|
||||
Code: fmt.Sprintf("weak_%s", family),
|
||||
Severity: v.severity,
|
||||
Message: fmt.Sprintf("%s algorithm %q is %s", family, a, v.reason),
|
||||
Fix: fixForFamily(family),
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// analyseStrictKex flags the absence of the Terrapin mitigation marker
|
||||
// (CVE-2023-48795): any modern sshd advertises kex-strict-s-v00@openssh.com
|
||||
// alongside its KEX algorithms when the patched transport is available.
|
||||
func analyseStrictKex(addr string, kex []string) []Issue {
|
||||
if len(kex) == 0 || contains(kex, "kex-strict-s-v00@openssh.com") {
|
||||
return nil
|
||||
}
|
||||
return []Issue{{
|
||||
Code: "missing_strict_kex",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Server does not advertise strict-KEX (CVE-2023-48795 \"Terrapin\"). Upgrade OpenSSH to 9.6 or later.",
|
||||
Fix: "Upgrade sshd; no client-side fix mitigates this server-side gap.",
|
||||
Endpoint: addr,
|
||||
}}
|
||||
}
|
||||
|
||||
// analysePreauthCompression flags servers that offer pre-authentication
|
||||
// zlib compression. Many setups use zlib@openssh.com safely post-auth.
|
||||
func analysePreauthCompression(addr string, comp []string) []Issue {
|
||||
for _, c := range comp {
|
||||
if c == "zlib" {
|
||||
return []Issue{{
|
||||
Code: "preauth_compression",
|
||||
Severity: SeverityInfo,
|
||||
Message: "Server offers pre-authentication zlib compression. Prefer zlib@openssh.com which kicks in only after auth.",
|
||||
Endpoint: addr,
|
||||
}}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// analyseAlgorithms is a convenience used by the HTML report: returns
|
||||
// every algorithm-related issue for a single endpoint.
|
||||
func analyseAlgorithms(addr string, p *SSHProbe) []Issue {
|
||||
var issues []Issue
|
||||
issues = append(issues, analyseWeakAlgos(addr, "kex", p.KEX, kexAlgos)...)
|
||||
issues = append(issues, analyseWeakAlgos(addr, "hostkey_alg", p.HostKey, hostKeyAlgos)...)
|
||||
issues = append(issues, analyseWeakAlgos(addr, "cipher", uniqueMerge(p.CiphersC2S, p.CiphersS2C), cipherAlgos)...)
|
||||
issues = append(issues, analyseWeakAlgos(addr, "mac", uniqueMerge(p.MACsC2S, p.MACsS2C), macAlgos)...)
|
||||
issues = append(issues, analyseStrictKex(addr, p.KEX)...)
|
||||
issues = append(issues, analysePreauthCompression(addr, p.CompC2S)...)
|
||||
return issues
|
||||
}
|
||||
|
||||
// fixForFamily returns a short generic hint to pair with the
|
||||
// per-algorithm warning. The HTML report shows algorithm-specific
|
||||
// verdicts alongside this so operators know what to edit.
|
||||
func fixForFamily(family string) string {
|
||||
switch family {
|
||||
case "kex":
|
||||
return "Edit /etc/ssh/sshd_config KexAlgorithms= to list only modern algorithms (curve25519-sha256, ecdh-sha2-nistp256, diffie-hellman-group16-sha512)."
|
||||
case "hostkey_alg":
|
||||
return "Set HostKeyAlgorithms= to ssh-ed25519,rsa-sha2-512,rsa-sha2-256 (drop ssh-rsa and ssh-dss)."
|
||||
case "cipher":
|
||||
return "Restrict Ciphers= to chacha20-poly1305@openssh.com, aes256-gcm@openssh.com, aes128-gcm@openssh.com and the -ctr variants."
|
||||
case "mac":
|
||||
return "Restrict MACs= to the -etm@openssh.com variants (hmac-sha2-256-etm, hmac-sha2-512-etm, umac-128-etm)."
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// uniqueMerge returns the union of a and b, preserving first-seen order.
|
||||
func uniqueMerge(a, b []string) []string {
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, v := range a {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
for _, v := range b {
|
||||
if !seen[v] {
|
||||
seen[v] = true
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func contains(haystack []string, needle string) bool {
|
||||
for _, v := range haystack {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// analyseAuthMethods flags the classic "password auth exposed to the
|
||||
// internet" antipattern and complementary findings.
|
||||
func analyseAuthMethods(addr string, p *SSHProbe) []Issue {
|
||||
var issues []Issue
|
||||
if p.AuthMethods == nil {
|
||||
return nil
|
||||
}
|
||||
if p.PasswordAuth {
|
||||
issues = append(issues, Issue{
|
||||
Code: "password_auth_enabled",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Server accepts password authentication. Combined with publicly exposed sshd this is the single largest source of compromises.",
|
||||
Fix: "Set PasswordAuthentication no in /etc/ssh/sshd_config and rely on publickey (or keyboard-interactive + hardware MFA).",
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
if !p.PublicKeyAuth && len(p.AuthMethods) > 0 {
|
||||
issues = append(issues, Issue{
|
||||
Code: "no_publickey_auth",
|
||||
Severity: SeverityWarn,
|
||||
Message: "Server does not advertise public-key authentication. This is unusual for production SSH deployments.",
|
||||
Fix: "Set PubkeyAuthentication yes in sshd_config.",
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// lowerAll returns a copy of s with every element lowercased. Used by
|
||||
// callers that want a case-insensitive membership check.
|
||||
func lowerAll(s []string) []string {
|
||||
out := make([]string, len(s))
|
||||
for i, v := range s {
|
||||
out[i] = strings.ToLower(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
218
checker/collect.go
Normal file
218
checker/collect.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
happydns "git.happydns.org/happyDomain/model"
|
||||
"git.happydns.org/happyDomain/services/abstract"
|
||||
)
|
||||
|
||||
// Collect resolves addresses + SSHFP records from the abstract.Server
|
||||
// service attached to this check, probes every (address, port)
|
||||
// combination in parallel, and returns a populated SSHData.
|
||||
func (p *sshProvider) Collect(ctx context.Context, opts sdk.CheckerOptions) (any, error) {
|
||||
server, err := resolveServer(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
timeoutMs := sdk.GetIntOption(opts, OptionProbeTimeoutMs, DefaultProbeTimeoutMs)
|
||||
if timeoutMs <= 0 {
|
||||
timeoutMs = DefaultProbeTimeoutMs
|
||||
}
|
||||
timeout := time.Duration(timeoutMs) * time.Millisecond
|
||||
|
||||
includeAuthProbe := sdk.GetBoolOption(opts, OptionIncludeAuthProbe, true)
|
||||
|
||||
ports := parsePorts(optString(opts, OptionPorts, ""))
|
||||
// Port 22 is always probed.
|
||||
if !containsUint16(ports, DefaultSSHPort) {
|
||||
ports = append([]uint16{DefaultSSHPort}, ports...)
|
||||
}
|
||||
|
||||
host, ips := addressesFromServer(server)
|
||||
if len(ips) == 0 {
|
||||
return nil, fmt.Errorf("abstract.Server service has no A/AAAA records")
|
||||
}
|
||||
|
||||
sshfp := sshfpFromServer(server)
|
||||
|
||||
data := &SSHData{
|
||||
Domain: host,
|
||||
SSHFP: sshfp,
|
||||
CollectedAt: time.Now(),
|
||||
}
|
||||
|
||||
// The fanout is small in practice (at most a handful of IPs × a
|
||||
// handful of ports), but we still cap concurrency for consistency
|
||||
// with the TLS checker.
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
sem := make(chan struct{}, MaxConcurrentProbes)
|
||||
for _, ip := range ips {
|
||||
for _, port := range ports {
|
||||
wg.Add(1)
|
||||
sem <- struct{}{}
|
||||
go func(ip string, port uint16) {
|
||||
defer wg.Done()
|
||||
defer func() { <-sem }()
|
||||
probe := probeEndpoint(ctx, host, ip, port, timeout, includeAuthProbe, sshfp)
|
||||
log.Printf("checker-ssh: %s:%d banner=%q kex=%d hostkeys=%d stage=%s",
|
||||
ip, port, probe.Banner, len(probe.KEX), len(probe.HostKeys), probe.Stage)
|
||||
mu.Lock()
|
||||
data.Endpoints = append(data.Endpoints, probe)
|
||||
mu.Unlock()
|
||||
}(ip, port)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// resolveServer extracts the *abstract.Server payload from the options.
|
||||
// Two shapes are supported, same as the ping checker:
|
||||
// - "service": ServiceMessage (in-process plugin path, or HTTP after
|
||||
// sdk.GetOption JSON-round-trips).
|
||||
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 service's owner domain name (used
|
||||
// for SNI-like purposes in SSH banner/hostname exchange) and the list
|
||||
// of IPs to probe.
|
||||
func addressesFromServer(server *abstract.Server) (host string, ips []string) {
|
||||
// We can't know the service's owner domain from the Server payload
|
||||
// alone. The host value we use here is purely informational for
|
||||
// the report; the ssh handshake itself doesn't need it.
|
||||
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
|
||||
}
|
||||
|
||||
// sshfpFromServer flattens the SSHFP records attached to the service
|
||||
// into our transport-neutral SSHFPSummary.
|
||||
func sshfpFromServer(server *abstract.Server) SSHFPSummary {
|
||||
out := SSHFPSummary{Present: len(server.SSHFP) > 0}
|
||||
for _, rr := range server.SSHFP {
|
||||
if rr == nil {
|
||||
continue
|
||||
}
|
||||
out.Records = append(out.Records, SSHFPRecord{
|
||||
Algorithm: rr.Algorithm,
|
||||
Type: rr.Type,
|
||||
Fingerprint: strings.ToLower(rr.FingerPrint),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Invalid port entries are silently discarded to avoid failing on a bad user input.
|
||||
func parsePorts(raw string) []uint16 {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(raw, ",")
|
||||
var out []uint16
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(p)
|
||||
if err != nil || n <= 0 || n > 65535 {
|
||||
continue
|
||||
}
|
||||
u := uint16(n)
|
||||
if containsUint16(out, u) {
|
||||
continue
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func containsUint16(list []uint16, v uint16) bool {
|
||||
for _, x := range list {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// optString returns a string option, tolerating json.Number / float64
|
||||
// sneaking in for what should have been a bare string.
|
||||
func optString(opts sdk.CheckerOptions, key, def string) string {
|
||||
v, ok := opts[key]
|
||||
if !ok {
|
||||
return def
|
||||
}
|
||||
switch s := v.(type) {
|
||||
case string:
|
||||
return s
|
||||
case fmt.Stringer:
|
||||
return s.String()
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
// Used to make golint happy about unused miekg/dns import if we ever
|
||||
// stop using the abstract.Server.SSHFP path. Currently the import is
|
||||
// effectively required transitively; kept as a guard.
|
||||
var _ = dns.TypeSSHFP
|
||||
|
||||
// Used to make golint happy about unused net import if we ever stop
|
||||
// touching IP parsing here.
|
||||
var _ = net.IPv4len
|
||||
88
checker/definition.go
Normal file
88
checker/definition.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Version is the checker version reported in CheckerDefinition.Version.
|
||||
// Defaults to "built-in"; standalone binaries and plugin builds override
|
||||
// it via -ldflags "-X .../checker.Version=...".
|
||||
var Version = "built-in"
|
||||
|
||||
// Definition returns the CheckerDefinition for the SSH checker.
|
||||
func (p *sshProvider) Definition() *sdk.CheckerDefinition {
|
||||
return &sdk.CheckerDefinition{
|
||||
ID: "ssh",
|
||||
Name: "SSH",
|
||||
Version: Version,
|
||||
Availability: sdk.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.Server"},
|
||||
},
|
||||
ObservationKeys: []sdk.ObservationKey{ObservationKeySSH},
|
||||
Options: sdk.CheckerOptionsDocumentation{
|
||||
UserOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionPorts,
|
||||
Type: "string",
|
||||
Label: "Additional ports",
|
||||
Placeholder: "22, 2222",
|
||||
Description: "Comma-separated list of additional TCP ports to probe. Port 22 is always probed.",
|
||||
Default: "",
|
||||
},
|
||||
{
|
||||
Id: OptionProbeTimeoutMs,
|
||||
Type: "number",
|
||||
Label: "Per-endpoint probe timeout (ms)",
|
||||
Description: "Maximum time allowed for dial + banner + KEXINIT + handshake on a single endpoint.",
|
||||
Default: float64(DefaultProbeTimeoutMs),
|
||||
},
|
||||
{
|
||||
Id: OptionIncludeAuthProbe,
|
||||
Type: "bool",
|
||||
Label: "Enumerate authentication methods",
|
||||
Description: "Perform a second connection with a dummy user to discover which auth methods the server advertises. Harmless but adds a connection attempt per endpoint.",
|
||||
Default: true,
|
||||
},
|
||||
},
|
||||
ServiceOpts: []sdk.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: OptionService,
|
||||
Label: "Service",
|
||||
AutoFill: sdk.AutoFillService,
|
||||
Hide: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
Rules: Rules(),
|
||||
Interval: &sdk.CheckIntervalSpec{
|
||||
Min: 6 * time.Hour,
|
||||
Max: 7 * 24 * time.Hour,
|
||||
Default: 24 * time.Hour,
|
||||
},
|
||||
HasHTMLReport: true,
|
||||
}
|
||||
}
|
||||
246
checker/interactive.go
Normal file
246
checker/interactive.go
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
//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 *sshProvider) RenderForm() []sdk.CheckerOptionField {
|
||||
return []sdk.CheckerOptionField{
|
||||
{
|
||||
Id: "domain",
|
||||
Type: "string",
|
||||
Label: "Host name",
|
||||
Placeholder: "ssh.example.com",
|
||||
Required: true,
|
||||
Description: "The SSH server hostname to probe. A/AAAA and SSHFP records are looked up live.",
|
||||
},
|
||||
{
|
||||
Id: OptionPorts,
|
||||
Type: "string",
|
||||
Label: "Additional ports",
|
||||
Placeholder: "22, 2222",
|
||||
Description: "Comma-separated list of additional TCP ports to probe. Port 22 is always probed.",
|
||||
},
|
||||
{
|
||||
Id: OptionProbeTimeoutMs,
|
||||
Type: "number",
|
||||
Label: "Per-endpoint probe timeout (ms)",
|
||||
Default: float64(DefaultProbeTimeoutMs),
|
||||
},
|
||||
{
|
||||
Id: OptionIncludeAuthProbe,
|
||||
Type: "bool",
|
||||
Label: "Enumerate authentication methods",
|
||||
Default: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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 *sshProvider) 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)
|
||||
}
|
||||
if sshfp, err := lookupSSHFP(resolver, fqdn); err != nil {
|
||||
return nil, fmt.Errorf("SSHFP lookup for %s: %w", domain, err)
|
||||
} else {
|
||||
server.SSHFP = sshfp
|
||||
}
|
||||
|
||||
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 ports := strings.TrimSpace(r.FormValue(OptionPorts)); ports != "" {
|
||||
opts[OptionPorts] = ports
|
||||
}
|
||||
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
|
||||
}
|
||||
opts[OptionIncludeAuthProbe] = parseInteractiveBool(r, OptionIncludeAuthProbe, true)
|
||||
|
||||
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 SSHFP/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
|
||||
}
|
||||
|
||||
// resolvConfPath returns the platform-specific resolver config file, or
|
||||
// "" if none is expected on this OS (e.g. Windows).
|
||||
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
|
||||
}
|
||||
|
||||
func lookupSSHFP(resolver, fqdn string) ([]*dns.SSHFP, error) {
|
||||
in, err := dnsExchange(resolver, fqdn, dns.TypeSSHFP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []*dns.SSHFP
|
||||
for _, rr := range in.Answer {
|
||||
if s, ok := rr.(*dns.SSHFP); ok {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
378
checker/kexinit.go
Normal file
378
checker/kexinit.go
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// The SSH transport protocol is fully specified in RFC 4253. For the
|
||||
// security audit we only need the pre-authentication handshake:
|
||||
//
|
||||
// 1. exchange of protocol version strings (ASCII banners, CRLF-terminated)
|
||||
// 2. exchange of SSH_MSG_KEXINIT packets, which carry the full algorithm
|
||||
// preference lists in one go.
|
||||
//
|
||||
// We deliberately avoid going beyond KEXINIT: a shallow probe is cheap,
|
||||
// works against every RFC-compliant SSH server without depending on any
|
||||
// particular algorithm family being supported, and sidesteps the risk of
|
||||
// running actual KEX math with untrusted peers.
|
||||
|
||||
const (
|
||||
// sshClientBanner is the version string we advertise. ssh-audit and
|
||||
// nmap publish a recognisable banner so operators can tell an audit
|
||||
// probe apart from a real client in their logs.
|
||||
sshClientBanner = "SSH-2.0-happyDomain-checker_1.0"
|
||||
|
||||
msgKexInit = 20
|
||||
|
||||
// maxPacketSize caps the largest packet we will read. RFC 4253 allows
|
||||
// up to 32768 bytes of payload, so 65k is a safe ceiling that also
|
||||
// protects us from a rogue server trying to exhaust memory.
|
||||
maxPacketSize = 65535
|
||||
|
||||
// maxBannerSize limits how much we read before we give up on the
|
||||
// peer's version string. RFC 4253 mandates at most 255 bytes.
|
||||
maxBannerSize = 4096
|
||||
)
|
||||
|
||||
// kexInitPayload is the parsed contents of a KEXINIT packet. The field
|
||||
// names match RFC 4253 §7.1 verbatim to make audits easy.
|
||||
type kexInitPayload struct {
|
||||
Cookie [16]byte
|
||||
KexAlgorithms []string
|
||||
ServerHostKeyAlgorithms []string
|
||||
EncryptionAlgorithmsClientToSvr []string
|
||||
EncryptionAlgorithmsSvrToClient []string
|
||||
MACAlgorithmsClientToSvr []string
|
||||
MACAlgorithmsSvrToClient []string
|
||||
CompressionAlgorithmsClientToSv []string
|
||||
CompressionAlgorithmsSvrToClt []string
|
||||
LanguagesClientToSvr []string
|
||||
LanguagesSvrToClient []string
|
||||
FirstKexPacketFollows bool
|
||||
}
|
||||
|
||||
// readBanner reads the peer's SSH identification string. Servers may
|
||||
// send several CRLF-terminated lines of free-text before the actual
|
||||
// "SSH-2.0-..." line (RFC 4253 §4.2); we skip those and return the first
|
||||
// line that looks like a version exchange.
|
||||
func readBanner(r *bufio.Reader) (string, error) {
|
||||
for i := 0; i < 16; i++ {
|
||||
line, err := readLine(r, maxBannerSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.HasPrefix(line, "SSH-") {
|
||||
return line, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no SSH version string received")
|
||||
}
|
||||
|
||||
// readLine reads a single CRLF-terminated line (or LF-terminated, as
|
||||
// some servers omit the CR) and returns it without the terminator.
|
||||
func readLine(r *bufio.Reader, max int) (string, error) {
|
||||
var buf []byte
|
||||
for {
|
||||
b, err := r.ReadByte()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if b == '\n' {
|
||||
if n := len(buf); n > 0 && buf[n-1] == '\r' {
|
||||
buf = buf[:n-1]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
buf = append(buf, b)
|
||||
if len(buf) > max {
|
||||
return "", fmt.Errorf("line too long")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeBanner sends our client identification string.
|
||||
func writeBanner(w io.Writer) error {
|
||||
_, err := io.WriteString(w, sshClientBanner+"\r\n")
|
||||
return err
|
||||
}
|
||||
|
||||
// readPacket reads a single SSH binary packet (RFC 4253 §6) from r. The
|
||||
// handshake is still in cleartext at this point, so we don't worry
|
||||
// about MAC or cipher state: packet_length + padding_length + payload +
|
||||
// random padding, no MAC.
|
||||
func readPacket(r io.Reader) (payload []byte, err error) {
|
||||
var lenBuf [4]byte
|
||||
if _, err = io.ReadFull(r, lenBuf[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
packetLen := binary.BigEndian.Uint32(lenBuf[:])
|
||||
if packetLen < 5 || packetLen > maxPacketSize {
|
||||
return nil, fmt.Errorf("invalid packet length %d", packetLen)
|
||||
}
|
||||
body := make([]byte, packetLen)
|
||||
if _, err = io.ReadFull(r, body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
padLen := int(body[0])
|
||||
if padLen >= len(body) {
|
||||
return nil, fmt.Errorf("invalid padding length %d vs packet %d", padLen, len(body))
|
||||
}
|
||||
return body[1 : len(body)-padLen], nil
|
||||
}
|
||||
|
||||
// writePacket frames payload into an RFC 4253 binary packet and sends it.
|
||||
func writePacket(w io.Writer, payload []byte) error {
|
||||
// packet_length covers padding_length + payload + random_padding,
|
||||
// but not itself. The total (4 + packet_length) must be a multiple
|
||||
// of 8 (the block size used in unencrypted mode), and padding must
|
||||
// be at least 4 bytes.
|
||||
const blockSize = 8
|
||||
padLen := blockSize - ((5 + len(payload)) % blockSize)
|
||||
if padLen < 4 {
|
||||
padLen += blockSize
|
||||
}
|
||||
packetLen := 1 + len(payload) + padLen
|
||||
|
||||
buf := make([]byte, 4+packetLen)
|
||||
binary.BigEndian.PutUint32(buf[:4], uint32(packetLen))
|
||||
buf[4] = byte(padLen)
|
||||
copy(buf[5:], payload)
|
||||
if _, err := rand.Read(buf[5+len(payload):]); err != nil {
|
||||
return fmt.Errorf("padding rand: %w", err)
|
||||
}
|
||||
_, err := w.Write(buf)
|
||||
return err
|
||||
}
|
||||
|
||||
// buildKexInit crafts a client KEXINIT payload that advertises every
|
||||
// algorithm family the Go SSH stack knows, plus the typical OpenSSH
|
||||
// names we aren't implementing. We are never going to actually perform
|
||||
// key exchange over this connection: the server only needs to accept
|
||||
// our KEXINIT as well-formed and echo its own.
|
||||
func buildKexInit() []byte {
|
||||
var cookie [16]byte
|
||||
_ = mustRand(cookie[:])
|
||||
|
||||
kex := strings.Join([]string{
|
||||
"curve25519-sha256",
|
||||
"curve25519-sha256@libssh.org",
|
||||
"ecdh-sha2-nistp256",
|
||||
"ecdh-sha2-nistp384",
|
||||
"ecdh-sha2-nistp521",
|
||||
"diffie-hellman-group-exchange-sha256",
|
||||
"diffie-hellman-group16-sha512",
|
||||
"diffie-hellman-group14-sha256",
|
||||
"diffie-hellman-group14-sha1",
|
||||
"diffie-hellman-group1-sha1",
|
||||
"sntrup761x25519-sha512@openssh.com",
|
||||
"mlkem768x25519-sha256",
|
||||
}, ",")
|
||||
hostKey := strings.Join([]string{
|
||||
"ssh-ed25519",
|
||||
"ssh-ed25519-cert-v01@openssh.com",
|
||||
"rsa-sha2-512",
|
||||
"rsa-sha2-256",
|
||||
"ssh-rsa",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"ssh-dss",
|
||||
}, ",")
|
||||
ciphers := strings.Join([]string{
|
||||
"chacha20-poly1305@openssh.com",
|
||||
"aes256-gcm@openssh.com",
|
||||
"aes128-gcm@openssh.com",
|
||||
"aes256-ctr",
|
||||
"aes192-ctr",
|
||||
"aes128-ctr",
|
||||
"aes256-cbc",
|
||||
"aes128-cbc",
|
||||
"3des-cbc",
|
||||
}, ",")
|
||||
macs := strings.Join([]string{
|
||||
"hmac-sha2-512-etm@openssh.com",
|
||||
"hmac-sha2-256-etm@openssh.com",
|
||||
"umac-128-etm@openssh.com",
|
||||
"hmac-sha2-512",
|
||||
"hmac-sha2-256",
|
||||
"hmac-sha1",
|
||||
"hmac-sha1-96",
|
||||
"hmac-md5",
|
||||
}, ",")
|
||||
comp := "none,zlib@openssh.com,zlib"
|
||||
|
||||
w := newPayloadWriter()
|
||||
w.writeByte(msgKexInit)
|
||||
w.writeBytes(cookie[:])
|
||||
w.writeString(kex)
|
||||
w.writeString(hostKey)
|
||||
w.writeString(ciphers)
|
||||
w.writeString(ciphers)
|
||||
w.writeString(macs)
|
||||
w.writeString(macs)
|
||||
w.writeString(comp)
|
||||
w.writeString(comp)
|
||||
w.writeString("") // languages c2s
|
||||
w.writeString("") // languages s2c
|
||||
w.writeByte(0) // first_kex_packet_follows
|
||||
w.writeUint32(0) // reserved
|
||||
return w.bytes()
|
||||
}
|
||||
|
||||
func mustRand(b []byte) error {
|
||||
_, err := rand.Read(b)
|
||||
return err
|
||||
}
|
||||
|
||||
// parseKexInit parses a server KEXINIT payload. Validation is minimal:
|
||||
// we do not reject over-long algorithm lists, we just trim them at the
|
||||
// RFC ceiling so a hostile server can't make us allocate unbounded
|
||||
// amounts of memory.
|
||||
func parseKexInit(payload []byte) (*kexInitPayload, error) {
|
||||
if len(payload) < 1 || payload[0] != msgKexInit {
|
||||
return nil, fmt.Errorf("not a KEXINIT packet (first byte = %d)", func() byte {
|
||||
if len(payload) == 0 {
|
||||
return 0
|
||||
}
|
||||
return payload[0]
|
||||
}())
|
||||
}
|
||||
r := newPayloadReader(payload[1:])
|
||||
out := &kexInitPayload{}
|
||||
if err := r.readBytes(out.Cookie[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
if out.KexAlgorithms, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.ServerHostKeyAlgorithms, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.EncryptionAlgorithmsClientToSvr, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.EncryptionAlgorithmsSvrToClient, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.MACAlgorithmsClientToSvr, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.MACAlgorithmsSvrToClient, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.CompressionAlgorithmsClientToSv, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.CompressionAlgorithmsSvrToClt, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.LanguagesClientToSvr, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if out.LanguagesSvrToClient, err = r.readNameList(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b, err := r.readByte()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.FirstKexPacketFollows = b != 0
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// payloadWriter/Reader are tiny helpers for SSH wire encoding. We only
|
||||
// ever use uint32-prefixed strings and comma-separated name-lists.
|
||||
|
||||
type payloadWriter struct{ buf []byte }
|
||||
|
||||
func newPayloadWriter() *payloadWriter { return &payloadWriter{} }
|
||||
func (w *payloadWriter) bytes() []byte { return w.buf }
|
||||
|
||||
func (w *payloadWriter) writeByte(b byte) { w.buf = append(w.buf, b) }
|
||||
func (w *payloadWriter) writeBytes(b []byte) { w.buf = append(w.buf, b...) }
|
||||
func (w *payloadWriter) writeUint32(v uint32) {
|
||||
var b [4]byte
|
||||
binary.BigEndian.PutUint32(b[:], v)
|
||||
w.buf = append(w.buf, b[:]...)
|
||||
}
|
||||
func (w *payloadWriter) writeString(s string) {
|
||||
w.writeUint32(uint32(len(s)))
|
||||
w.buf = append(w.buf, s...)
|
||||
}
|
||||
|
||||
type payloadReader struct {
|
||||
buf []byte
|
||||
pos int
|
||||
}
|
||||
|
||||
func newPayloadReader(b []byte) *payloadReader { return &payloadReader{buf: b} }
|
||||
|
||||
func (r *payloadReader) readByte() (byte, error) {
|
||||
if r.pos >= len(r.buf) {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
b := r.buf[r.pos]
|
||||
r.pos++
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func (r *payloadReader) readBytes(dst []byte) error {
|
||||
if r.pos+len(dst) > len(r.buf) {
|
||||
return io.ErrUnexpectedEOF
|
||||
}
|
||||
copy(dst, r.buf[r.pos:r.pos+len(dst)])
|
||||
r.pos += len(dst)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *payloadReader) readUint32() (uint32, error) {
|
||||
if r.pos+4 > len(r.buf) {
|
||||
return 0, io.ErrUnexpectedEOF
|
||||
}
|
||||
v := binary.BigEndian.Uint32(r.buf[r.pos : r.pos+4])
|
||||
r.pos += 4
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (r *payloadReader) readNameList() ([]string, error) {
|
||||
n, err := r.readUint32()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int(n) > len(r.buf)-r.pos {
|
||||
return nil, io.ErrUnexpectedEOF
|
||||
}
|
||||
s := string(r.buf[r.pos : r.pos+int(n)])
|
||||
r.pos += int(n)
|
||||
if s == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return strings.Split(s, ","), nil
|
||||
}
|
||||
395
checker/prober.go
Normal file
395
checker/prober.go
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// probeEndpoint runs the full probe flow on a single (host, ip, port)
|
||||
// triple. It never returns a Go error: every failure mode is recorded
|
||||
// as a raw field on SSHProbe (Stage + Error). Severity / pass/fail
|
||||
// classification is performed later by CheckRule.Evaluate, never here.
|
||||
func probeEndpoint(ctx context.Context, host, ip string, port uint16, timeout time.Duration, includeAuthProbe bool, sshfp SSHFPSummary) SSHProbe {
|
||||
start := time.Now()
|
||||
addr := net.JoinHostPort(ip, strconv.Itoa(int(port)))
|
||||
p := SSHProbe{
|
||||
Host: host,
|
||||
Port: port,
|
||||
Address: addr,
|
||||
IP: ip,
|
||||
IsIPv6: strings.Contains(ip, ":"),
|
||||
Stage: "dial",
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
d := &net.Dialer{}
|
||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
p.Error = "dial: " + err.Error()
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
p.TCPConnected = true
|
||||
p.Stage = "banner"
|
||||
if deadline, ok := dialCtx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
// Phase 1: protocol banner exchange.
|
||||
br := bufio.NewReader(conn)
|
||||
banner, err := readBanner(br)
|
||||
if err != nil {
|
||||
p.Error = "banner: " + err.Error()
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
p.Banner = banner
|
||||
p.ProtoVer, p.SoftVer, p.Vendor = parseBanner(banner)
|
||||
p.Stage = "banner_write"
|
||||
|
||||
if err := writeBanner(conn); err != nil {
|
||||
p.Error = "write-banner: " + err.Error()
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
||||
// Phase 2: exchange KEXINIT.
|
||||
p.Stage = "kexinit_read"
|
||||
srvPayload, err := readPacket(br)
|
||||
if err != nil {
|
||||
p.Error = "kexinit-read: " + err.Error()
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
p.Stage = "kexinit_parse"
|
||||
srvKex, err := parseKexInit(srvPayload)
|
||||
if err != nil {
|
||||
p.Error = "kexinit-parse: " + err.Error()
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
||||
p.KEX = srvKex.KexAlgorithms
|
||||
p.HostKey = srvKex.ServerHostKeyAlgorithms
|
||||
p.CiphersC2S = srvKex.EncryptionAlgorithmsClientToSvr
|
||||
p.CiphersS2C = srvKex.EncryptionAlgorithmsSvrToClient
|
||||
p.MACsC2S = srvKex.MACAlgorithmsClientToSvr
|
||||
p.MACsS2C = srvKex.MACAlgorithmsSvrToClient
|
||||
p.CompC2S = srvKex.CompressionAlgorithmsClientToSv
|
||||
p.CompS2C = srvKex.CompressionAlgorithmsSvrToClt
|
||||
p.Stage = "kexinit_ok"
|
||||
|
||||
// We intentionally don't proceed with KEX here: algorithm posture is
|
||||
// already captured. Closing now is friendlier than triggering a full
|
||||
// exchange that might never terminate.
|
||||
_ = conn.Close()
|
||||
|
||||
// We hand off to Go's ssh package for the full handshake. HostKeyCallback lets us
|
||||
// capture each presented key without reimplementing DH/curve25519/kyber ourselves.
|
||||
p.HostKeys = probeHostKeys(ctx, addr, host, srvKex.ServerHostKeyAlgorithms, timeout)
|
||||
for i := range p.HostKeys {
|
||||
p.HostKeys[i].applySSHFP(sshfp)
|
||||
}
|
||||
if len(p.HostKeys) > 0 {
|
||||
p.Stage = "handshake_ok"
|
||||
}
|
||||
|
||||
if includeAuthProbe {
|
||||
p.AuthProbeAttempted = true
|
||||
methods, err := probeAuthMethods(ctx, addr, timeout)
|
||||
if err == nil {
|
||||
p.AuthMethods = methods
|
||||
for _, m := range methods {
|
||||
switch m {
|
||||
case "password":
|
||||
p.PasswordAuth = true
|
||||
case "keyboard-interactive":
|
||||
p.KeyboardInteractive = true
|
||||
case "publickey":
|
||||
p.PublicKeyAuth = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
p.ElapsedMS = time.Since(start).Milliseconds()
|
||||
return p
|
||||
}
|
||||
|
||||
// Most deployments expose at most two or three key families (ed25519, rsa, ecdsa),
|
||||
// so connecting once per family stays cheap.
|
||||
func probeHostKeys(ctx context.Context, addr, host string, algos []string, timeout time.Duration) []HostKeyInfo {
|
||||
wantFamilies := pickHostKeyFamilies(algos)
|
||||
|
||||
seen := map[string]bool{} // by sha256 hex, dedupe across families
|
||||
var out []HostKeyInfo
|
||||
|
||||
for _, algo := range wantFamilies {
|
||||
key, err := fetchHostKey(ctx, addr, host, algo, timeout)
|
||||
if err != nil || key == nil {
|
||||
continue
|
||||
}
|
||||
info := describeHostKey(key)
|
||||
if seen[info.SHA256] {
|
||||
continue
|
||||
}
|
||||
seen[info.SHA256] = true
|
||||
out = append(out, info)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// rsa-sha2-512 and rsa-sha2-256 both return the same RSA key, so we collapse by family.
|
||||
func pickHostKeyFamilies(algos []string) []string {
|
||||
var out []string
|
||||
families := map[string]bool{}
|
||||
add := func(family, algo string) {
|
||||
if families[family] {
|
||||
return
|
||||
}
|
||||
families[family] = true
|
||||
out = append(out, algo)
|
||||
}
|
||||
for _, a := range algos {
|
||||
switch a {
|
||||
case "ssh-ed25519", "ssh-ed25519-cert-v01@openssh.com":
|
||||
add("ed25519", "ssh-ed25519")
|
||||
case "rsa-sha2-512", "rsa-sha2-256", "ssh-rsa":
|
||||
add("rsa", a)
|
||||
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
|
||||
add("ecdsa", a)
|
||||
case "ssh-dss":
|
||||
add("dsa", "ssh-dss")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Offering no auth methods aborts the handshake at the auth step, which is enough
|
||||
// to capture the host key without completing a full session.
|
||||
func fetchHostKey(ctx context.Context, addr, host, algo string, timeout time.Duration) (ssh.PublicKey, error) {
|
||||
var captured ssh.PublicKey
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: "happydomain-checker",
|
||||
Auth: nil,
|
||||
HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error {
|
||||
captured = key
|
||||
return nil
|
||||
},
|
||||
HostKeyAlgorithms: []string{algo},
|
||||
Timeout: timeout,
|
||||
ClientVersion: sshClientBanner,
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
d := &net.Dialer{}
|
||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if deadline, ok := dialCtx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
_, _, _, err = ssh.NewClientConn(conn, host, cfg)
|
||||
if err != nil && captured == nil {
|
||||
return nil, err
|
||||
}
|
||||
return captured, nil
|
||||
}
|
||||
|
||||
// probeAuthMethods opens a fresh connection, completes the KEX, and
|
||||
// then sends a "none" authentication request (RFC 4252 §5.2). The
|
||||
// server's failure response carries the list of methods it would
|
||||
// actually accept: exactly what we need.
|
||||
func probeAuthMethods(ctx context.Context, addr string, timeout time.Duration) ([]string, error) {
|
||||
cfg := &ssh.ClientConfig{
|
||||
User: "happydomain-checker",
|
||||
Auth: []ssh.AuthMethod{}, // forces a "none" attempt
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||
Timeout: timeout,
|
||||
ClientVersion: sshClientBanner,
|
||||
}
|
||||
|
||||
dialCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
d := &net.Dialer{}
|
||||
conn, err := d.DialContext(dialCtx, "tcp", addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if deadline, ok := dialCtx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(deadline)
|
||||
}
|
||||
|
||||
_, _, _, err = ssh.NewClientConn(conn, addr, cfg)
|
||||
if err == nil {
|
||||
// A server that lets us through "none" is unusual but possible
|
||||
// (anonymous SSH for git-serve-style deployments); report that
|
||||
// upstream by returning an empty list.
|
||||
return nil, nil
|
||||
}
|
||||
return extractMethodsFromAuthError(err), nil
|
||||
}
|
||||
|
||||
// x/crypto/ssh does not expose offered auth methods via a typed accessor; string
|
||||
// parsing is the officially documented path.
|
||||
func extractMethodsFromAuthError(err error) []string {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
msg := err.Error()
|
||||
start := strings.Index(msg, "attempted methods [")
|
||||
if start < 0 {
|
||||
return nil
|
||||
}
|
||||
start += len("attempted methods [")
|
||||
end := strings.Index(msg[start:], "]")
|
||||
if end < 0 {
|
||||
return nil
|
||||
}
|
||||
raw := strings.Fields(msg[start : start+end])
|
||||
var out []string
|
||||
for _, m := range raw {
|
||||
if m == "none" {
|
||||
continue
|
||||
}
|
||||
out = append(out, m)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func describeHostKey(key ssh.PublicKey) HostKeyInfo {
|
||||
marshaled := key.Marshal()
|
||||
sha2 := sha256.Sum256(marshaled)
|
||||
sha1sum := sha1.Sum(marshaled)
|
||||
info := HostKeyInfo{
|
||||
Type: key.Type(),
|
||||
SHA256: hex.EncodeToString(sha2[:]),
|
||||
SHA1: hex.EncodeToString(sha1sum[:]),
|
||||
}
|
||||
info.SSHFPAlgo = sshfpAlgoForKeyType(info.Type)
|
||||
info.Bits = keyBits(key)
|
||||
return info
|
||||
}
|
||||
|
||||
// keyBits returns a key-family-specific size estimate. It is advisory:
|
||||
// we only use it in the report, and a server that ships an RSA key
|
||||
// smaller than 2048 bits is the sort of red flag we want to show.
|
||||
func keyBits(key ssh.PublicKey) int {
|
||||
switch k := key.(type) {
|
||||
case ssh.CryptoPublicKey:
|
||||
type bitSizer interface{ Size() int }
|
||||
switch p := k.CryptoPublicKey().(type) {
|
||||
case bitSizer:
|
||||
return p.Size() * 8
|
||||
default:
|
||||
_ = p
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// sshfpAlgoForKeyType maps an SSH host-key type string to the SSHFP
|
||||
// algorithm number defined in RFC 4255 / RFC 6594 / RFC 7479.
|
||||
func sshfpAlgoForKeyType(t string) uint8 {
|
||||
switch t {
|
||||
case "ssh-rsa", "rsa-sha2-256", "rsa-sha2-512":
|
||||
return 1
|
||||
case "ssh-dss":
|
||||
return 2
|
||||
case "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521":
|
||||
return 3
|
||||
case "ssh-ed25519":
|
||||
return 4
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// parseBanner splits an "SSH-2.0-OpenSSH_9.3p1 Debian-1" banner into
|
||||
// (protocolVersion, softwareVersion, vendorComment). The grammar is
|
||||
// RFC 4253 §4.2: "SSH-<protoversion>-<softwareversion> <comments>".
|
||||
func parseBanner(b string) (proto, soft, vendor string) {
|
||||
// SSH- prefix is guaranteed by readBanner.
|
||||
rest := strings.TrimPrefix(b, "SSH-")
|
||||
dash := strings.IndexByte(rest, '-')
|
||||
if dash < 0 {
|
||||
return rest, "", ""
|
||||
}
|
||||
proto = rest[:dash]
|
||||
rest = rest[dash+1:]
|
||||
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
|
||||
soft = rest[:sp]
|
||||
vendor = strings.TrimSpace(rest[sp+1:])
|
||||
} else {
|
||||
soft = rest
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// applySSHFP fills in the SSHFPMatchSHA* flags based on the declared
|
||||
// SSHFP records for this key's algorithm family. These are raw
|
||||
// observations (the record matched this key fingerprint); any
|
||||
// severity verdict about coverage lives in the SSHFP rule.
|
||||
func (h *HostKeyInfo) applySSHFP(s SSHFPSummary) {
|
||||
for _, rr := range s.Records {
|
||||
if rr.Algorithm != h.SSHFPAlgo {
|
||||
continue
|
||||
}
|
||||
want := strings.ToLower(rr.Fingerprint)
|
||||
switch rr.Type {
|
||||
case 1:
|
||||
if want == h.SHA1 {
|
||||
h.SSHFPMatchSHA1 = true
|
||||
}
|
||||
case 2:
|
||||
if want == h.SHA256 {
|
||||
h.SSHFPMatchSHA256 = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// errNoHostKey is returned by fetchHostKey when the callback never
|
||||
// fired (e.g. transport-level error before the host key was received).
|
||||
// Currently only used internally for readability.
|
||||
var errNoHostKey = errors.New("no host key observed")
|
||||
49
checker/provider.go
Normal file
49
checker/provider.go
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Provider returns a new SSH observation provider.
|
||||
func Provider() sdk.ObservationProvider {
|
||||
return &sshProvider{}
|
||||
}
|
||||
|
||||
type sshProvider struct{}
|
||||
|
||||
func (p *sshProvider) Key() sdk.ObservationKey {
|
||||
return ObservationKeySSH
|
||||
}
|
||||
|
||||
// GetHTMLReport implements sdk.CheckerHTMLReporter.
|
||||
func (p *sshProvider) GetHTMLReport(rctx sdk.ReportContext) (string, error) {
|
||||
var d SSHData
|
||||
if err := json.Unmarshal(rctx.Data(), &d); err != nil {
|
||||
return "", fmt.Errorf("unmarshal ssh observation: %w", err)
|
||||
}
|
||||
return renderReport(&d, rctx)
|
||||
}
|
||||
679
checker/report.go
Normal file
679
checker/report.go
Normal file
|
|
@ -0,0 +1,679 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// renderReport builds the HTML iframe contents displayed in the
|
||||
// happyDomain UI. The layout is deliberately close to the XMPP/TLS
|
||||
// reports so operators get a consistent experience across checkers.
|
||||
//
|
||||
// Structure:
|
||||
// 1. Header with overall status badge + SSHFP verdict chips.
|
||||
// 2. "What to fix" list (the most common / highest-severity issues
|
||||
// with inline copy-pasteable sshd_config or DNS snippets).
|
||||
// 3. Per-endpoint details (banner, host keys, algorithm tables,
|
||||
// auth methods).
|
||||
//
|
||||
// We render the algorithm tables with per-row severity classes so the
|
||||
// weak/broken entries light up visually.
|
||||
func renderReport(d *SSHData, rctx sdk.ReportContext) (string, error) {
|
||||
var states []sdk.CheckState
|
||||
if rctx != nil {
|
||||
states = rctx.States()
|
||||
}
|
||||
view := buildReportData(d, states)
|
||||
var buf strings.Builder
|
||||
if err := reportTpl.Execute(&buf, view); err != nil {
|
||||
return "", fmt.Errorf("render ssh report: %w", err)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
type reportView struct {
|
||||
Domain string
|
||||
RunAt string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
HasIssues bool
|
||||
TopFixes []reportFix
|
||||
SSHFPPresent bool
|
||||
SSHFPMatched bool
|
||||
SSHFPRecords []reportSSHFPRecord
|
||||
Endpoints []reportEndpoint
|
||||
HasAuthProbe bool
|
||||
AnyIPv4, AnyIPv6 bool
|
||||
}
|
||||
|
||||
type reportFix struct {
|
||||
Severity string
|
||||
Code string
|
||||
Message string
|
||||
Fix string
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
type reportSSHFPRecord struct {
|
||||
Algorithm uint8
|
||||
AlgoName string
|
||||
Type uint8
|
||||
TypeName string
|
||||
Fingerprint string
|
||||
Matched bool
|
||||
}
|
||||
|
||||
type reportEndpoint struct {
|
||||
Address string
|
||||
Host string
|
||||
Port uint16
|
||||
IsIPv6 bool
|
||||
TCPConnected bool
|
||||
Banner string
|
||||
SoftwareVer string
|
||||
Vendor string
|
||||
ElapsedMS int64
|
||||
Error string
|
||||
StatusLabel string
|
||||
StatusClass string
|
||||
AnyFail bool
|
||||
|
||||
HostKeys []reportHostKey
|
||||
AlgoTables []reportAlgoTable
|
||||
AuthMethods []reportAuthMethod
|
||||
|
||||
Issues []reportFix
|
||||
}
|
||||
|
||||
type reportHostKey struct {
|
||||
Type string
|
||||
Bits int
|
||||
SHA256 string
|
||||
SHA1 string
|
||||
SSHFPMatched bool
|
||||
SSHFPFamily string
|
||||
SSHFPSnippet string
|
||||
}
|
||||
|
||||
type reportAlgoTable struct {
|
||||
Title string
|
||||
Rows []reportAlgoRow
|
||||
}
|
||||
|
||||
type reportAlgoRow struct {
|
||||
Name string
|
||||
Severity string // "", "warn", "crit", "info"
|
||||
Note string
|
||||
}
|
||||
|
||||
type reportAuthMethod struct {
|
||||
Name string
|
||||
Severity string // "ok", "warn"
|
||||
Note string
|
||||
}
|
||||
|
||||
func buildReportData(d *SSHData, states []sdk.CheckState) reportView {
|
||||
v := reportView{
|
||||
Domain: d.Domain,
|
||||
RunAt: d.CollectedAt.Format("2006-01-02 15:04 MST"),
|
||||
SSHFPPresent: d.SSHFP.Present,
|
||||
}
|
||||
|
||||
// Deduplicate: the same weak cipher reported by two endpoints merges into one row.
|
||||
// When no states are available, fall back to data-only rendering with no hints.
|
||||
type fix struct {
|
||||
severity string
|
||||
code string
|
||||
message string
|
||||
fixText string
|
||||
endpoint string
|
||||
}
|
||||
stateFix := func(s sdk.CheckState) (fix, bool) {
|
||||
sev := statusToSeverity(s.Status)
|
||||
if sev == "" {
|
||||
return fix{}, false
|
||||
}
|
||||
var fixText string
|
||||
if s.Meta != nil {
|
||||
if raw, ok := s.Meta["fix"]; ok {
|
||||
if str, ok := raw.(string); ok {
|
||||
fixText = str
|
||||
}
|
||||
}
|
||||
}
|
||||
return fix{
|
||||
severity: sev,
|
||||
code: s.Code,
|
||||
message: s.Message,
|
||||
fixText: fixText,
|
||||
endpoint: s.Subject,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Per-endpoint grouping by Subject (endpoint Address).
|
||||
perEp := map[string][]fix{}
|
||||
var allFixes []fix
|
||||
seen := map[string]bool{}
|
||||
for _, s := range states {
|
||||
f, ok := stateFix(s)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if f.endpoint != "" {
|
||||
perEp[f.endpoint] = append(perEp[f.endpoint], f)
|
||||
}
|
||||
key := f.code + "|" + f.message
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
allFixes = append(allFixes, f)
|
||||
}
|
||||
|
||||
sort.SliceStable(allFixes, func(i, j int) bool {
|
||||
return sevRank(allFixes[i].severity) < sevRank(allFixes[j].severity)
|
||||
})
|
||||
for _, f := range allFixes {
|
||||
if f.severity == SeverityInfo && !strings.Contains(f.code, "sshfp") {
|
||||
continue // informational clutter, keep in per-endpoint only
|
||||
}
|
||||
v.TopFixes = append(v.TopFixes, reportFix{
|
||||
Severity: f.severity,
|
||||
Code: f.code,
|
||||
Message: f.message,
|
||||
Fix: f.fixText,
|
||||
Endpoint: f.endpoint,
|
||||
})
|
||||
}
|
||||
v.HasIssues = len(v.TopFixes) > 0
|
||||
|
||||
worst := SeverityOK
|
||||
for _, f := range allFixes {
|
||||
if f.severity == SeverityCrit {
|
||||
worst = SeverityCrit
|
||||
break
|
||||
}
|
||||
if f.severity == SeverityWarn && worst != SeverityCrit {
|
||||
worst = SeverityWarn
|
||||
}
|
||||
}
|
||||
switch worst {
|
||||
case SeverityCrit:
|
||||
v.StatusLabel = "FAIL"
|
||||
v.StatusClass = "fail"
|
||||
case SeverityWarn:
|
||||
v.StatusLabel = "WARN"
|
||||
v.StatusClass = "warn"
|
||||
default:
|
||||
v.StatusLabel = "OK"
|
||||
v.StatusClass = "ok"
|
||||
}
|
||||
|
||||
// SSHFP records table.
|
||||
for _, rr := range d.SSHFP.Records {
|
||||
matched := false
|
||||
for _, ep := range d.Endpoints {
|
||||
for _, k := range ep.HostKeys {
|
||||
if k.SSHFPAlgo == rr.Algorithm {
|
||||
if rr.Type == 2 && strings.EqualFold(rr.Fingerprint, k.SHA256) {
|
||||
matched = true
|
||||
}
|
||||
if rr.Type == 1 && strings.EqualFold(rr.Fingerprint, k.SHA1) {
|
||||
matched = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
v.SSHFPMatched = true
|
||||
}
|
||||
v.SSHFPRecords = append(v.SSHFPRecords, reportSSHFPRecord{
|
||||
Algorithm: rr.Algorithm,
|
||||
AlgoName: sshfpAlgoName(rr.Algorithm),
|
||||
Type: rr.Type,
|
||||
TypeName: sshfpHashName(rr.Type),
|
||||
Fingerprint: rr.Fingerprint,
|
||||
Matched: matched,
|
||||
})
|
||||
}
|
||||
|
||||
for _, ep := range d.Endpoints {
|
||||
re := reportEndpoint{
|
||||
Address: ep.Address,
|
||||
Host: ep.Host,
|
||||
Port: ep.Port,
|
||||
IsIPv6: ep.IsIPv6,
|
||||
TCPConnected: ep.TCPConnected,
|
||||
Banner: ep.Banner,
|
||||
SoftwareVer: ep.SoftVer,
|
||||
Vendor: ep.Vendor,
|
||||
ElapsedMS: ep.ElapsedMS,
|
||||
Error: ep.Error,
|
||||
}
|
||||
if ep.IsIPv6 {
|
||||
v.AnyIPv6 = true
|
||||
} else {
|
||||
v.AnyIPv4 = true
|
||||
}
|
||||
|
||||
perEpIssues := perEp[ep.Address]
|
||||
// Per-endpoint status label.
|
||||
epWorst := SeverityOK
|
||||
for _, f := range perEpIssues {
|
||||
if f.severity == SeverityCrit {
|
||||
epWorst = SeverityCrit
|
||||
break
|
||||
}
|
||||
if f.severity == SeverityWarn && epWorst != SeverityCrit {
|
||||
epWorst = SeverityWarn
|
||||
}
|
||||
}
|
||||
switch epWorst {
|
||||
case SeverityCrit:
|
||||
re.StatusLabel = "FAIL"
|
||||
re.StatusClass = "fail"
|
||||
re.AnyFail = true
|
||||
case SeverityWarn:
|
||||
re.StatusLabel = "WARN"
|
||||
re.StatusClass = "warn"
|
||||
default:
|
||||
re.StatusLabel = "OK"
|
||||
re.StatusClass = "ok"
|
||||
}
|
||||
|
||||
for _, k := range ep.HostKeys {
|
||||
rh := reportHostKey{
|
||||
Type: k.Type,
|
||||
Bits: k.Bits,
|
||||
SHA256: k.SHA256,
|
||||
SHA1: k.SHA1,
|
||||
}
|
||||
rh.SSHFPMatched = k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1
|
||||
rh.SSHFPFamily = sshfpAlgoName(k.SSHFPAlgo)
|
||||
rh.SSHFPSnippet = fmt.Sprintf("%d 2 %s", k.SSHFPAlgo, k.SHA256)
|
||||
re.HostKeys = append(re.HostKeys, rh)
|
||||
}
|
||||
|
||||
re.AlgoTables = []reportAlgoTable{
|
||||
{Title: "Key exchange (KEX)", Rows: algoRows(ep.KEX, kexAlgos)},
|
||||
{Title: "Server host keys", Rows: algoRows(ep.HostKey, hostKeyAlgos)},
|
||||
{Title: "Ciphers", Rows: algoRows(uniqueMerge(ep.CiphersC2S, ep.CiphersS2C), cipherAlgos)},
|
||||
{Title: "MACs", Rows: algoRows(uniqueMerge(ep.MACsC2S, ep.MACsS2C), macAlgos)},
|
||||
}
|
||||
|
||||
if ep.AuthMethods != nil || ep.PasswordAuth || ep.PublicKeyAuth || ep.KeyboardInteractive {
|
||||
v.HasAuthProbe = true
|
||||
for _, m := range ep.AuthMethods {
|
||||
sev := SeverityOK
|
||||
note := ""
|
||||
switch m {
|
||||
case "password":
|
||||
sev = SeverityWarn
|
||||
note = "password auth over the internet is the #1 brute-force target"
|
||||
case "keyboard-interactive":
|
||||
note = "often used for 2FA, otherwise equivalent to password"
|
||||
case "publickey":
|
||||
note = "preferred method"
|
||||
}
|
||||
re.AuthMethods = append(re.AuthMethods, reportAuthMethod{
|
||||
Name: m,
|
||||
Severity: sev,
|
||||
Note: note,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
for _, f := range perEpIssues {
|
||||
re.Issues = append(re.Issues, reportFix{
|
||||
Severity: f.severity,
|
||||
Code: f.code,
|
||||
Message: f.message,
|
||||
Fix: f.fixText,
|
||||
})
|
||||
}
|
||||
|
||||
v.Endpoints = append(v.Endpoints, re)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// Non-finding statuses return "" so callers skip them in fix listings.
|
||||
func statusToSeverity(s sdk.Status) string {
|
||||
switch s {
|
||||
case sdk.StatusCrit:
|
||||
return SeverityCrit
|
||||
case sdk.StatusWarn:
|
||||
return SeverityWarn
|
||||
case sdk.StatusInfo:
|
||||
return SeverityInfo
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func algoRows(list []string, table map[string]algoVerdict) []reportAlgoRow {
|
||||
out := make([]reportAlgoRow, 0, len(list))
|
||||
for _, name := range list {
|
||||
v := verdictFor(table, name)
|
||||
out = append(out, reportAlgoRow{
|
||||
Name: name,
|
||||
Severity: v.severity,
|
||||
Note: v.reason,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func sevRank(s string) int {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return 0
|
||||
case SeverityWarn:
|
||||
return 1
|
||||
case SeverityInfo:
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
|
||||
func sshfpAlgoName(a uint8) string {
|
||||
switch a {
|
||||
case 1:
|
||||
return "RSA"
|
||||
case 2:
|
||||
return "DSA"
|
||||
case 3:
|
||||
return "ECDSA"
|
||||
case 4:
|
||||
return "Ed25519"
|
||||
case 6:
|
||||
return "Ed448"
|
||||
}
|
||||
return fmt.Sprintf("algo %d", a)
|
||||
}
|
||||
|
||||
func sshfpHashName(t uint8) string {
|
||||
switch t {
|
||||
case 1:
|
||||
return "SHA-1"
|
||||
case 2:
|
||||
return "SHA-256"
|
||||
}
|
||||
return fmt.Sprintf("hash %d", t)
|
||||
}
|
||||
|
||||
var reportTpl = template.Must(template.New("ssh").Funcs(template.FuncMap{
|
||||
"sevClass": func(s string) string {
|
||||
switch s {
|
||||
case SeverityCrit:
|
||||
return "fail"
|
||||
case SeverityWarn:
|
||||
return "warn"
|
||||
case SeverityInfo:
|
||||
return "muted"
|
||||
case SeverityOK:
|
||||
return "ok"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
}).Parse(`<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SSH Report: {{.Domain}}</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
:root {
|
||||
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: #1f2937;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
body { margin: 0; padding: 1rem; }
|
||||
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||
pre { font-family: ui-monospace, monospace; font-size: .78rem; background: #111827; color: #e5e7eb; padding: .6rem .8rem; border-radius: 6px; overflow-x: auto; margin: .35rem 0 0; }
|
||||
h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||
h3 { font-size: .9rem; font-weight: 600; margin: .5rem 0 .3rem; }
|
||||
|
||||
.hd, .section, details {
|
||||
background: #fff;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
}
|
||||
.hd { border-radius: 10px; padding: 1rem 1.25rem; margin-bottom: .75rem; }
|
||||
.section { border-radius: 8px; padding: .85rem 1rem; margin-bottom: .6rem; }
|
||||
details { border-radius: 8px; margin-bottom: .45rem; overflow: hidden; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex; align-items: center;
|
||||
padding: .2em .65em; border-radius: 9999px;
|
||||
font-size: .78rem; font-weight: 700; letter-spacing: .02em;
|
||||
}
|
||||
.ok { background: #d1fae5; color: #065f46; }
|
||||
.warn { background: #fef3c7; color: #92400e; }
|
||||
.fail { background: #fee2e2; color: #991b1b; }
|
||||
.muted { background: #e5e7eb; color: #374151; }
|
||||
|
||||
.meta { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||
|
||||
summary {
|
||||
display: flex; align-items: center; gap: .5rem;
|
||||
padding: .65rem 1rem; cursor: pointer; user-select: none; list-style: none;
|
||||
}
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary::before { content: "▶"; font-size: .65rem; color: #9ca3af; transition: transform .15s; flex-shrink: 0; }
|
||||
details[open] > summary::before { transform: rotate(90deg); }
|
||||
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||
|
||||
table { border-collapse: collapse; width: 100%; font-size: .85rem; margin-top: .25rem; }
|
||||
th, td { text-align: left; padding: .25rem .5rem; border-bottom: 1px solid #f3f4f6; vertical-align: top; }
|
||||
th { font-weight: 600; color: #6b7280; }
|
||||
tr.crit td:first-child { border-left: 3px solid #dc2626; }
|
||||
tr.warn td:first-child { border-left: 3px solid #f59e0b; }
|
||||
tr.info td:first-child { border-left: 3px solid #3b82f6; }
|
||||
|
||||
.fix {
|
||||
border-left: 3px solid #dc2626;
|
||||
padding: .5rem .75rem; margin-bottom: .5rem;
|
||||
background: #fef2f2; border-radius: 0 6px 6px 0;
|
||||
}
|
||||
.fix.warn { border-color: #f59e0b; background: #fffbeb; }
|
||||
.fix.info { border-color: #3b82f6; background: #eff6ff; }
|
||||
.fix.muted { border-color: #9ca3af; background: #f9fafb; }
|
||||
.fix .code { font-family: ui-monospace, monospace; font-size: .75rem; color: #6b7280; }
|
||||
.fix .msg { font-weight: 600; margin: .1rem 0 .2rem; }
|
||||
.fix .how { font-size: .88rem; }
|
||||
.fix .ep { font-size: .78rem; color: #6b7280; font-family: ui-monospace, monospace; }
|
||||
|
||||
.chiprow { display: flex; flex-wrap: wrap; gap: .25rem; }
|
||||
.chip {
|
||||
display: inline-block; padding: .12em .5em;
|
||||
background: #e0e7ff; color: #3730a3;
|
||||
border-radius: 4px; font-size: .78rem; font-family: ui-monospace, monospace;
|
||||
}
|
||||
.chip.fail { background: #fee2e2; color: #991b1b; }
|
||||
.chip.warn { background: #fef3c7; color: #92400e; }
|
||||
.chip.ok { background: #d1fae5; color: #065f46; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: auto 1fr; gap: .3rem 1rem; font-size: .86rem; }
|
||||
.kv dt { color: #6b7280; }
|
||||
.kv dd { margin: 0; }
|
||||
|
||||
.note { color: #6b7280; font-size: .85rem; }
|
||||
.footer { color: #6b7280; font-size: .78rem; text-align: center; margin-top: 1rem; padding-bottom: 2rem; }
|
||||
.check-ok { color: #059669; }
|
||||
.check-fail { color: #dc2626; }
|
||||
|
||||
.fp {
|
||||
font-family: ui-monospace, monospace; font-size: .72rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="hd">
|
||||
<h1>SSH: <code>{{.Domain}}</code></h1>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
<div class="meta">
|
||||
{{if .SSHFPPresent}}
|
||||
{{if .SSHFPMatched}}<span class="badge ok">SSHFP verified</span>
|
||||
{{else}}<span class="badge fail">SSHFP mismatch</span>{{end}}
|
||||
{{else}}<span class="badge muted">no SSHFP</span>{{end}}
|
||||
{{if .AnyIPv4}}<span class="badge muted">IPv4</span>{{end}}
|
||||
{{if .AnyIPv6}}<span class="badge muted">IPv6</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">Checked {{.RunAt}}</div>
|
||||
</div>
|
||||
|
||||
{{if .HasIssues}}
|
||||
<div class="section">
|
||||
<h2>What to fix</h2>
|
||||
{{range .TopFixes}}
|
||||
<div class="fix {{sevClass .Severity}}">
|
||||
<div class="code">{{.Code}}{{if .Endpoint}} · {{.Endpoint}}{{end}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .SSHFPPresent}}
|
||||
<div class="section">
|
||||
<h2>SSHFP records</h2>
|
||||
<table>
|
||||
<tr><th>Algorithm</th><th>Hash</th><th>Fingerprint</th><th>Status</th></tr>
|
||||
{{range .SSHFPRecords}}
|
||||
<tr>
|
||||
<td>{{.AlgoName}} ({{.Algorithm}})</td>
|
||||
<td>{{.TypeName}} ({{.Type}})</td>
|
||||
<td class="fp">{{.Fingerprint}}</td>
|
||||
<td>{{if .Matched}}<span class="badge ok">match</span>{{else}}<span class="badge warn">no match</span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="section">
|
||||
<h2>SSHFP records</h2>
|
||||
<p class="note">No SSHFP records are published for this service. Clients trust the host key the first time they connect (TOFU). Publishing SSHFP records (with DNSSEC) lets clients verify the server automatically.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Endpoints}}
|
||||
<div class="section">
|
||||
<h2>Endpoints ({{len .Endpoints}})</h2>
|
||||
{{range .Endpoints}}
|
||||
<details{{if .AnyFail}} open{{end}}>
|
||||
<summary>
|
||||
<span class="conn-addr">{{.Address}}{{if .Banner}} · {{.Banner}}{{end}}</span>
|
||||
<span class="badge {{.StatusClass}}">{{.StatusLabel}}</span>
|
||||
</summary>
|
||||
<div class="details-body">
|
||||
<dl class="kv">
|
||||
<dt>Host</dt><dd>{{.Host}}</dd>
|
||||
<dt>IP</dt><dd><code>{{.Address}}</code>{{if .IsIPv6}} (IPv6){{end}}</dd>
|
||||
<dt>TCP</dt><dd>{{if .TCPConnected}}<span class="check-ok">✓ connected</span>{{else}}<span class="check-fail">✗ failed</span>{{end}}</dd>
|
||||
{{if .SoftwareVer}}<dt>Version</dt><dd><code>{{.SoftwareVer}}</code>{{if .Vendor}} · <span class="note">{{.Vendor}}</span>{{end}}</dd>{{end}}
|
||||
<dt>Duration</dt><dd>{{.ElapsedMS}} ms</dd>
|
||||
{{if .Error}}<dt>Error</dt><dd><span class="check-fail">{{.Error}}</span></dd>{{end}}
|
||||
</dl>
|
||||
|
||||
{{if .HostKeys}}
|
||||
<h3>Host keys</h3>
|
||||
<table>
|
||||
<tr><th>Type</th><th>Bits</th><th>SHA-256 fingerprint</th><th>SSHFP</th></tr>
|
||||
{{range .HostKeys}}
|
||||
<tr>
|
||||
<td>{{.Type}}</td>
|
||||
<td>{{if .Bits}}{{.Bits}}{{else}}-{{end}}</td>
|
||||
<td class="fp">{{.SHA256}}</td>
|
||||
<td>
|
||||
{{if .SSHFPMatched}}<span class="badge ok">verified</span>
|
||||
{{else}}<span class="badge warn">no match</span>
|
||||
<div class="note">Add: <code>IN SSHFP {{.SSHFPSnippet}}</code></div>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
|
||||
{{range .AlgoTables}}
|
||||
{{if .Rows}}
|
||||
<h3>{{.Title}}</h3>
|
||||
<table>
|
||||
<tr><th>Algorithm</th><th>Verdict</th></tr>
|
||||
{{range .Rows}}
|
||||
<tr class="{{.Severity}}">
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td>
|
||||
{{if eq .Severity "crit"}}<span class="badge fail">broken</span>
|
||||
{{else if eq .Severity "warn"}}<span class="badge warn">weak</span>
|
||||
{{else if eq .Severity "info"}}<span class="badge muted">info</span>
|
||||
{{else if eq .Severity "ok"}}<span class="badge ok">good</span>
|
||||
{{else}}<span class="badge ok">OK</span>{{end}}
|
||||
{{if .Note}} <span class="note">{{.Note}}</span>{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</table>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if .AuthMethods}}
|
||||
<h3>Authentication methods</h3>
|
||||
<div class="chiprow">
|
||||
{{range .AuthMethods}}<span class="chip {{sevClass .Severity}}">{{.Name}}</span>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{if .Issues}}
|
||||
<h3>Findings</h3>
|
||||
{{range .Issues}}
|
||||
<div class="fix {{sevClass .Severity}}">
|
||||
<div class="code">{{.Code}}</div>
|
||||
<div class="msg">{{.Message}}</div>
|
||||
{{if .Fix}}<div class="how">→ {{.Fix}}</div>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
</details>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<p class="footer">SSH checker: algorithm posture inspired by <a href="https://github.com/jtesta/ssh-audit">ssh-audit</a>. For client-side audits, run that tool locally.</p>
|
||||
|
||||
</body>
|
||||
</html>`))
|
||||
137
checker/rules.go
Normal file
137
checker/rules.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// Each concern is its own rule so results surface independently in the UI
|
||||
// rather than being squashed under a single aggregated verdict.
|
||||
func Rules() []sdk.CheckRule {
|
||||
return []sdk.CheckRule{
|
||||
&reachabilityRule{},
|
||||
&handshakeRule{},
|
||||
&protocolVersionRule{},
|
||||
&bannerSoftwareRule{},
|
||||
&knownVulnsRule{},
|
||||
newKexAlgorithmsRule(),
|
||||
newHostKeyAlgorithmsRule(),
|
||||
newCipherAlgorithmsRule(),
|
||||
newMacAlgorithmsRule(),
|
||||
&strictKexRule{},
|
||||
&preauthCompressionRule{},
|
||||
&hostKeyStrengthRule{},
|
||||
&sshfpAlignmentRule{},
|
||||
&sshfpHashRule{},
|
||||
&authMethodsRule{},
|
||||
}
|
||||
}
|
||||
|
||||
// On failure, returns a single error state the caller should emit to short-circuit its rule.
|
||||
func loadSSHData(ctx context.Context, obs sdk.ObservationGetter) (*SSHData, *sdk.CheckState) {
|
||||
var data SSHData
|
||||
if err := obs.Get(ctx, ObservationKeySSH, &data); err != nil {
|
||||
return nil, &sdk.CheckState{
|
||||
Status: sdk.StatusError,
|
||||
Message: fmt.Sprintf("failed to load SSH observation: %v", err),
|
||||
Code: "ssh.observation_error",
|
||||
}
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// reachableEndpoints returns the subset of endpoints that completed
|
||||
// enough of the handshake to expose algorithm data.
|
||||
func reachableEndpoints(eps []SSHProbe) []SSHProbe {
|
||||
var out []SSHProbe
|
||||
for _, ep := range eps {
|
||||
if len(ep.KEX) > 0 {
|
||||
out = append(out, ep)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// severityToStatus maps an Issue severity to the SDK Status enum.
|
||||
func severityToStatus(sev string) sdk.Status {
|
||||
switch sev {
|
||||
case SeverityCrit:
|
||||
return sdk.StatusCrit
|
||||
case SeverityWarn:
|
||||
return sdk.StatusWarn
|
||||
case SeverityInfo:
|
||||
return sdk.StatusInfo
|
||||
default:
|
||||
return sdk.StatusOK
|
||||
}
|
||||
}
|
||||
|
||||
func issueToState(is Issue) sdk.CheckState {
|
||||
st := sdk.CheckState{
|
||||
Status: severityToStatus(is.Severity),
|
||||
Message: is.Message,
|
||||
Code: is.Code,
|
||||
Subject: is.Endpoint,
|
||||
}
|
||||
if is.Fix != "" {
|
||||
st.Meta = map[string]any{"fix": is.Fix}
|
||||
}
|
||||
return st
|
||||
}
|
||||
|
||||
func statesFromIssues(issues []Issue) []sdk.CheckState {
|
||||
out := make([]sdk.CheckState, 0, len(issues))
|
||||
for _, is := range issues {
|
||||
out = append(out, issueToState(is))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func passState(code, message string) sdk.CheckState {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusOK,
|
||||
Message: message,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
func notTestedState(code, message string) sdk.CheckState {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: message,
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
|
||||
// noEndpointsState is returned by rules that need probe output but got
|
||||
// nothing (no endpoints collected at all).
|
||||
func noEndpointsState(code string) sdk.CheckState {
|
||||
return sdk.CheckState{
|
||||
Status: sdk.StatusUnknown,
|
||||
Message: "No SSH endpoints were probed.",
|
||||
Code: code,
|
||||
}
|
||||
}
|
||||
174
checker/rules_algorithms.go
Normal file
174
checker/rules_algorithms.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// algorithmFamilyRule is the shared implementation for the four
|
||||
// algorithm posture rules (KEX, host key, cipher, MAC). Each one
|
||||
// inspects a different field on SSHProbe and uses a different catalog.
|
||||
type algorithmFamilyRule struct {
|
||||
ruleName string
|
||||
description string
|
||||
passCode string
|
||||
passMsg string
|
||||
family string
|
||||
extract func(p *SSHProbe) []string
|
||||
table map[string]algoVerdict
|
||||
}
|
||||
|
||||
func (r *algorithmFamilyRule) Name() string { return r.ruleName }
|
||||
func (r *algorithmFamilyRule) Description() string { return r.description }
|
||||
|
||||
func (r *algorithmFamilyRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
eps := reachableEndpoints(data.Endpoints)
|
||||
if len(eps) == 0 {
|
||||
return []sdk.CheckState{notTestedState(r.ruleName+".skipped", "No endpoint produced an algorithm listing.")}
|
||||
}
|
||||
var issues []Issue
|
||||
for _, ep := range eps {
|
||||
issues = append(issues, analyseWeakAlgos(ep.Address, r.family, r.extract(&ep), r.table)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState(r.passCode, r.passMsg)}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
|
||||
type kexAlgorithmsRule struct{ algorithmFamilyRule }
|
||||
|
||||
func newKexAlgorithmsRule() *kexAlgorithmsRule {
|
||||
return &kexAlgorithmsRule{algorithmFamilyRule{
|
||||
ruleName: "ssh.kex_algorithms",
|
||||
description: "Flags key-exchange algorithms advertised by the server that are weak or broken.",
|
||||
passCode: "ssh.kex_algorithms.ok",
|
||||
passMsg: "Every advertised KEX algorithm is modern.",
|
||||
family: "kex",
|
||||
extract: func(p *SSHProbe) []string { return p.KEX },
|
||||
table: kexAlgos,
|
||||
}}
|
||||
}
|
||||
|
||||
type hostKeyAlgorithmsRule struct{ algorithmFamilyRule }
|
||||
|
||||
func newHostKeyAlgorithmsRule() *hostKeyAlgorithmsRule {
|
||||
return &hostKeyAlgorithmsRule{algorithmFamilyRule{
|
||||
ruleName: "ssh.host_key_algorithms",
|
||||
description: "Flags server host-key algorithms that are weak or deprecated (ssh-rsa/SHA-1, ssh-dss, …).",
|
||||
passCode: "ssh.host_key_algorithms.ok",
|
||||
passMsg: "Every advertised host-key algorithm is modern.",
|
||||
family: "hostkey_alg",
|
||||
extract: func(p *SSHProbe) []string { return p.HostKey },
|
||||
table: hostKeyAlgos,
|
||||
}}
|
||||
}
|
||||
|
||||
type cipherAlgorithmsRule struct{ algorithmFamilyRule }
|
||||
|
||||
func newCipherAlgorithmsRule() *cipherAlgorithmsRule {
|
||||
return &cipherAlgorithmsRule{algorithmFamilyRule{
|
||||
ruleName: "ssh.cipher_algorithms",
|
||||
description: "Flags symmetric ciphers advertised by the server that are weak or broken (CBC, 3DES, RC4, …).",
|
||||
passCode: "ssh.cipher_algorithms.ok",
|
||||
passMsg: "Every advertised cipher is modern.",
|
||||
family: "cipher",
|
||||
extract: func(p *SSHProbe) []string { return uniqueMerge(p.CiphersC2S, p.CiphersS2C) },
|
||||
table: cipherAlgos,
|
||||
}}
|
||||
}
|
||||
|
||||
type macAlgorithmsRule struct{ algorithmFamilyRule }
|
||||
|
||||
func newMacAlgorithmsRule() *macAlgorithmsRule {
|
||||
return &macAlgorithmsRule{algorithmFamilyRule{
|
||||
ruleName: "ssh.mac_algorithms",
|
||||
description: "Flags MAC algorithms advertised by the server that are weak (SHA-1, non-ETM, …).",
|
||||
passCode: "ssh.mac_algorithms.ok",
|
||||
passMsg: "Every advertised MAC algorithm is modern.",
|
||||
family: "mac",
|
||||
extract: func(p *SSHProbe) []string { return uniqueMerge(p.MACsC2S, p.MACsS2C) },
|
||||
table: macAlgos,
|
||||
}}
|
||||
}
|
||||
|
||||
// strictKexRule flags the absence of the Terrapin mitigation marker.
|
||||
type strictKexRule struct{}
|
||||
|
||||
func (r *strictKexRule) Name() string { return "ssh.strict_kex" }
|
||||
func (r *strictKexRule) Description() string {
|
||||
return "Verifies the server advertises the strict-KEX marker (CVE-2023-48795 Terrapin mitigation)."
|
||||
}
|
||||
|
||||
func (r *strictKexRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
eps := reachableEndpoints(data.Endpoints)
|
||||
if len(eps) == 0 {
|
||||
return []sdk.CheckState{notTestedState("ssh.strict_kex.skipped", "No endpoint produced an algorithm listing.")}
|
||||
}
|
||||
var issues []Issue
|
||||
for _, ep := range eps {
|
||||
issues = append(issues, analyseStrictKex(ep.Address, ep.KEX)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.strict_kex.ok", "Every endpoint advertises the Terrapin mitigation marker.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
|
||||
// preauthCompressionRule flags servers offering "zlib" (pre-auth)
|
||||
// compression alongside / instead of zlib@openssh.com (post-auth).
|
||||
type preauthCompressionRule struct{}
|
||||
|
||||
func (r *preauthCompressionRule) Name() string { return "ssh.preauth_compression" }
|
||||
func (r *preauthCompressionRule) Description() string {
|
||||
return "Flags servers that offer pre-authentication zlib compression (prefer zlib@openssh.com)."
|
||||
}
|
||||
|
||||
func (r *preauthCompressionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
eps := reachableEndpoints(data.Endpoints)
|
||||
if len(eps) == 0 {
|
||||
return []sdk.CheckState{notTestedState("ssh.preauth_compression.skipped", "No endpoint produced compression data.")}
|
||||
}
|
||||
var issues []Issue
|
||||
for _, ep := range eps {
|
||||
issues = append(issues, analysePreauthCompression(ep.Address, ep.CompC2S)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.preauth_compression.ok", "No endpoint offers pre-authentication zlib compression.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
61
checker/rules_auth.go
Normal file
61
checker/rules_auth.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// authMethodsRule reports on the authentication methods advertised by
|
||||
// the server (password exposure, missing public-key support). Only
|
||||
// active when the auth probe ran.
|
||||
type authMethodsRule struct{}
|
||||
|
||||
func (r *authMethodsRule) Name() string { return "ssh.auth_methods" }
|
||||
func (r *authMethodsRule) Description() string {
|
||||
return "Reviews the advertised authentication methods (password exposure, public-key availability)."
|
||||
}
|
||||
|
||||
func (r *authMethodsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var probed bool
|
||||
var issues []Issue
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.AuthProbeAttempted {
|
||||
continue
|
||||
}
|
||||
probed = true
|
||||
issues = append(issues, analyseAuthMethods(ep.Address, &ep)...)
|
||||
}
|
||||
if !probed {
|
||||
return []sdk.CheckState{notTestedState("ssh.auth_methods.skipped", "Authentication-method enumeration disabled or not performed.")}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.auth_methods.ok", "Authentication method posture looks sound.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
122
checker/rules_banner.go
Normal file
122
checker/rules_banner.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// protocolVersionRule flags servers advertising SSH-1.x, which has
|
||||
// not been safe for decades.
|
||||
type protocolVersionRule struct{}
|
||||
|
||||
func (r *protocolVersionRule) Name() string { return "ssh.protocol_version" }
|
||||
func (r *protocolVersionRule) Description() string {
|
||||
return "Verifies every endpoint advertises SSH-2 and rejects the legacy SSH-1 protocol."
|
||||
}
|
||||
|
||||
func (r *protocolVersionRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{noEndpointsState("ssh.protocol_version.no_endpoints")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.Banner == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(ep.ProtoVer, "2.") && ep.ProtoVer != "2" {
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "ssh_legacy_protocol",
|
||||
Subject: ep.Address,
|
||||
Message: fmt.Sprintf("Server advertises SSH protocol %q (banner %q). SSH-1 is obsolete and insecure.", ep.ProtoVer, ep.Banner),
|
||||
Meta: map[string]any{"fix": "Disable SSH-1 support; run an sshd that only speaks SSH-2."},
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.protocol_version.ok", "Every endpoint advertises SSH-2.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// bannerSoftwareRule reports when a server's software identifier does
|
||||
// not look like a recognised OpenSSH build.
|
||||
type bannerSoftwareRule struct{}
|
||||
|
||||
func (r *bannerSoftwareRule) Name() string { return "ssh.banner_software" }
|
||||
func (r *bannerSoftwareRule) Description() string {
|
||||
return "Flags servers whose banner is not a recognised OpenSSH build (so their maintenance status cannot be inferred)."
|
||||
}
|
||||
|
||||
func (r *bannerSoftwareRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{noEndpointsState("ssh.banner_software.no_endpoints")}
|
||||
}
|
||||
var issues []Issue
|
||||
for _, ep := range data.Endpoints {
|
||||
issues = append(issues, analyseBannerSoftware(ep.Address, ep.Banner, ep.SoftVer)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.banner_software.ok", "All probed servers advertise a recognised OpenSSH build.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
|
||||
// knownVulnsRule maps the observed banner against an OpenSSH CVE
|
||||
// catalog and emits one state per matched vulnerability.
|
||||
type knownVulnsRule struct{}
|
||||
|
||||
func (r *knownVulnsRule) Name() string { return "ssh.known_vulnerabilities" }
|
||||
func (r *knownVulnsRule) Description() string {
|
||||
return "Matches the advertised OpenSSH version against a curated catalog of remotely-observable CVEs."
|
||||
}
|
||||
|
||||
func (r *knownVulnsRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{noEndpointsState("ssh.known_vulnerabilities.no_endpoints")}
|
||||
}
|
||||
var issues []Issue
|
||||
for _, ep := range data.Endpoints {
|
||||
issues = append(issues, analyseBannerVulns(ep.Address, ep.Banner, ep.SoftVer)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.known_vulnerabilities.ok", "No known CVE match against the advertised OpenSSH versions.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
64
checker/rules_hostkey.go
Normal file
64
checker/rules_hostkey.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.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// hostKeyStrengthRule flags host keys whose size is below what modern
|
||||
// OpenSSH requires (currently < 2048-bit RSA).
|
||||
type hostKeyStrengthRule struct{}
|
||||
|
||||
func (r *hostKeyStrengthRule) Name() string { return "ssh.host_key_strength" }
|
||||
func (r *hostKeyStrengthRule) Description() string {
|
||||
return "Flags SSH host keys whose size is below the currently accepted minimum (e.g. RSA < 2048 bits)."
|
||||
}
|
||||
|
||||
func (r *hostKeyStrengthRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var anyKey bool
|
||||
var issues []Issue
|
||||
for _, ep := range data.Endpoints {
|
||||
if len(ep.HostKeys) > 0 {
|
||||
anyKey = true
|
||||
}
|
||||
// Also flag endpoints that reached KEXINIT but failed to
|
||||
// produce any host key: the handshake didn't complete.
|
||||
if len(ep.KEX) > 0 {
|
||||
issues = append(issues, analyseHandshakeHostKey(ep.Address, true, ep.HostKeys)...)
|
||||
}
|
||||
issues = append(issues, analyseHostKeyStrength(ep.Address, ep.HostKeys)...)
|
||||
}
|
||||
if !anyKey && len(issues) == 0 {
|
||||
return []sdk.CheckState{notTestedState("ssh.host_key_strength.skipped", "No host key observed on any reachable endpoint.")}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.host_key_strength.ok", "Every observed host key meets the minimum accepted size.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
134
checker/rules_reachability.go
Normal file
134
checker/rules_reachability.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// reachabilityRule reports per-endpoint TCP reachability. One state
|
||||
// per probed (ip, port) pair so the UI distinguishes individual
|
||||
// firewall / routing issues.
|
||||
type reachabilityRule struct{}
|
||||
|
||||
func (r *reachabilityRule) Name() string { return "ssh.tcp_reachable" }
|
||||
func (r *reachabilityRule) Description() string {
|
||||
return "Verifies that every probed (address, port) pair accepts a TCP connection."
|
||||
}
|
||||
|
||||
func (r *reachabilityRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{noEndpointsState("ssh.tcp_reachable.no_endpoints")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
for _, ep := range data.Endpoints {
|
||||
if ep.TCPConnected {
|
||||
continue
|
||||
}
|
||||
msg := "Cannot open TCP connection to " + ep.Address
|
||||
if ep.Error != "" {
|
||||
msg += ": " + ep.Error
|
||||
}
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Message: msg,
|
||||
Code: "tcp_unreachable",
|
||||
Subject: ep.Address,
|
||||
Meta: map[string]any{
|
||||
"fix": "Check DNS, firewall (allow tcp/" + strconv.Itoa(int(ep.Port)) + " from the internet), and that sshd is running.",
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.tcp_reachable.ok", "All probed endpoints accept TCP connections.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
|
||||
// handshakeRule reports per-endpoint SSH handshake progress: whether
|
||||
// banner exchange and KEXINIT parsing completed. Endpoints that are
|
||||
// TCP-unreachable are skipped (covered by reachabilityRule).
|
||||
type handshakeRule struct{}
|
||||
|
||||
func (r *handshakeRule) Name() string { return "ssh.handshake" }
|
||||
func (r *handshakeRule) Description() string {
|
||||
return "Verifies that the SSH banner exchange and KEXINIT parse succeed on every reachable endpoint."
|
||||
}
|
||||
|
||||
func (r *handshakeRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if len(data.Endpoints) == 0 {
|
||||
return []sdk.CheckState{noEndpointsState("ssh.handshake.no_endpoints")}
|
||||
}
|
||||
var states []sdk.CheckState
|
||||
for _, ep := range data.Endpoints {
|
||||
if !ep.TCPConnected {
|
||||
continue
|
||||
}
|
||||
switch ep.Stage {
|
||||
case "banner":
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "no_ssh_banner",
|
||||
Subject: ep.Address,
|
||||
Message: fmt.Sprintf("Server on %s did not send an SSH-2.0 banner: %s", ep.Address, ep.Error),
|
||||
Meta: map[string]any{"fix": "Check that an SSH daemon (not HTTP, mail, ...) listens on this port."},
|
||||
})
|
||||
case "banner_write":
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "banner_write_failed",
|
||||
Subject: ep.Address,
|
||||
Message: "Failed to send our client banner: " + ep.Error,
|
||||
})
|
||||
case "kexinit_read":
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "kexinit_read_failed",
|
||||
Subject: ep.Address,
|
||||
Message: "Server did not send KEXINIT after banner: " + ep.Error,
|
||||
})
|
||||
case "kexinit_parse":
|
||||
states = append(states, sdk.CheckState{
|
||||
Status: sdk.StatusCrit,
|
||||
Code: "kexinit_parse_failed",
|
||||
Subject: ep.Address,
|
||||
Message: "Malformed KEXINIT packet: " + ep.Error,
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(states) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.handshake.ok", "All reachable endpoints completed the SSH handshake.")}
|
||||
}
|
||||
return states
|
||||
}
|
||||
87
checker/rules_sshfp.go
Normal file
87
checker/rules_sshfp.go
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
)
|
||||
|
||||
// sshfpAlignmentRule compares the published SSHFP records with the
|
||||
// observed host keys (match, missing, mismatch, uncovered family).
|
||||
type sshfpAlignmentRule struct{}
|
||||
|
||||
func (r *sshfpAlignmentRule) Name() string { return "ssh.sshfp_alignment" }
|
||||
func (r *sshfpAlignmentRule) Description() string {
|
||||
return "Compares published SSHFP records against the observed host keys (match, missing, mismatch)."
|
||||
}
|
||||
|
||||
func (r *sshfpAlignmentRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
var issues []Issue
|
||||
sawKey := false
|
||||
for _, ep := range data.Endpoints {
|
||||
if len(ep.HostKeys) == 0 {
|
||||
continue
|
||||
}
|
||||
sawKey = true
|
||||
issues = append(issues, analyseSSHFPAlignment(ep.Address, ep.HostKeys, data.SSHFP)...)
|
||||
}
|
||||
if !sawKey {
|
||||
return []sdk.CheckState{notTestedState("ssh.sshfp_alignment.skipped", "No host key observed; SSHFP alignment cannot be assessed.")}
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.sshfp_alignment.ok", "Published SSHFP records match the observed host keys.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
|
||||
// sshfpHashRule flags an SSHFP set that uses only the deprecated SHA-1
|
||||
// (type 1) hash variant.
|
||||
type sshfpHashRule struct{}
|
||||
|
||||
func (r *sshfpHashRule) Name() string { return "ssh.sshfp_hash" }
|
||||
func (r *sshfpHashRule) Description() string {
|
||||
return "Flags SSHFP record sets that only publish SHA-1 (type 1) fingerprints instead of SHA-256."
|
||||
}
|
||||
|
||||
func (r *sshfpHashRule) Evaluate(ctx context.Context, obs sdk.ObservationGetter, _ sdk.CheckerOptions) []sdk.CheckState {
|
||||
data, errSt := loadSSHData(ctx, obs)
|
||||
if errSt != nil {
|
||||
return []sdk.CheckState{*errSt}
|
||||
}
|
||||
if !data.SSHFP.Present {
|
||||
return []sdk.CheckState{notTestedState("ssh.sshfp_hash.skipped", "No SSHFP records published.")}
|
||||
}
|
||||
var issues []Issue
|
||||
for _, ep := range data.Endpoints {
|
||||
issues = append(issues, analyseSSHFPHashes(ep.Address, ep.HostKeys, data.SSHFP)...)
|
||||
}
|
||||
if len(issues) == 0 {
|
||||
return []sdk.CheckState{passState("ssh.sshfp_hash.ok", "SSHFP records include a SHA-256 (type 2) fingerprint.")}
|
||||
}
|
||||
return statesFromIssues(issues)
|
||||
}
|
||||
47
checker/service.go
Normal file
47
checker/service.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/miekg/dns"
|
||||
)
|
||||
|
||||
// serviceMessage mirrors happydns.ServiceMessage for the tiny subset
|
||||
// of fields this checker reads or produces. Keeping a local copy lets
|
||||
// us drop the happyDomain module dependency while preserving the
|
||||
// on-the-wire JSON shape that the host emits when AutoFillService
|
||||
// hands us an abstract.Server payload.
|
||||
type serviceMessage struct {
|
||||
Type string `json:"_svctype"`
|
||||
Domain string `json:"_domain"`
|
||||
Service json.RawMessage `json:"Service"`
|
||||
}
|
||||
|
||||
// abstractServer mirrors services/abstract.Server: the A/AAAA/SSHFP
|
||||
// records associated to a host in a zone.
|
||||
type abstractServer struct {
|
||||
A *dns.A `json:"A,omitempty"`
|
||||
AAAA *dns.AAAA `json:"AAAA,omitempty"`
|
||||
SSHFP []*dns.SSHFP `json:"SSHFP,omitempty"`
|
||||
}
|
||||
174
checker/sshfp.go
Normal file
174
checker/sshfp.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// analyseHandshakeHostKey flags an endpoint where the full handshake
|
||||
// never yielded any host key.
|
||||
func analyseHandshakeHostKey(addr string, reached bool, keys []HostKeyInfo) []Issue {
|
||||
if !reached || len(keys) > 0 {
|
||||
return nil
|
||||
}
|
||||
return []Issue{{
|
||||
Code: "no_host_key",
|
||||
Severity: SeverityCrit,
|
||||
Message: "Could not retrieve any SSH host key; the full handshake failed.",
|
||||
Fix: "Check that the server accepts curve25519-sha256 or a similarly modern KEX, and that firewalls don't terminate the TLS-less SSH transport mid-flight.",
|
||||
Endpoint: addr,
|
||||
}}
|
||||
}
|
||||
|
||||
// analyseHostKeyStrength flags host keys whose size is below the
|
||||
// minimum accepted by modern OpenSSH.
|
||||
func analyseHostKeyStrength(addr string, keys []HostKeyInfo) []Issue {
|
||||
var issues []Issue
|
||||
for _, k := range keys {
|
||||
if k.SSHFPAlgo == 1 && k.Bits > 0 && k.Bits < 2048 {
|
||||
issues = append(issues, Issue{
|
||||
Code: "short_rsa_host_key",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("RSA host key is %d bits; OpenSSH has rejected < 2048 bits since 8.2.", k.Bits),
|
||||
Fix: "Regenerate the host key: rm /etc/ssh/ssh_host_rsa_key && ssh-keygen -t rsa -b 4096 -f /etc/ssh/ssh_host_rsa_key -N ''",
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// analyseSSHFPAlignment returns per-key alignment issues: match,
|
||||
// no coverage for a key family, or mismatch between DNS and server.
|
||||
func analyseSSHFPAlignment(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue {
|
||||
if len(keys) == 0 {
|
||||
return nil
|
||||
}
|
||||
if !s.Present {
|
||||
return []Issue{{
|
||||
Code: "sshfp_missing",
|
||||
Severity: SeverityInfo,
|
||||
Message: "No SSHFP records published. Clients currently trust-on-first-use this server's host key.",
|
||||
Fix: fmt.Sprintf("Publish SSHFP records under this service. Example for the ed25519 key: `IN SSHFP 4 2 %s`.", firstSHA256(keys)),
|
||||
Endpoint: addr,
|
||||
}}
|
||||
}
|
||||
var issues []Issue
|
||||
coveredFamily := map[uint8]bool{}
|
||||
for _, rr := range s.Records {
|
||||
coveredFamily[rr.Algorithm] = true
|
||||
}
|
||||
for _, k := range keys {
|
||||
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 {
|
||||
issues = append(issues, Issue{
|
||||
Code: "sshfp_verified",
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf("Host key %s (%s) matches the published SSHFP record.", k.Type, shortFP(k.SHA256)),
|
||||
Endpoint: addr,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if !coveredFamily[k.SSHFPAlgo] {
|
||||
issues = append(issues, Issue{
|
||||
Code: "sshfp_not_covered",
|
||||
Severity: SeverityWarn,
|
||||
Message: fmt.Sprintf("No SSHFP record covers host-key algorithm %s.", k.Type),
|
||||
Fix: fmt.Sprintf("Add `IN SSHFP %d 2 %s` to the zone.", k.SSHFPAlgo, k.SHA256),
|
||||
Endpoint: addr,
|
||||
})
|
||||
continue
|
||||
}
|
||||
issues = append(issues, Issue{
|
||||
Code: "sshfp_mismatch",
|
||||
Severity: SeverityCrit,
|
||||
Message: fmt.Sprintf("Published SSHFP record does not match the %s host key presented by %s. Either the server key was rotated without updating DNS, or the server is impersonated.", k.Type, addr),
|
||||
Fix: fmt.Sprintf("Update the SSHFP record to the current fingerprint: `IN SSHFP %d 2 %s`, and investigate why DNS and the server disagree.", k.SSHFPAlgo, k.SHA256),
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// analyseSSHFPHashes flags a server whose published SSHFP records only
|
||||
// use the deprecated SHA-1 (type 1) hash variant and where at least
|
||||
// one of those records matched an observed key.
|
||||
func analyseSSHFPHashes(addr string, keys []HostKeyInfo, s SSHFPSummary) []Issue {
|
||||
if !s.Present {
|
||||
return nil
|
||||
}
|
||||
matchedAny := false
|
||||
for _, k := range keys {
|
||||
if k.SSHFPMatchSHA256 || k.SSHFPMatchSHA1 {
|
||||
matchedAny = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matchedAny {
|
||||
return nil
|
||||
}
|
||||
for _, rr := range s.Records {
|
||||
if rr.Type == 2 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return []Issue{{
|
||||
Code: "sshfp_only_sha1",
|
||||
Severity: SeverityWarn,
|
||||
Message: "SSHFP records use only SHA-1 (type 1) fingerprints. SHA-1 is deprecated for this use.",
|
||||
Fix: "Add SHA-256 (type 2) SSHFP records alongside (or instead of) the existing SHA-1 ones.",
|
||||
Endpoint: addr,
|
||||
}}
|
||||
}
|
||||
|
||||
// analyseHostKeys is a convenience wrapper used by the HTML report.
|
||||
// reachedKexInit signals whether the handshake made it far enough for
|
||||
// the absence of host keys to be meaningful (i.e. the server accepted
|
||||
// our KEXINIT).
|
||||
func analyseHostKeys(addr string, keys []HostKeyInfo, s SSHFPSummary, reachedKexInit bool) []Issue {
|
||||
var issues []Issue
|
||||
issues = append(issues, analyseHandshakeHostKey(addr, reachedKexInit, keys)...)
|
||||
issues = append(issues, analyseHostKeyStrength(addr, keys)...)
|
||||
issues = append(issues, analyseSSHFPAlignment(addr, keys, s)...)
|
||||
issues = append(issues, analyseSSHFPHashes(addr, keys, s)...)
|
||||
return issues
|
||||
}
|
||||
|
||||
func firstSHA256(keys []HostKeyInfo) string {
|
||||
for _, k := range keys {
|
||||
if k.Type == "ssh-ed25519" {
|
||||
return k.SHA256
|
||||
}
|
||||
}
|
||||
if len(keys) > 0 {
|
||||
return keys[0].SHA256
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func shortFP(hexFP string) string {
|
||||
if len(hexFP) < 16 {
|
||||
return hexFP
|
||||
}
|
||||
return strings.ToUpper(hexFP[:8] + ":" + hexFP[8:16] + "…")
|
||||
}
|
||||
147
checker/types.go
Normal file
147
checker/types.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
// Package checker implements an SSH server security checker for
|
||||
// happyDomain. It probes each SSH endpoint associated with an
|
||||
// abstract.Server service and produces a structured report covering
|
||||
// reachability, banner/version posture, algorithm negotiation
|
||||
// (KEX/HostKey/Cipher/MAC/Compression), authentication method exposure
|
||||
// and SSHFP host-key fingerprint validation.
|
||||
package checker
|
||||
|
||||
import "time"
|
||||
|
||||
// ObservationKeySSH is the observation key this checker writes.
|
||||
const ObservationKeySSH = "ssh"
|
||||
|
||||
// Option ids on CheckerOptions.
|
||||
const (
|
||||
OptionService = "service"
|
||||
OptionPorts = "ports"
|
||||
OptionProbeTimeoutMs = "probeTimeoutMs"
|
||||
OptionIncludeAuthProbe = "includeAuthProbe"
|
||||
)
|
||||
|
||||
// Defaults.
|
||||
const (
|
||||
DefaultSSHPort = 22
|
||||
DefaultProbeTimeoutMs = 10000
|
||||
MaxConcurrentProbes = 16
|
||||
)
|
||||
|
||||
// Severity levels used in Issue.Severity.
|
||||
const (
|
||||
SeverityCrit = "crit"
|
||||
SeverityWarn = "warn"
|
||||
SeverityInfo = "info"
|
||||
SeverityOK = "ok"
|
||||
)
|
||||
|
||||
// SSHData is the full collected payload written under ObservationKeySSH.
|
||||
type SSHData struct {
|
||||
Domain string `json:"domain,omitempty"`
|
||||
Endpoints []SSHProbe `json:"endpoints"`
|
||||
SSHFP SSHFPSummary `json:"sshfp"`
|
||||
CollectedAt time.Time `json:"collected_at"`
|
||||
}
|
||||
|
||||
// SSHFPSummary captures the SSHFP records declared for the service and
|
||||
// whether a usable chain (DNSSEC) is available.
|
||||
type SSHFPSummary struct {
|
||||
Records []SSHFPRecord `json:"records,omitempty"`
|
||||
// Present indicates whether the service carries at least one SSHFP RR.
|
||||
Present bool `json:"present"`
|
||||
}
|
||||
|
||||
// SSHFPRecord is a single SSHFP record as declared in the zone.
|
||||
type SSHFPRecord struct {
|
||||
Algorithm uint8 `json:"algorithm"` // 1=RSA, 2=DSA, 3=ECDSA, 4=Ed25519
|
||||
Type uint8 `json:"type"` // 1=SHA-1, 2=SHA-256
|
||||
Fingerprint string `json:"fingerprint"` // hex, lowercase
|
||||
}
|
||||
|
||||
// SSHProbe is the outcome of probing a single SSH endpoint.
|
||||
type SSHProbe struct {
|
||||
Host string `json:"host"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
IsIPv6 bool `json:"ipv6,omitempty"`
|
||||
TCPConnected bool `json:"tcp_connected"`
|
||||
|
||||
// Banner is the SSH protocol banner (e.g. "SSH-2.0-OpenSSH_9.3p1").
|
||||
Banner string `json:"banner,omitempty"`
|
||||
SoftVer string `json:"software_version,omitempty"`
|
||||
ProtoVer string `json:"protocol_version,omitempty"`
|
||||
Vendor string `json:"vendor,omitempty"`
|
||||
ElapsedMS int64 `json:"elapsed_ms,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Algorithms negotiated by the server.
|
||||
KEX []string `json:"kex_algorithms,omitempty"`
|
||||
HostKey []string `json:"host_key_algorithms,omitempty"`
|
||||
CiphersC2S []string `json:"ciphers_c2s,omitempty"`
|
||||
CiphersS2C []string `json:"ciphers_s2c,omitempty"`
|
||||
MACsC2S []string `json:"macs_c2s,omitempty"`
|
||||
MACsS2C []string `json:"macs_s2c,omitempty"`
|
||||
CompC2S []string `json:"compression_c2s,omitempty"`
|
||||
CompS2C []string `json:"compression_s2c,omitempty"`
|
||||
|
||||
// Host keys observed during KEX. Multiple entries can appear if the
|
||||
// server advertises several host-key types and we probe each in a
|
||||
// second pass.
|
||||
HostKeys []HostKeyInfo `json:"host_keys,omitempty"`
|
||||
|
||||
// Authentication methods advertised for a dummy "none" auth attempt.
|
||||
AuthMethods []string `json:"auth_methods,omitempty"`
|
||||
PasswordAuth bool `json:"password_auth,omitempty"`
|
||||
KeyboardInteractive bool `json:"keyboard_interactive,omitempty"`
|
||||
PublicKeyAuth bool `json:"public_key_auth,omitempty"`
|
||||
AuthProbeAttempted bool `json:"auth_probe_attempted,omitempty"`
|
||||
|
||||
// Stage is the furthest probe stage the connection reached. One of
|
||||
// "dial", "banner", "banner_write", "kexinit_read", "kexinit_parse",
|
||||
// "kexinit_ok", "handshake_ok". Empty means the dial failed before
|
||||
// even being attempted.
|
||||
Stage string `json:"stage,omitempty"`
|
||||
}
|
||||
|
||||
// HostKeyInfo captures an observed host key and its computed fingerprints.
|
||||
type HostKeyInfo struct {
|
||||
Type string `json:"type"` // e.g. "ssh-ed25519"
|
||||
Bits int `json:"bits,omitempty"` // key size (bits)
|
||||
SHA256 string `json:"sha256"` // hex fingerprint (lowercase, no colons)
|
||||
SHA1 string `json:"sha1"` // hex fingerprint (lowercase, no colons)
|
||||
SSHFPAlgo uint8 `json:"sshfp_algorithm"` // the SSHFP algorithm number matching this key type
|
||||
SSHFPMatchSHA256 bool `json:"sshfp_match_sha256"`
|
||||
SSHFPMatchSHA1 bool `json:"sshfp_match_sha1"`
|
||||
}
|
||||
|
||||
// Issue is a single SSH finding surfaced to consumers.
|
||||
type Issue struct {
|
||||
Code string `json:"code"`
|
||||
Severity string `json:"severity"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Fix string `json:"fix,omitempty"`
|
||||
// Endpoint is the "host:port" this issue applies to (empty for
|
||||
// service-level issues such as missing SSHFP).
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
}
|
||||
249
checker/vulns.go
Normal file
249
checker/vulns.go
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package checker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// OpenSSH CVE database. The entries here are a curated subset of the
|
||||
// ssh-audit vulnerability list focused on issues that are both
|
||||
// remotely observable from a banner and serious enough to warrant
|
||||
// surfacing in a periodic check.
|
||||
//
|
||||
// Versions are expressed using a semver-ish triple plus an optional
|
||||
// "p" patch suffix, which mirrors OpenSSH's own numbering
|
||||
// (e.g. 9.3p1 < 9.3p2 < 9.4p1). The matcher is conservative: when a
|
||||
// banner can't be parsed into a version, we skip the match rather
|
||||
// than over-flag.
|
||||
|
||||
type opensshVuln struct {
|
||||
Code string
|
||||
CVE string
|
||||
Severity string
|
||||
Title string
|
||||
Description string
|
||||
Fix string
|
||||
AffectedRanges []opensshRange
|
||||
}
|
||||
|
||||
type opensshRange struct {
|
||||
MinInclusive string // "" means open-ended below
|
||||
MaxExclusive string // "" means open-ended above
|
||||
}
|
||||
|
||||
var opensshVulns = []opensshVuln{
|
||||
{
|
||||
Code: "cve_2024_6387_regreSSHion",
|
||||
CVE: "CVE-2024-6387",
|
||||
Severity: SeverityCrit,
|
||||
Title: "regreSSHion (CVE-2024-6387)",
|
||||
Description: "Signal-handler race in OpenSSH's sshd allows unauthenticated remote code execution as root on glibc-based systems.",
|
||||
Fix: "Upgrade OpenSSH to 9.8p1 or later. If upgrading is not possible, set LoginGraceTime 0 in sshd_config as a mitigation (denial-of-service trade-off).",
|
||||
AffectedRanges: []opensshRange{
|
||||
// Regression reintroduced in 8.5p1; fixed in 9.8p1.
|
||||
{MinInclusive: "8.5p1", MaxExclusive: "9.8p1"},
|
||||
// The race also existed in < 4.4p1 (CVE-2006-5051 variant).
|
||||
{MaxExclusive: "4.4p1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "cve_2023_38408_agent",
|
||||
CVE: "CVE-2023-38408",
|
||||
Severity: SeverityCrit,
|
||||
Title: "ssh-agent PKCS#11 provider RCE",
|
||||
Description: "OpenSSH's forwarded ssh-agent in 5.5 through 9.3p1 can load and execute arbitrary shared libraries, enabling RCE if an attacker controls the forwarded agent.",
|
||||
Fix: "Upgrade OpenSSH to 9.3p2 or later.",
|
||||
AffectedRanges: []opensshRange{
|
||||
{MinInclusive: "5.5", MaxExclusive: "9.3p2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "cve_2023_48795_terrapin",
|
||||
CVE: "CVE-2023-48795",
|
||||
Severity: SeverityWarn,
|
||||
Title: "Terrapin prefix truncation (CVE-2023-48795)",
|
||||
Description: "A MITM can silently drop the first messages after KEX completes, potentially downgrading security features. Affects any SSH server supporting ChaCha20-Poly1305 or CBC-EtM without strict-KEX.",
|
||||
Fix: "Upgrade OpenSSH to 9.6p1 or later (advertises kex-strict-s-v00@openssh.com).",
|
||||
AffectedRanges: []opensshRange{
|
||||
{MaxExclusive: "9.6p1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "cve_2021_41617_agent_forward",
|
||||
CVE: "CVE-2021-41617",
|
||||
Severity: SeverityWarn,
|
||||
Title: "sshd AuthorizedKeysCommand / AuthorizedPrincipalsCommand privilege drop flaw",
|
||||
Description: "sshd from 6.2 to 8.8 fails to correctly drop supplementary groups when executing the AuthorizedKeysCommand/AuthorizedPrincipalsCommand helpers.",
|
||||
Fix: "Upgrade OpenSSH to 8.8p1 or later.",
|
||||
AffectedRanges: []opensshRange{
|
||||
{MinInclusive: "6.2", MaxExclusive: "8.8p1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "cve_2020_15778_scp",
|
||||
CVE: "CVE-2020-15778",
|
||||
Severity: SeverityWarn,
|
||||
Title: "scp command-injection via shell quoting",
|
||||
Description: "scp in OpenSSH through 8.3p1 does not sanitise filenames when copying files, enabling command injection on the destination via crafted names.",
|
||||
Fix: "Upgrade OpenSSH to 8.4p1 or later; prefer sftp/rsync over scp.",
|
||||
AffectedRanges: []opensshRange{
|
||||
{MaxExclusive: "8.4p1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Code: "cve_2018_15473_user_enum",
|
||||
CVE: "CVE-2018-15473",
|
||||
Severity: SeverityWarn,
|
||||
Title: "Username enumeration via timing",
|
||||
Description: "OpenSSH through 7.7p1 allows remote username enumeration by timing the response to malformed authentication packets.",
|
||||
Fix: "Upgrade OpenSSH to 7.8p1 or later.",
|
||||
AffectedRanges: []opensshRange{
|
||||
{MaxExclusive: "7.8p1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// analyseBannerSoftware flags a non-OpenSSH banner for operator
|
||||
// awareness. No CVE match is attempted on unrecognised software.
|
||||
func analyseBannerSoftware(addr, banner, software string) []Issue {
|
||||
if banner == "" {
|
||||
return nil
|
||||
}
|
||||
if parseOpenSSHVersion(software) != nil {
|
||||
return nil
|
||||
}
|
||||
if looksLikeOpenSSH(software) {
|
||||
return nil
|
||||
}
|
||||
return []Issue{{
|
||||
Code: "non_openssh",
|
||||
Severity: SeverityInfo,
|
||||
Message: fmt.Sprintf("Server reports %q, not a recognised OpenSSH build. Verify the deployed software is maintained.", software),
|
||||
Endpoint: addr,
|
||||
}}
|
||||
}
|
||||
|
||||
// analyseBannerVulns runs the banner through the OpenSSH CVE database
|
||||
// and returns the matched issues. The banner parser is deliberately
|
||||
// loose: a server running a vendor-patched OpenSSH (e.g.
|
||||
// "OpenSSH_9.2p1 Debian-2+deb12u2") will still match the upstream
|
||||
// version numbers, because distribution maintainers tend to backport
|
||||
// fixes without changing the version string. Operators get to
|
||||
// override these false positives at the UI layer, same as other
|
||||
// checkers.
|
||||
func analyseBannerVulns(addr, banner, software string) []Issue {
|
||||
if banner == "" {
|
||||
return nil
|
||||
}
|
||||
ver := parseOpenSSHVersion(software)
|
||||
if ver == nil {
|
||||
return nil
|
||||
}
|
||||
var issues []Issue
|
||||
for _, v := range opensshVulns {
|
||||
if rangesMatch(ver, v.AffectedRanges) {
|
||||
issues = append(issues, Issue{
|
||||
Code: v.Code,
|
||||
Severity: v.Severity,
|
||||
Message: fmt.Sprintf("%s: %s", v.Title, v.Description),
|
||||
Fix: v.Fix,
|
||||
Endpoint: addr,
|
||||
})
|
||||
}
|
||||
}
|
||||
return issues
|
||||
}
|
||||
|
||||
// analyseBanner combines software-awareness and vulnerability matches.
|
||||
// Retained as a convenience for the HTML report, which surfaces both
|
||||
// concerns in a single "What to fix" list.
|
||||
func analyseBanner(addr, banner, software string) []Issue {
|
||||
out := analyseBannerSoftware(addr, banner, software)
|
||||
out = append(out, analyseBannerVulns(addr, banner, software)...)
|
||||
return out
|
||||
}
|
||||
|
||||
func looksLikeOpenSSH(s string) bool {
|
||||
return strings.HasPrefix(s, "OpenSSH_")
|
||||
}
|
||||
|
||||
// opensshVersion captures a (major, minor, portable) tuple. Portable
|
||||
// is 0 when the banner lists only a vanilla upstream version (which
|
||||
// is rare). OpenSSH_9.3p1 → {9, 3, 1}.
|
||||
type opensshVersion struct{ Major, Minor, Portable int }
|
||||
|
||||
var opensshBannerRe = regexp.MustCompile(`^OpenSSH_(\d+)\.(\d+)(?:p(\d+))?`)
|
||||
|
||||
func parseOpenSSHVersion(software string) *opensshVersion {
|
||||
m := opensshBannerRe.FindStringSubmatch(software)
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
v := &opensshVersion{}
|
||||
v.Major, _ = strconv.Atoi(m[1])
|
||||
v.Minor, _ = strconv.Atoi(m[2])
|
||||
if m[3] != "" {
|
||||
v.Portable, _ = strconv.Atoi(m[3])
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func less(a, b opensshVersion) bool {
|
||||
if a.Major != b.Major {
|
||||
return a.Major < b.Major
|
||||
}
|
||||
if a.Minor != b.Minor {
|
||||
return a.Minor < b.Minor
|
||||
}
|
||||
return a.Portable < b.Portable
|
||||
}
|
||||
|
||||
func rangesMatch(v *opensshVersion, ranges []opensshRange) bool {
|
||||
for _, r := range ranges {
|
||||
min, okMin := parseVersionString(r.MinInclusive)
|
||||
max, okMax := parseVersionString(r.MaxExclusive)
|
||||
if okMin && less(*v, min) {
|
||||
continue
|
||||
}
|
||||
if okMax && !less(*v, max) {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseVersionString(s string) (opensshVersion, bool) {
|
||||
if s == "" {
|
||||
return opensshVersion{}, false
|
||||
}
|
||||
// Reuse the banner regex by pretending we have a "OpenSSH_" prefix.
|
||||
v := parseOpenSSHVersion("OpenSSH_" + s)
|
||||
if v == nil {
|
||||
return opensshVersion{}, false
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
45
go.mod
Normal file
45
go.mod
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
module git.happydns.org/checker-ssh
|
||||
|
||||
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/crypto v0.49.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/mod v0.33.0 // indirect
|
||||
golang.org/x/net v0.51.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
|
||||
)
|
||||
104
go.sum
Normal file
104
go.sum
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
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=
|
||||
48
main.go
Normal file
48
main.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.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/checker-sdk-go/checker/server"
|
||||
ssh "git.happydns.org/checker-ssh/checker"
|
||||
)
|
||||
|
||||
// Version is the standalone binary's version. It defaults to
|
||||
// "custom-build" and is meant to be overridden by the CI at link time:
|
||||
//
|
||||
// go build -ldflags "-X main.Version=1.2.3" .
|
||||
var Version = "custom-build"
|
||||
|
||||
var listenAddr = flag.String("listen", ":8080", "HTTP listen address")
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
ssh.Version = Version
|
||||
|
||||
srv := server.New(ssh.Provider())
|
||||
if err := srv.ListenAndServe(*listenAddr); err != nil {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}
|
||||
42
plugin/plugin.go
Normal file
42
plugin/plugin.go
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// 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>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// 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/>.
|
||||
|
||||
// Command plugin is the happyDomain plugin entrypoint for the SSH
|
||||
// checker. It is built as a Go plugin (`go build -buildmode=plugin`)
|
||||
// and loaded at runtime by happyDomain.
|
||||
package main
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
ssh "git.happydns.org/checker-ssh/checker"
|
||||
)
|
||||
|
||||
// Version is the plugin's version. Override at link time with
|
||||
// -ldflags "-X main.Version=1.2.3".
|
||||
var Version = "custom-build"
|
||||
|
||||
// NewCheckerPlugin is the symbol resolved by happyDomain when loading
|
||||
// the .so file.
|
||||
func NewCheckerPlugin() (*sdk.CheckerDefinition, sdk.ObservationProvider, error) {
|
||||
ssh.Version = Version
|
||||
prvd := ssh.Provider()
|
||||
return prvd.(sdk.CheckerDefinitionProvider).Definition(), prvd, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue