Skip to content

Commit 23f7af2

Browse files
committed
api: New route to retrieve current TLS certificate chain
1 parent 6737e33 commit 23f7af2

2 files changed

Lines changed: 141 additions & 0 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// This file is part of the happyDomain (R) project.
2+
// Copyright (c) 2020-2026 happyDomain
3+
// Authors: Pierre-Olivier Mercier, et al.
4+
//
5+
// This program is offered under a commercial and under the AGPL license.
6+
// For commercial licensing, contact us at <contact@happydomain.org>.
7+
//
8+
// For AGPL licensing:
9+
// This program is free software: you can redistribute it and/or modify
10+
// it under the terms of the GNU Affero General Public License as published by
11+
// the Free Software Foundation, either version 3 of the License, or
12+
// (at your option) any later version.
13+
//
14+
// This program is distributed in the hope that it will be useful,
15+
// but WITHOUT ANY WARRANTY; without even the implied warranty of
16+
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17+
// GNU Affero General Public License for more details.
18+
//
19+
// You should have received a copy of the GNU Affero General Public License
20+
// along with this program. If not, see <https://www.gnu.org/licenses/>.
21+
22+
// Package controller exposes the "fetch certificate" endpoint used by the
23+
// TLSA editor to prefill Certificate hashes from a live TLS endpoint.
24+
//
25+
// Scoped to the domain the user owns (DomainHandler middleware + suffix
26+
// check) so it cannot be repurposed as an arbitrary TLS-probing proxy.
27+
package controller
28+
29+
import (
30+
"fmt"
31+
"net"
32+
"net/http"
33+
"strconv"
34+
"strings"
35+
"time"
36+
37+
"github.com/gin-gonic/gin"
38+
39+
tls "git.happydns.org/checker-tls/checker"
40+
"git.happydns.org/happyDomain/internal/api/middleware"
41+
"git.happydns.org/happyDomain/model"
42+
)
43+
44+
const fetchCertificateTimeout = 10 * time.Second
45+
46+
type CertificateController struct{}
47+
48+
func NewCertificateController() *CertificateController {
49+
return &CertificateController{}
50+
}
51+
52+
// fetchCertificateRequest is the editor's selection. Host is the owner
53+
// subdomain (without "_port._proto"); STARTTLS is optional and when empty
54+
// we auto-map a handful of common ports.
55+
type fetchCertificateRequest struct {
56+
Host string `json:"host" binding:"required"`
57+
Port uint16 `json:"port" binding:"required"`
58+
Proto string `json:"proto"`
59+
STARTTLS string `json:"starttls"`
60+
}
61+
62+
// fetchCertificateResponse carries the full chain (leaf first) so the editor
63+
// can offer DANE-EE and DANE-TA hashes side by side.
64+
type fetchCertificateResponse struct {
65+
Endpoint string `json:"endpoint"`
66+
Chain []tls.CertInfo `json:"chain"`
67+
}
68+
69+
// FetchCertificate dials the requested endpoint and returns DANE-friendly
70+
// pre-hashed views of the server's certificate chain.
71+
//
72+
// @Summary Fetch a live certificate for a subdomain
73+
// @Tags domains
74+
// @Accept json
75+
// @Produce json
76+
// @Param domain path string true "Domain identifier"
77+
// @Param body body fetchCertificateRequest true "Endpoint to probe"
78+
// @Success 200 {object} fetchCertificateResponse
79+
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
80+
// @Failure 403 {object} happydns.ErrorResponse "Host not under this domain"
81+
// @Failure 502 {object} happydns.ErrorResponse "Upstream TLS error"
82+
// @Router /domains/{domain}/fetch-certificate [post]
83+
func (cc *CertificateController) FetchCertificate(c *gin.Context) {
84+
var req fetchCertificateRequest
85+
if err := c.ShouldBindJSON(&req); err != nil {
86+
middleware.ErrorResponse(c, http.StatusBadRequest, err)
87+
return
88+
}
89+
if req.Port == 0 {
90+
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("port is required"))
91+
return
92+
}
93+
proto := strings.ToLower(strings.TrimSpace(req.Proto))
94+
if proto == "" {
95+
proto = "tcp"
96+
}
97+
if proto != "tcp" && proto != "udp" {
98+
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("unsupported proto %q", req.Proto))
99+
return
100+
}
101+
102+
// Authorization: the authenticated domain must be a suffix of Host. We
103+
// trust c.Get("domain") (set by DomainHandler), not the client-supplied
104+
// Host, so the endpoint can't double as an arbitrary TLS-probing proxy.
105+
domVal, ok := c.Get("domain")
106+
if !ok {
107+
middleware.ErrorResponse(c, http.StatusForbidden, fmt.Errorf("domain context missing"))
108+
return
109+
}
110+
dom, ok := domVal.(*happydns.Domain)
111+
if !ok {
112+
middleware.ErrorResponse(c, http.StatusInternalServerError, fmt.Errorf("unexpected domain context type"))
113+
return
114+
}
115+
host := strings.TrimSpace(req.Host)
116+
if !strings.HasSuffix(host, dom.DomainName) {
117+
middleware.ErrorResponse(c, http.StatusForbidden, fmt.Errorf("host %q is not under %q", host, dom.DomainName))
118+
return
119+
}
120+
121+
host = strings.TrimSuffix(host, ".")
122+
123+
starttls := req.STARTTLS
124+
if starttls == "" {
125+
starttls = tls.AutoSTARTTLS(req.Port)
126+
}
127+
128+
chain, err := tls.FetchChain(c.Request.Context(), host, req.Port, starttls, fetchCertificateTimeout)
129+
if err != nil {
130+
middleware.ErrorResponse(c, http.StatusBadGateway, err)
131+
return
132+
}
133+
134+
c.JSON(http.StatusOK, fetchCertificateResponse{
135+
Endpoint: net.JoinHostPort(host, strconv.FormatUint(uint64(req.Port), 10)),
136+
Chain: tls.BuildChain(chain),
137+
})
138+
}

internal/api/route/domain.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ func DeclareDomainRoutes(
6767
apiDomainsRoutes.POST("/zone", dc.ImportZone)
6868
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
6969

70+
certCtrl := controller.NewCertificateController()
71+
apiDomainsRoutes.POST("/fetch-certificate", certCtrl.FetchCertificate)
72+
7073
// Mount domain-scoped checker routes.
7174
if cc != nil {
7275
DeclareScopedCheckerRoutes(apiDomainsRoutes, cc)

0 commit comments

Comments
 (0)