Compare commits
6 commits
cf0fdd773c
...
3d3819d3f9
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d3819d3f9 | |||
| bebb58beaf | |||
| a867ff92e9 | |||
| fdf9d88383 | |||
| 0ee552a35b | |||
| 2a1ba01940 |
16 changed files with 683 additions and 106 deletions
34
checkers/dane.go
Normal file
34
checkers/dane.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// 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 checkers
|
||||
|
||||
import (
|
||||
dane "git.happydns.org/checker-dane/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
prvd := dane.Provider()
|
||||
checker.RegisterObservationProvider(prvd)
|
||||
checker.RegisterExternalizableChecker(prvd.(sdk.CheckerDefinitionProvider).Definition())
|
||||
}
|
||||
|
|
@ -23,11 +23,13 @@ package checkers
|
|||
|
||||
import (
|
||||
matrix "git.happydns.org/checker-matrix/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(matrix.Provider())
|
||||
prvd := matrix.Provider()
|
||||
checker.RegisterObservationProvider(prvd)
|
||||
// Not Externalizable checker as it already calls a HTTP API
|
||||
checker.RegisterChecker(matrix.Definition())
|
||||
checker.RegisterChecker(prvd.(sdk.CheckerDefinitionProvider).Definition())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ package checkers
|
|||
|
||||
import (
|
||||
nsr "git.happydns.org/checker-ns-restrictions/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(nsr.Provider())
|
||||
checker.RegisterExternalizableChecker(nsr.Definition())
|
||||
prvd := nsr.Provider()
|
||||
checker.RegisterObservationProvider(prvd)
|
||||
checker.RegisterExternalizableChecker(prvd.(sdk.CheckerDefinitionProvider).Definition())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,10 +23,12 @@ package checkers
|
|||
|
||||
import (
|
||||
ping "git.happydns.org/checker-ping/checker"
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(ping.Provider())
|
||||
checker.RegisterExternalizableChecker(ping.Definition())
|
||||
prvd := ping.Provider()
|
||||
checker.RegisterObservationProvider(prvd)
|
||||
checker.RegisterExternalizableChecker(prvd.(sdk.CheckerDefinitionProvider).Definition())
|
||||
}
|
||||
|
|
|
|||
34
checkers/tls.go
Normal file
34
checkers/tls.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// 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 checkers
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
tls "git.happydns.org/checker-tls/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
prvd := tls.Provider()
|
||||
checker.RegisterObservationProvider(prvd)
|
||||
checker.RegisterExternalizableChecker(prvd.(sdk.CheckerDefinitionProvider).Definition())
|
||||
}
|
||||
|
|
@ -22,12 +22,14 @@
|
|||
package checkers
|
||||
|
||||
import (
|
||||
sdk "git.happydns.org/checker-sdk-go/checker"
|
||||
zonemaster "git.happydns.org/checker-zonemaster/checker"
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
)
|
||||
|
||||
func init() {
|
||||
checker.RegisterObservationProvider(zonemaster.Provider())
|
||||
prvd := zonemaster.Provider()
|
||||
checker.RegisterObservationProvider(prvd)
|
||||
// Not Externalizable checker as it already calls a HTTP API
|
||||
checker.RegisterChecker(zonemaster.Definition())
|
||||
checker.RegisterChecker(prvd.(sdk.CheckerDefinitionProvider).Definition())
|
||||
}
|
||||
|
|
|
|||
4
go.mod
4
go.mod
|
|
@ -5,10 +5,12 @@ go 1.25.0
|
|||
toolchain go1.26.2
|
||||
|
||||
require (
|
||||
git.happydns.org/checker-dane v0.1.3
|
||||
git.happydns.org/checker-matrix v0.1.0
|
||||
git.happydns.org/checker-ns-restrictions v0.1.0
|
||||
git.happydns.org/checker-ping v0.1.0
|
||||
git.happydns.org/checker-sdk-go v1.4.0
|
||||
git.happydns.org/checker-sdk-go v1.5.0
|
||||
git.happydns.org/checker-tls v0.6.2
|
||||
git.happydns.org/checker-zonemaster v0.1.0
|
||||
github.com/JGLTechnologies/gin-rate-limit v1.5.8
|
||||
github.com/StackExchange/dnscontrol/v4 v4.34.0
|
||||
|
|
|
|||
8
go.sum
8
go.sum
|
|
@ -8,14 +8,18 @@ cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCB
|
|||
codeberg.org/miekg/dns v0.6.73 h1:4aRD1k1THw49vpe1d+W3KO16adAGN8Raxdi0WGvvbrY=
|
||||
codeberg.org/miekg/dns v0.6.73/go.mod h1:58Y3ZTg6Z5ZEm/ZAAwHehbZfrD4u5mE4RByHoPEMyKk=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
git.happydns.org/checker-dane v0.1.3 h1:9VpQ4FrWJE/O6MZ08FCk1vmHsr3u5V7478als9Y4jl8=
|
||||
git.happydns.org/checker-dane v0.1.3/go.mod h1:md5SQA8M1QGq9MoXe3QVV+m55I+r8lU4iYx5KzvkbII=
|
||||
git.happydns.org/checker-matrix v0.1.0 h1:GGNIkJBlqvGtP42wbvyCr+vWQyZXYqNOQVRgFjsOzF0=
|
||||
git.happydns.org/checker-matrix v0.1.0/go.mod h1:L0MxEEyuLFrR3aWBW54wHQ1EvVIx4zx9IZmuh8HUHOA=
|
||||
git.happydns.org/checker-ns-restrictions v0.1.0 h1:SfIst5rHmviH9YGfUH3R108iZpeHk53f3C1j6YNDmPA=
|
||||
git.happydns.org/checker-ns-restrictions v0.1.0/go.mod h1:8B0KhImefLDFNIYYxdztsCq3576PIT7vM5KhXnwZ1Zw=
|
||||
git.happydns.org/checker-ping v0.1.0 h1:Cu9Upvs/WoAWHi0A/1QahmuqB4/99n/jK29W/Bnv2y0=
|
||||
git.happydns.org/checker-ping v0.1.0/go.mod h1:P0xv85b2MoVud7UXbfoS0n3qMlyQGfg+uz1knN+7Q7w=
|
||||
git.happydns.org/checker-sdk-go v1.4.0 h1:sO8EnF3suhNgYLRsbmCZWJOymH/oNMrOUqj3FEzJArs=
|
||||
git.happydns.org/checker-sdk-go v1.4.0/go.mod h1:aNAcfYFfbhvH9kJhE0Njp5GX0dQbxdRB0rJ0KvSC5nI=
|
||||
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/checker-tls v0.6.2 h1:8oKia1XlD+tklyqrwzmUgFH1Kw8VLSLLF9suZ7Qr14E=
|
||||
git.happydns.org/checker-tls v0.6.2/go.mod h1:9tpnxg0iOwS+7If64DRG1jqYonUAgxOBuxwfF5mVkL4=
|
||||
git.happydns.org/checker-zonemaster v0.1.0 h1:1Qa0HtAGTfNU/3XvHhxDNJR9Td8DLQa8PrcNbexoeyI=
|
||||
git.happydns.org/checker-zonemaster v0.1.0/go.mod h1:HnVHYW3EZWy03Z0g1KMLUT9XJbIzmgoFBfid/jpe6kA=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
|
||||
|
|
|
|||
138
internal/api/controller/certificate.go
Normal file
138
internal/api/controller/certificate.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// 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 controller exposes the "fetch certificate" endpoint used by the
|
||||
// TLSA editor to prefill Certificate hashes from a live TLS endpoint.
|
||||
//
|
||||
// Scoped to the domain the user owns (DomainHandler middleware + suffix
|
||||
// check) so it cannot be repurposed as an arbitrary TLS-probing proxy.
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
tls "git.happydns.org/checker-tls/checker"
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const fetchCertificateTimeout = 10 * time.Second
|
||||
|
||||
type CertificateController struct{}
|
||||
|
||||
func NewCertificateController() *CertificateController {
|
||||
return &CertificateController{}
|
||||
}
|
||||
|
||||
// fetchCertificateRequest is the editor's selection. Host is the owner
|
||||
// subdomain (without "_port._proto"); STARTTLS is optional and when empty
|
||||
// we auto-map a handful of common ports.
|
||||
type fetchCertificateRequest struct {
|
||||
Host string `json:"host" binding:"required"`
|
||||
Port uint16 `json:"port" binding:"required"`
|
||||
Proto string `json:"proto"`
|
||||
STARTTLS string `json:"starttls"`
|
||||
}
|
||||
|
||||
// fetchCertificateResponse carries the full chain (leaf first) so the editor
|
||||
// can offer DANE-EE and DANE-TA hashes side by side.
|
||||
type fetchCertificateResponse struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Chain []tls.CertInfo `json:"chain"`
|
||||
}
|
||||
|
||||
// FetchCertificate dials the requested endpoint and returns DANE-friendly
|
||||
// pre-hashed views of the server's certificate chain.
|
||||
//
|
||||
// @Summary Fetch a live certificate for a subdomain
|
||||
// @Tags domains
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param body body fetchCertificateRequest true "Endpoint to probe"
|
||||
// @Success 200 {object} fetchCertificateResponse
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
|
||||
// @Failure 403 {object} happydns.ErrorResponse "Host not under this domain"
|
||||
// @Failure 502 {object} happydns.ErrorResponse "Upstream TLS error"
|
||||
// @Router /domains/{domain}/fetch-certificate [post]
|
||||
func (cc *CertificateController) FetchCertificate(c *gin.Context) {
|
||||
var req fetchCertificateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
if req.Port == 0 {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("port is required"))
|
||||
return
|
||||
}
|
||||
proto := strings.ToLower(strings.TrimSpace(req.Proto))
|
||||
if proto == "" {
|
||||
proto = "tcp"
|
||||
}
|
||||
if proto != "tcp" && proto != "udp" {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("unsupported proto %q", req.Proto))
|
||||
return
|
||||
}
|
||||
|
||||
// Authorization: the authenticated domain must be a suffix of Host. We
|
||||
// trust c.Get("domain") (set by DomainHandler), not the client-supplied
|
||||
// Host, so the endpoint can't double as an arbitrary TLS-probing proxy.
|
||||
domVal, ok := c.Get("domain")
|
||||
if !ok {
|
||||
middleware.ErrorResponse(c, http.StatusForbidden, fmt.Errorf("domain context missing"))
|
||||
return
|
||||
}
|
||||
dom, ok := domVal.(*happydns.Domain)
|
||||
if !ok {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, fmt.Errorf("unexpected domain context type"))
|
||||
return
|
||||
}
|
||||
host := strings.TrimSpace(req.Host)
|
||||
if !strings.HasSuffix(host, dom.DomainName) {
|
||||
middleware.ErrorResponse(c, http.StatusForbidden, fmt.Errorf("host %q is not under %q", host, dom.DomainName))
|
||||
return
|
||||
}
|
||||
|
||||
host = strings.TrimSuffix(host, ".")
|
||||
|
||||
starttls := req.STARTTLS
|
||||
if starttls == "" {
|
||||
starttls = tls.AutoSTARTTLS(req.Port)
|
||||
}
|
||||
|
||||
chain, err := tls.FetchChain(c.Request.Context(), host, req.Port, starttls, fetchCertificateTimeout)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadGateway, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, fetchCertificateResponse{
|
||||
Endpoint: net.JoinHostPort(host, strconv.FormatUint(uint64(req.Port), 10)),
|
||||
Chain: tls.BuildChain(chain),
|
||||
})
|
||||
}
|
||||
|
|
@ -67,6 +67,9 @@ func DeclareDomainRoutes(
|
|||
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
||||
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
||||
|
||||
certCtrl := controller.NewCertificateController()
|
||||
apiDomainsRoutes.POST("/fetch-certificate", certCtrl.FetchCertificate)
|
||||
|
||||
// Mount domain-scoped checker routes.
|
||||
if cc != nil {
|
||||
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)
|
||||
|
|
|
|||
|
|
@ -287,6 +287,11 @@ func (app *App) initUsecases() {
|
|||
app.store,
|
||||
app.store,
|
||||
)
|
||||
if setter, ok := app.usecases.checkerEngine.(interface {
|
||||
SetRemoteAddresses(map[string]string)
|
||||
}); ok {
|
||||
setter.SetRemoteAddresses(app.cfg.CheckerRemoteAddresses)
|
||||
}
|
||||
// Build the user-level gate so paused or long-inactive users do not
|
||||
// get checked. The same user resolver is reused by the janitor for
|
||||
// per-user retention overrides.
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import (
|
|||
"runtime"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/checker"
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
|
@ -70,6 +71,20 @@ func declareFlags(o *happydns.Options) {
|
|||
|
||||
flag.Var(&stringSlice{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing checker plugins (.so files); may be repeated")
|
||||
|
||||
// One -checker-<id>-remote-address flag per registered checker. Checkers
|
||||
// register themselves in init() of the blank-imported `checkers` package,
|
||||
// so by the time declareFlags runs the registry is fully populated.
|
||||
if o.CheckerRemoteAddresses == nil {
|
||||
o.CheckerRemoteAddresses = map[string]string{}
|
||||
}
|
||||
for id := range checker.GetCheckers() {
|
||||
flag.Var(
|
||||
&mapEntry{Map: &o.CheckerRemoteAddresses, Key: id},
|
||||
fmt.Sprintf("checker-%s-remote-address", id),
|
||||
fmt.Sprintf("URL of a remote HTTP service that should run the %q checker (overrides any per-checker endpoint AdminOpt)", id),
|
||||
)
|
||||
}
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,29 @@ func (s *stringSlice) Set(value string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// mapEntry is a flag.Value that writes the flag value into a map under a
|
||||
// preset key. Used to register one flag per checker writing into a shared
|
||||
// map[string]string on Options.
|
||||
type mapEntry struct {
|
||||
Map *map[string]string
|
||||
Key string
|
||||
}
|
||||
|
||||
func (m *mapEntry) String() string {
|
||||
if m.Map == nil || *m.Map == nil {
|
||||
return ""
|
||||
}
|
||||
return (*m.Map)[m.Key]
|
||||
}
|
||||
|
||||
func (m *mapEntry) Set(value string) error {
|
||||
if *m.Map == nil {
|
||||
*m.Map = map[string]string{}
|
||||
}
|
||||
(*m.Map)[m.Key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
type JWTSecretKey struct {
|
||||
Secret *[]byte
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,14 +34,23 @@ import (
|
|||
|
||||
// checkerEngine implements the happydns.CheckerEngine interface.
|
||||
type checkerEngine struct {
|
||||
optionsUC *CheckerOptionsUsecase
|
||||
evalStore CheckEvaluationStorage
|
||||
execStore ExecutionStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
cacheStore ObservationCacheStorage
|
||||
entryStore DiscoveryEntryStorage
|
||||
obsRefStore DiscoveryObservationStorage
|
||||
relatedLookup checkerPkg.RelatedObservationLookup
|
||||
optionsUC *CheckerOptionsUsecase
|
||||
evalStore CheckEvaluationStorage
|
||||
execStore ExecutionStorage
|
||||
snapStore ObservationSnapshotStorage
|
||||
cacheStore ObservationCacheStorage
|
||||
entryStore DiscoveryEntryStorage
|
||||
obsRefStore DiscoveryObservationStorage
|
||||
relatedLookup checkerPkg.RelatedObservationLookup
|
||||
remoteAddresses map[string]string
|
||||
}
|
||||
|
||||
// SetRemoteAddresses installs a checker-ID -> remote HTTP endpoint map. When
|
||||
// a non-empty entry exists for a checker, runPipeline routes its observation
|
||||
// collection through the remote service instead of the local provider, and
|
||||
// takes precedence over any per-checker "endpoint" AdminOpt.
|
||||
func (e *checkerEngine) SetRemoteAddresses(addrs map[string]string) {
|
||||
e.remoteAddresses = addrs
|
||||
}
|
||||
|
||||
// NewCheckerEngine creates a new CheckerEngine implementation. Passing nil
|
||||
|
|
@ -189,8 +198,14 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
|
|||
obsCtx.SetRelatedLookup(def.ID, e.relatedLookup)
|
||||
}
|
||||
|
||||
// If an endpoint is configured, override observation providers with HTTP transport.
|
||||
if endpoint, ok := mergedOpts["endpoint"].(string); ok && endpoint != "" {
|
||||
// If an endpoint is configured, override observation providers with HTTP
|
||||
// transport. The CLI/config -checker-<id>-remote-address value (if set)
|
||||
// wins over the per-checker "endpoint" AdminOpt.
|
||||
endpoint, _ := mergedOpts["endpoint"].(string)
|
||||
if cli, ok := e.remoteAddresses[def.ID]; ok && cli != "" {
|
||||
endpoint = cli
|
||||
}
|
||||
if endpoint != "" {
|
||||
for _, key := range def.ObservationKeys {
|
||||
obsCtx.SetProviderOverride(key, checkerPkg.NewHTTPObservationProvider(key, endpoint))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -134,6 +134,12 @@ type Options struct {
|
|||
// PluginsDirectories lists filesystem paths scanned at startup for
|
||||
// checker plugins (.so files).
|
||||
PluginsDirectories []string
|
||||
|
||||
// CheckerRemoteAddresses maps a checker ID to the URL of a remote HTTP
|
||||
// service that should run that checker's observation collection. When
|
||||
// set for a given checker, this CLI/config value takes precedence over
|
||||
// any per-checker "endpoint" AdminOpt.
|
||||
CheckerRemoteAddresses map[string]string
|
||||
}
|
||||
|
||||
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import TableRecords from "$lib/components/records/TableRecords.svelte";
|
||||
import RawInput from "$lib/components/inputs/raw.svelte";
|
||||
import BasicInput from "$lib/components/inputs/basic.svelte";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { dnsResource, dnsRR } from "$lib/dns_rr";
|
||||
|
||||
|
|
@ -36,103 +33,396 @@
|
|||
}
|
||||
|
||||
let { dn, origin, readonly = false, value = $bindable({}) }: Props = $props();
|
||||
const type = "svcs.TLSAs";
|
||||
|
||||
// Initialize tlsa array if needed - cast to array type
|
||||
if (!value["tlsa"]) {
|
||||
value["tlsa"] = [] as any;
|
||||
}
|
||||
const records = (): dnsRR[] => value["tlsa"] as any as dnsRR[];
|
||||
|
||||
// Extract port and protocol from first record's domain name
|
||||
// Port + protocol are encoded in the owner name ("_443._tcp.host"). Parse
|
||||
// them out of the first record so an existing service opens with the
|
||||
// matching form values.
|
||||
let port = $state<number>(443);
|
||||
let protocol = $state<string>("tcp");
|
||||
|
||||
// Type-safe accessor for tlsa records as array
|
||||
const getTlsaArray = (): dnsRR[] => (value["tlsa"] as any) as dnsRR[];
|
||||
|
||||
if (getTlsaArray()?.[0]?.Hdr?.Name) {
|
||||
const match = getTlsaArray()[0].Hdr.Name.match(/^_(\d+)\._(\w+)/);
|
||||
if (match) {
|
||||
port = parseInt(match[1], 10);
|
||||
protocol = match[2];
|
||||
let protocol = $state<"tcp" | "udp">("tcp");
|
||||
if (records()?.[0]?.Hdr?.Name) {
|
||||
const m = records()[0].Hdr.Name.match(/^_(\d+)\._(tcp|udp)/);
|
||||
if (m) {
|
||||
port = parseInt(m[1], 10);
|
||||
protocol = m[2] as "tcp" | "udp";
|
||||
}
|
||||
}
|
||||
|
||||
// Construct the full DN with port and protocol prefix
|
||||
let fullDn = $derived(`_${port}._${protocol}`);
|
||||
const fullDn = $derived(`_${port}._${protocol}`);
|
||||
|
||||
// Sync the DN to all TLSA records
|
||||
$effect(() => {
|
||||
const records = getTlsaArray();
|
||||
if (records) {
|
||||
for (const record of records) {
|
||||
if (record?.Hdr) {
|
||||
record.Hdr.Name = fullDn;
|
||||
}
|
||||
}
|
||||
for (const r of records()) {
|
||||
if (r?.Hdr) r.Hdr.Name = fullDn;
|
||||
}
|
||||
});
|
||||
|
||||
// Numeric values are the RFC 6698 TLSA fields.
|
||||
const USAGE = [
|
||||
{
|
||||
v: 3,
|
||||
label: "DANE-EE — End entity (most common)",
|
||||
hint: "TLSA hash matches the leaf certificate. No CA is required; this profile is recommended for Let's Encrypt with SPKI pinning.",
|
||||
},
|
||||
{
|
||||
v: 2,
|
||||
label: "DANE-TA — Trust anchor",
|
||||
hint: "TLSA hash matches a CA in the chain you run. PKIX validation is not required.",
|
||||
},
|
||||
{
|
||||
v: 1,
|
||||
label: "PKIX-EE — End entity + PKIX",
|
||||
hint: "Like DANE-EE but the chain must also validate through public trust roots.",
|
||||
},
|
||||
{
|
||||
v: 0,
|
||||
label: "PKIX-TA — Trust anchor + PKIX",
|
||||
hint: "Like DANE-TA plus PKIX validation. Rarely used.",
|
||||
},
|
||||
];
|
||||
const SELECTOR = [
|
||||
{
|
||||
v: 1,
|
||||
label: "SPKI — Public key only (recommended)",
|
||||
hint: "Matches the Subject Public Key Info. Survives cert renewals that keep the same key pair.",
|
||||
},
|
||||
{
|
||||
v: 0,
|
||||
label: "Cert — Full certificate",
|
||||
hint: "Matches the whole certificate. You must rotate the TLSA at every cert renewal.",
|
||||
},
|
||||
];
|
||||
const MATCHING = [
|
||||
{
|
||||
v: 1,
|
||||
label: "SHA-256 (recommended)",
|
||||
hint: "32-byte hash (64 hex chars). Universally supported.",
|
||||
},
|
||||
{
|
||||
v: 2,
|
||||
label: "SHA-512",
|
||||
hint: "64-byte hash. Stronger; same guarantees as SHA-256 in practice.",
|
||||
},
|
||||
{
|
||||
v: 0,
|
||||
label: "Full / exact",
|
||||
hint: "Match the raw bytes. Produces very long records; use only when a hash is not acceptable.",
|
||||
},
|
||||
];
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
id: "le-dane-ee-spki",
|
||||
label: "Let's Encrypt / DANE-EE · SPKI · SHA-256",
|
||||
u: 3,
|
||||
s: 1,
|
||||
m: 1,
|
||||
},
|
||||
{ id: "pkix-ee-spki", label: "Public CA / PKIX-EE · SPKI · SHA-256", u: 1, s: 1, m: 1 },
|
||||
{
|
||||
id: "dane-ta-spki",
|
||||
label: "Self-hosted CA / DANE-TA · SPKI · SHA-256",
|
||||
u: 2,
|
||||
s: 1,
|
||||
m: 1,
|
||||
},
|
||||
];
|
||||
|
||||
function addRecord() {
|
||||
const r = {
|
||||
Hdr: { Name: fullDn, Rrtype: 52, Class: 1, Ttl: 3600, Rdlength: 0 },
|
||||
Usage: 3,
|
||||
Selector: 1,
|
||||
MatchingType: 1,
|
||||
Certificate: "",
|
||||
} as unknown as dnsRR;
|
||||
records().push(r);
|
||||
}
|
||||
function removeRecord(i: number) {
|
||||
records().splice(i, 1);
|
||||
}
|
||||
function applyPreset(i: number, id: string) {
|
||||
const p = PRESETS.find((x) => x.id === id);
|
||||
if (!p) return;
|
||||
const r = records()[i];
|
||||
r.Usage = p.u;
|
||||
r.Selector = p.s;
|
||||
r.MatchingType = p.m;
|
||||
}
|
||||
|
||||
let fetching = $state<number | null>(null);
|
||||
let errorMsg = $state<string>("");
|
||||
|
||||
async function fetchLive(i: number) {
|
||||
fetching = i;
|
||||
errorMsg = "";
|
||||
try {
|
||||
const host = dn || origin.domain;
|
||||
const res = await fetch(`/api/domains/${origin.id}/fetch-certificate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ host, port, proto: protocol }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.errmsg || `HTTP ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const r = records()[i];
|
||||
// Usage 1/3 are end-entity, so the leaf cert (chain[0]); 0/2 are
|
||||
// trust-anchor, so the next link up if the chain has one.
|
||||
const slot = r.Usage === 1 || r.Usage === 3 ? 0 : Math.min(1, data.chain.length - 1);
|
||||
const c = data.chain[slot];
|
||||
r.Certificate = pickHash(c, r.Selector, r.MatchingType);
|
||||
} catch (e: any) {
|
||||
errorMsg = `Fetch failed: ${e.message || e}`;
|
||||
} finally {
|
||||
fetching = null;
|
||||
}
|
||||
}
|
||||
|
||||
function pickHash(c: any, selector: number, matching: number): string {
|
||||
// Matching=0 (Full) expects hex of raw DER.
|
||||
const b64 = selector === 1 ? c.spki_der_base64 : c.der_base64;
|
||||
if (matching === 0) return hexFromBase64(b64);
|
||||
if (selector === 1) return matching === 2 ? c.spki_sha512 : c.spki_sha256;
|
||||
return matching === 2 ? c.cert_sha512 : c.cert_sha256;
|
||||
}
|
||||
|
||||
function hexFromBase64(b64: string): string {
|
||||
const bin = atob(b64);
|
||||
let out = "";
|
||||
for (let i = 0; i < bin.length; i++) out += bin.charCodeAt(i).toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Accepts PEM (one or more "-----BEGIN CERTIFICATE-----" blocks) or raw
|
||||
// DER. We compute the hash client-side so the user's certificate file
|
||||
// never leaves the browser.
|
||||
async function onUpload(i: number, ev: Event) {
|
||||
const input = ev.target as HTMLInputElement;
|
||||
const f = input.files?.[0];
|
||||
if (!f) return;
|
||||
try {
|
||||
const buf = new Uint8Array(await f.arrayBuffer());
|
||||
const der = isPEM(buf) ? firstPEMBlock(buf) : buf;
|
||||
const r = records()[i];
|
||||
const target = r.Selector === 1 ? extractSPKI(der) : der;
|
||||
const hash = await hashBytes(target, r.MatchingType);
|
||||
r.Certificate = hash;
|
||||
} catch (e: any) {
|
||||
errorMsg = `Upload failed: ${e.message || e}`;
|
||||
} finally {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function isPEM(b: Uint8Array): boolean {
|
||||
const head = new TextDecoder().decode(b.slice(0, 27));
|
||||
return head.startsWith("-----BEGIN");
|
||||
}
|
||||
function firstPEMBlock(b: Uint8Array): Uint8Array {
|
||||
const text = new TextDecoder().decode(b);
|
||||
const m = text.match(/-----BEGIN [^-]+-----([\s\S]+?)-----END /);
|
||||
if (!m) throw new Error("No PEM block found");
|
||||
const b64 = m[1].replace(/\s+/g, "");
|
||||
const bin = atob(b64);
|
||||
const out = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
// Walks Certificate → TBSCertificate → SubjectPublicKeyInfo. Strict (no
|
||||
// recovery on malformed DER) — the user can always upload again or paste
|
||||
// the hash by hand.
|
||||
function extractSPKI(der: Uint8Array): Uint8Array {
|
||||
const seq = readTLV(der, 0);
|
||||
const tbs = readTLV(der, seq.content);
|
||||
let p = tbs.content;
|
||||
if (der[p] === 0xa0) {
|
||||
// Skip optional [0] version tag
|
||||
p = readTLV(der, p).next;
|
||||
}
|
||||
p = readTLV(der, p).next; // serial
|
||||
p = readTLV(der, p).next; // signature AlgorithmIdentifier
|
||||
p = readTLV(der, p).next; // issuer
|
||||
p = readTLV(der, p).next; // validity
|
||||
p = readTLV(der, p).next; // subject
|
||||
const spki = readTLV(der, p);
|
||||
return der.slice(p, spki.next);
|
||||
}
|
||||
function readTLV(b: Uint8Array, o: number): { content: number; next: number } {
|
||||
o++; // tag
|
||||
let len = b[o++];
|
||||
if (len & 0x80) {
|
||||
const n = len & 0x7f;
|
||||
len = 0;
|
||||
for (let i = 0; i < n; i++) len = (len << 8) | b[o++];
|
||||
}
|
||||
return { content: o, next: o + len };
|
||||
}
|
||||
async function hashBytes(b: Uint8Array, matching: number): Promise<string> {
|
||||
if (matching === 0) {
|
||||
let out = "";
|
||||
for (let i = 0; i < b.length; i++) out += b[i].toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
const algo = matching === 2 ? "SHA-512" : "SHA-256";
|
||||
const h = new Uint8Array(await crypto.subtle.digest(algo, b as BufferSource));
|
||||
let out = "";
|
||||
for (let i = 0; i < h.length; i++) out += h[i].toString(16).padStart(2, "0");
|
||||
return out;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<BasicInput
|
||||
edit
|
||||
index="port"
|
||||
specs={{
|
||||
id: "port",
|
||||
label: "Service Port",
|
||||
description: "Port number where people will establish the connection",
|
||||
type: "uint16",
|
||||
placeholder: "443",
|
||||
}}
|
||||
bind:value={port}
|
||||
/>
|
||||
|
||||
<BasicInput
|
||||
edit
|
||||
index="protocol"
|
||||
specs={{
|
||||
id: "protocol",
|
||||
label: "Protocol",
|
||||
description: "Protocol used to establish the connection",
|
||||
type: "string",
|
||||
choices: ["tcp", "udp"],
|
||||
}}
|
||||
bind:value={protocol}
|
||||
/>
|
||||
|
||||
<TableRecords
|
||||
class="mt-3"
|
||||
dn={fullDn}
|
||||
edit
|
||||
{origin}
|
||||
bind:rrs={(value["tlsa"] as any)}
|
||||
rrtype="TLSA"
|
||||
>
|
||||
{#snippet header(field: string)}
|
||||
{#if field == "Usage"}
|
||||
Certificate Usage
|
||||
{:else if field == "Selector"}
|
||||
Selector
|
||||
{:else if field == "MatchingType"}
|
||||
Matching Type
|
||||
{:else if field == "Certificate"}
|
||||
Certificate
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#snippet field(idx: number, field: string)}
|
||||
{@const tlsaArray = (value["tlsa"] as any) as dnsRR[]}
|
||||
{#if tlsaArray && tlsaArray[idx]}
|
||||
<RawInput
|
||||
edit
|
||||
index={idx.toString()}
|
||||
specs={{
|
||||
id: field,
|
||||
type: field == "Certificate" ? "string" : "uint16",
|
||||
}}
|
||||
bind:value={tlsaArray[idx][field]}
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-sm-3">
|
||||
<label for="tlsa-port" class="form-label fw-semibold mb-1">Service Port</label>
|
||||
<input
|
||||
id="tlsa-port"
|
||||
class="form-control form-control-sm"
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
bind:value={port}
|
||||
disabled={readonly}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</TableRecords>
|
||||
</div>
|
||||
<div class="col-sm-3">
|
||||
<label for="tlsa-proto" class="form-label fw-semibold mb-1">Protocol</label>
|
||||
<select
|
||||
id="tlsa-proto"
|
||||
class="form-select form-select-sm"
|
||||
bind:value={protocol}
|
||||
disabled={readonly}
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-sm-6 text-muted">
|
||||
<small>TLSA owner: <code class="bg-light px-1 rounded">{fullDn}.{dn || origin.domain}</code></small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMsg}
|
||||
<div class="alert alert-danger py-2 mb-0" role="alert">{errorMsg}</div>
|
||||
{/if}
|
||||
|
||||
{#each records() as rec, i}
|
||||
<fieldset class="border rounded p-3">
|
||||
<legend class="float-none w-auto px-2 fs-6 text-secondary">Record #{i + 1}</legend>
|
||||
|
||||
<div class="mb-3" style="max-width: 28rem;">
|
||||
<label for="tlsa-preset-{i}" class="form-label fw-semibold mb-1">Preset</label>
|
||||
<select
|
||||
id="tlsa-preset-{i}"
|
||||
class="form-select form-select-sm"
|
||||
disabled={readonly}
|
||||
onchange={(e) => applyPreset(i, (e.currentTarget as HTMLSelectElement).value)}
|
||||
>
|
||||
<option value="">— Choose a preset —</option>
|
||||
{#each PRESETS as p}
|
||||
<option value={p.id}>{p.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="tlsa-usage-{i}" class="form-label fw-semibold mb-1">Certificate usage</label>
|
||||
<select
|
||||
id="tlsa-usage-{i}"
|
||||
class="form-select form-select-sm"
|
||||
bind:value={rec.Usage}
|
||||
disabled={readonly}
|
||||
>
|
||||
{#each USAGE as u}
|
||||
<option value={u.v}>{u.v} — {u.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<small class="form-text text-muted">{USAGE.find((x) => x.v === rec.Usage)?.hint || ""}</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="tlsa-sel-{i}" class="form-label fw-semibold mb-1">Selector</label>
|
||||
<select
|
||||
id="tlsa-sel-{i}"
|
||||
class="form-select form-select-sm"
|
||||
bind:value={rec.Selector}
|
||||
disabled={readonly}
|
||||
>
|
||||
{#each SELECTOR as s}
|
||||
<option value={s.v}>{s.v} — {s.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<small class="form-text text-muted">{SELECTOR.find((x) => x.v === rec.Selector)?.hint || ""}</small>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label for="tlsa-mt-{i}" class="form-label fw-semibold mb-1">Matching type</label>
|
||||
<select
|
||||
id="tlsa-mt-{i}"
|
||||
class="form-select form-select-sm"
|
||||
bind:value={rec.MatchingType}
|
||||
disabled={readonly}
|
||||
>
|
||||
{#each MATCHING as m}
|
||||
<option value={m.v}>{m.v} — {m.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<small class="form-text text-muted">{MATCHING.find((x) => x.v === rec.MatchingType)?.hint || ""}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<label for="tlsa-cert-{i}" class="form-label fw-semibold mb-1">Certificate data (hex)</label>
|
||||
<textarea
|
||||
id="tlsa-cert-{i}"
|
||||
class="form-control font-monospace small"
|
||||
rows="3"
|
||||
bind:value={rec.Certificate}
|
||||
disabled={readonly}
|
||||
spellcheck="false"
|
||||
placeholder="Paste a hex-encoded hash, or use the buttons below to compute it."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if !readonly}
|
||||
<div class="d-flex flex-wrap gap-2 mt-3 align-items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
onclick={() => fetchLive(i)}
|
||||
disabled={fetching !== null}
|
||||
>
|
||||
{fetching === i ? "Fetching…" : "Fetch from live server"}
|
||||
</button>
|
||||
<label class="btn btn-sm btn-outline-secondary mb-0">
|
||||
<input
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer,.der"
|
||||
onchange={(e) => onUpload(i, e)}
|
||||
hidden
|
||||
/>
|
||||
Upload certificate (PEM/DER)
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-danger ms-auto"
|
||||
onclick={() => removeRecord(i)}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
{/each}
|
||||
|
||||
{#if !readonly}
|
||||
<button type="button" class="btn btn-sm btn-outline-primary align-self-start" onclick={addRecord}>
|
||||
+ Add TLSA record
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue