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