Compare commits

...

6 commits

Author SHA1 Message Date
3d3819d3f9 Add TLSA checker
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-27 09:48:48 +07:00
bebb58beaf web: revamp TLSA editor. Able to import certificate and fetch through API current chain to auto-fill 2026-04-27 09:48:48 +07:00
a867ff92e9 api: New route to retrieve current TLS certificate chain 2026-04-27 09:48:48 +07:00
fdf9d88383 Add TLS checker
All checks were successful
continuous-integration/drone/push Build is passing
2026-04-27 09:47:50 +07:00
0ee552a35b Add per-checker remote address CLI flags
Register one -checker-<id>-remote-address flag per registered checker,
allowing operators to delegate a checker's observation collection to a
remote HTTP service at startup. When set, the CLI/config value wins
over any per-checker "endpoint" AdminOpt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 09:47:33 +07:00
2a1ba01940 Bump checker-sdk to v1.5.0 2026-04-27 09:47:32 +07:00
16 changed files with 683 additions and 106 deletions

34
checkers/dane.go Normal file
View 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())
}

View file

@ -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())
}

View file

@ -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())
}

View file

@ -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
View 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())
}

View file

@ -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
View file

@ -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
View file

@ -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=

View 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),
})
}

View file

@ -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)

View file

@ -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.

View file

@ -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
}

View file

@ -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
}

View file

@ -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))
}

View file

@ -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.

View file

@ -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>