Skip to content

Commit 04140d0

Browse files
authored
Use a unix socket to talk to pkimetal (#8715)
To minimize supply chain risk, we want to run pkimetal with `networking: none`. Support using a Unix socket to talk to pkimetal. I've pulled the pkimetal client out of the lint_cert into a shared package to make it more obvious what's shared code between the crl and cert lint. Fixes #8530 Coauthored with Claude.
1 parent 491e1e9 commit 04140d0

9 files changed

Lines changed: 204 additions & 126 deletions

File tree

docker-compose.yml

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ services:
2121
- .:/boulder:cached
2222
- ./.gocache:/root/.cache/go-build:cached
2323
- ./test/certs/.softhsm-tokens/:/var/lib/softhsm/tokens/:cached
24+
- pkimetal-socket:/var/run/pkimetal
2425
networks:
2526
bouldernet:
2627
ipv4_address: 10.77.77.77
@@ -145,8 +146,10 @@ services:
145146

146147
bpkimetal:
147148
image: ghcr.io/pkimetal/pkimetal:v1.41.0
148-
networks:
149-
- bouldernet
149+
volumes:
150+
- pkimetal-socket:/var/run/pkimetal
151+
- ./test/pkimetal-config.yaml:/config/config.yaml:ro
152+
network_mode: none
150153

151154
bvitess:
152155
# The `letsencrypt/boulder-vtcomboserver:latest` tag is automatically built
@@ -181,6 +184,17 @@ services:
181184
aliases:
182185
- boulder-vitess
183186

187+
volumes:
188+
# Shared between bpkimetal (which listens on a unix socket here) and any
189+
# boulder container that needs to reach pkimetal. Owned by uid 1001 so
190+
# the default pkimetal user in the container can create the socket.
191+
pkimetal-socket:
192+
driver: local
193+
driver_opts:
194+
type: tmpfs
195+
device: tmpfs
196+
o: "uid=1001,gid=1001,mode=0755"
197+
184198
networks:
185199
# This network represents the data-center internal network. It is used for
186200
# boulder services and their infrastructure, such as consul, mariadb, and

linter/lints/rfc/lint_cert_via_pkimetal.go

Lines changed: 7 additions & 113 deletions
Original file line numberDiff line numberDiff line change
@@ -1,121 +1,15 @@
11
package rfc
22

33
import (
4-
"context"
5-
"encoding/base64"
6-
"encoding/json"
7-
"fmt"
8-
"io"
9-
"net/http"
10-
"net/url"
11-
"slices"
12-
"strings"
13-
"time"
14-
154
"github.com/zmap/zcrypto/x509"
165
"github.com/zmap/zlint/v3/lint"
176
"github.com/zmap/zlint/v3/util"
18-
)
19-
20-
// PKIMetalConfig and its execute method provide a shared basis for linting
21-
// both certs and CRLs using PKIMetal.
22-
type PKIMetalConfig struct {
23-
Addr string `toml:"addr" comment:"The address where a pkilint REST API can be reached."`
24-
Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."`
25-
Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."`
26-
IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."`
27-
}
28-
29-
func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResult, error) {
30-
timeout := pkim.Timeout
31-
if timeout == 0 {
32-
timeout = 100 * time.Millisecond
33-
}
34-
35-
ctx, cancel := context.WithTimeout(context.Background(), timeout)
36-
defer cancel()
37-
38-
apiURL, err := url.JoinPath(pkim.Addr, endpoint)
39-
if err != nil {
40-
return nil, fmt.Errorf("constructing pkimetal url: %w", err)
41-
}
42-
43-
// reqForm matches PKIMetal's documented form-urlencoded request format. It
44-
// does not include the "profile" field, as its default value ("autodetect")
45-
// is good for our purposes.
46-
// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194
47-
reqForm := url.Values{}
48-
reqForm.Set("b64input", base64.StdEncoding.EncodeToString(der))
49-
reqForm.Set("severity", pkim.Severity)
50-
reqForm.Set("format", "json")
51-
52-
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(reqForm.Encode()))
53-
if err != nil {
54-
return nil, fmt.Errorf("creating pkimetal request: %w", err)
55-
}
56-
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
57-
req.Header.Add("Accept", "application/json")
58-
59-
resp, err := http.DefaultClient.Do(req)
60-
if err != nil {
61-
return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout)
62-
}
63-
defer resp.Body.Close()
64-
65-
if resp.StatusCode != http.StatusOK {
66-
return nil, fmt.Errorf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status)
67-
}
687

69-
resJSON, err := io.ReadAll(resp.Body)
70-
if err != nil {
71-
return nil, fmt.Errorf("reading response from pkimetal API: %s", err)
72-
}
73-
74-
// finding matches the repeated portion of PKIMetal's documented JSON response.
75-
// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221
76-
type finding struct {
77-
Linter string `json:"linter"`
78-
Finding string `json:"finding"`
79-
Severity string `json:"severity"`
80-
Code string `json:"code"`
81-
Field string `json:"field"`
82-
}
83-
84-
var res []finding
85-
err = json.Unmarshal(resJSON, &res)
86-
if err != nil {
87-
return nil, fmt.Errorf("parsing response from pkimetal API: %s", err)
88-
}
89-
90-
var findings []string
91-
for _, finding := range res {
92-
var id string
93-
if finding.Code != "" {
94-
id = fmt.Sprintf("%s:%s", finding.Linter, finding.Code)
95-
} else {
96-
id = fmt.Sprintf("%s:%s", finding.Linter, strings.ReplaceAll(strings.ToLower(finding.Finding), " ", "_"))
97-
}
98-
if slices.Contains(pkim.IgnoreLints, id) {
99-
continue
100-
}
101-
desc := fmt.Sprintf("%s from %s: %s", finding.Severity, id, finding.Finding)
102-
findings = append(findings, desc)
103-
}
104-
105-
if len(findings) != 0 {
106-
// Group the findings by severity, for human readers.
107-
slices.Sort(findings)
108-
return &lint.LintResult{
109-
Status: lint.Error,
110-
Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")),
111-
}, nil
112-
}
113-
114-
return &lint.LintResult{Status: lint.Pass}, nil
115-
}
8+
"github.com/letsencrypt/boulder/linter/pkimetal"
9+
)
11610

11711
type certViaPKIMetal struct {
118-
PKIMetalConfig
12+
pkimetal.Client
11913
}
12014

12115
func init() {
@@ -136,17 +30,17 @@ func NewCertViaPKIMetal() lint.CertificateLintInterface {
13630
}
13731

13832
func (l *certViaPKIMetal) Configure() any {
139-
return l
33+
return &l.Config
14034
}
14135

14236
func (l *certViaPKIMetal) CheckApplies(c *x509.Certificate) bool {
14337
// This lint applies to all certificates issued by Boulder, as long as it has
144-
// been configured with an address to reach out to. If not, skip it.
145-
return l.Addr != ""
38+
// been configured with a socket to reach out to. If not, skip it.
39+
return l.Enabled()
14640
}
14741

14842
func (l *certViaPKIMetal) Execute(c *x509.Certificate) *lint.LintResult {
149-
res, err := l.execute("lintcert", c.Raw)
43+
res, err := l.Client.Execute("lintcert", c.Raw)
15044
if err != nil {
15145
return &lint.LintResult{
15246
Status: lint.Error,

linter/lints/rfc/lint_crl_via_pkimetal.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"github.com/zmap/zcrypto/x509"
55
"github.com/zmap/zlint/v3/lint"
66
"github.com/zmap/zlint/v3/util"
7+
8+
"github.com/letsencrypt/boulder/linter/pkimetal"
79
)
810

911
type crlViaPKIMetal struct {
10-
PKIMetalConfig
12+
pkimetal.Client
1113
}
1214

1315
func init() {
@@ -28,17 +30,17 @@ func NewCrlViaPKIMetal() lint.RevocationListLintInterface {
2830
}
2931

3032
func (l *crlViaPKIMetal) Configure() any {
31-
return l
33+
return &l.Config
3234
}
3335

3436
func (l *crlViaPKIMetal) CheckApplies(c *x509.RevocationList) bool {
3537
// This lint applies to all CRLs issued by Boulder, as long as it has
36-
// been configured with an address to reach out to. If not, skip it.
37-
return l.Addr != ""
38+
// been configured with a socket to reach out to. If not, skip it.
39+
return l.Enabled()
3840
}
3941

4042
func (l *crlViaPKIMetal) Execute(c *x509.RevocationList) *lint.LintResult {
41-
res, err := l.execute("lintcrl", c.Raw)
43+
res, err := l.Client.Execute("lintcrl", c.Raw)
4244
if err != nil {
4345
return &lint.LintResult{
4446
Status: lint.Error,

linter/pkimetal/client.go

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
package pkimetal
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net"
10+
"net/http"
11+
"net/url"
12+
"slices"
13+
"strings"
14+
"sync"
15+
"time"
16+
17+
"github.com/zmap/zlint/v3/lint"
18+
)
19+
20+
// Config holds configuration for linting both certs and CRLs using PKIMetal.
21+
// Zlint will deserialize toml here.
22+
type Config struct {
23+
Socket string `toml:"socket" comment:"Path to a unix socket where pkimetal is listening."`
24+
Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."`
25+
Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."`
26+
IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."`
27+
}
28+
29+
type Client struct {
30+
Config
31+
32+
clientOnce sync.Once
33+
httpClient *http.Client
34+
}
35+
36+
// Enabled returns true if the client has a socket configured.
37+
func (pkim *Client) Enabled() bool {
38+
return pkim != nil && pkim.Socket != ""
39+
}
40+
41+
// Execute linting in pkimetal.
42+
func (pkim *Client) Execute(endpoint string, der []byte) (*lint.LintResult, error) {
43+
timeout := pkim.Timeout
44+
if timeout == 0 {
45+
timeout = 100 * time.Millisecond
46+
}
47+
48+
ctx, cancel := context.WithTimeout(context.Background(), timeout)
49+
defer cancel()
50+
51+
// Host is ignored by our unix-socket transport, so any valid base works.
52+
apiURL, err := url.JoinPath("http://pkimetal", endpoint)
53+
if err != nil {
54+
return nil, fmt.Errorf("constructing pkimetal url: %w", err)
55+
}
56+
57+
// reqForm matches PKIMetal's documented form-urlencoded request format. It
58+
// does not include the "profile" field, as its default value ("autodetect")
59+
// is good for our purposes.
60+
// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194
61+
reqForm := url.Values{}
62+
reqForm.Set("b64input", base64.StdEncoding.EncodeToString(der))
63+
reqForm.Set("severity", pkim.Severity)
64+
reqForm.Set("format", "json")
65+
66+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(reqForm.Encode()))
67+
if err != nil {
68+
return nil, fmt.Errorf("creating pkimetal request: %w", err)
69+
}
70+
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
71+
req.Header.Add("Accept", "application/json")
72+
73+
resp, err := pkim.getHTTPClient().Do(req)
74+
if err != nil {
75+
return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout)
76+
}
77+
defer resp.Body.Close()
78+
79+
if resp.StatusCode != http.StatusOK {
80+
return nil, fmt.Errorf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status)
81+
}
82+
83+
resJSON, err := io.ReadAll(resp.Body)
84+
if err != nil {
85+
return nil, fmt.Errorf("reading response from pkimetal API: %s", err)
86+
}
87+
88+
// finding matches the repeated portion of PKIMetal's documented JSON response.
89+
// https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221
90+
type finding struct {
91+
Linter string `json:"linter"`
92+
Finding string `json:"finding"`
93+
Severity string `json:"severity"`
94+
Code string `json:"code"`
95+
Field string `json:"field"`
96+
}
97+
98+
var res []finding
99+
err = json.Unmarshal(resJSON, &res)
100+
if err != nil {
101+
return nil, fmt.Errorf("parsing response from pkimetal API: %s", err)
102+
}
103+
104+
var findings []string
105+
for _, finding := range res {
106+
var id string
107+
if finding.Code != "" {
108+
id = fmt.Sprintf("%s:%s", finding.Linter, finding.Code)
109+
} else {
110+
id = fmt.Sprintf("%s:%s", finding.Linter, strings.ReplaceAll(strings.ToLower(finding.Finding), " ", "_"))
111+
}
112+
if slices.Contains(pkim.IgnoreLints, id) {
113+
continue
114+
}
115+
desc := fmt.Sprintf("%s from %s: %s", finding.Severity, id, finding.Finding)
116+
findings = append(findings, desc)
117+
}
118+
119+
if len(findings) != 0 {
120+
// Group the findings by severity, for human readers.
121+
slices.Sort(findings)
122+
return &lint.LintResult{
123+
Status: lint.Error,
124+
Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")),
125+
}, nil
126+
}
127+
128+
return &lint.LintResult{Status: lint.Pass}, nil
129+
}
130+
131+
func (pkim *Client) getHTTPClient() *http.Client {
132+
// Create an http client on first use, as there's not a great place to do this setup ahead of time.
133+
pkim.clientOnce.Do(func() {
134+
socket := pkim.Socket
135+
transport := http.DefaultTransport.(*http.Transport).Clone()
136+
transport.Proxy = nil
137+
transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) {
138+
var d net.Dialer
139+
return d.DialContext(ctx, "unix", socket)
140+
}
141+
pkim.httpClient = &http.Client{
142+
Transport: transport,
143+
}
144+
})
145+
return pkim.httpClient
146+
}

test/config-next/zlint.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[e_pkimetal_lint_cabf_serverauth_cert]
2-
addr = "http://bpkimetal:8080"
2+
socket = "/var/run/pkimetal/pkimetal.sock"
33
severity = "notice"
44
timeout = 2000000000 # 2 seconds
55
ignore_lints = [
@@ -25,7 +25,7 @@ ignore_lints = [
2525
]
2626

2727
[e_pkimetal_lint_cabf_serverauth_crl]
28-
addr = "http://bpkimetal:8080"
28+
socket = "/var/run/pkimetal/pkimetal.sock"
2929
severity = "notice"
3030
timeout = 2000000000 # 2 seconds
3131
ignore_lints = []

0 commit comments

Comments
 (0)