From 0b3fce5a2ddecf64b711bfe46311fc259b09d6c7 Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 03:07:21 +0000 Subject: [PATCH 1/9] test: bump pkimetal to v1.41.0 and ignore new ctlint warning Updates the integration-test pkimetal sidecar from v1.20.0 to v1.41.0. Newer ctlint emits a warning for every certificate issued by an issuer that isn't in CCADB, which fires for all of our test certificates; ignore it in both zlint configs. Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 2 +- test/config-next/zlint.toml | 5 ++++- test/config/zlint.toml | 3 +++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index d97c1b8b906..cba62ac154d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -144,7 +144,7 @@ services: - bouldernet bpkimetal: - image: ghcr.io/pkimetal/pkimetal:v1.20.0 + image: ghcr.io/pkimetal/pkimetal:v1.41.0 networks: - bouldernet diff --git a/test/config-next/zlint.toml b/test/config-next/zlint.toml index b80dad07803..e359cb58fa8 100644 --- a/test/config-next/zlint.toml +++ b/test/config-next/zlint.toml @@ -18,7 +18,10 @@ ignore_lints = [ # Some linters continue to complain about the lack of an AIA OCSP URI, even # when a CRLDP is present. "certlint:br_certificates_must_include_an_http_url_of_the_ocsp_responder", - "x509lint:no_ocsp_over_http" + "x509lint:no_ocsp_over_http", + # ctlint requires CCADB data to verify SCT signatures; our test issuers are + # not in CCADB so this warning fires for every issued certificate. + "ctlint:cannot_verify_sct_signature_without_issuer_spki,_which_could_not_be_found_in_the_available_ccadb_data", ] [e_pkimetal_lint_cabf_serverauth_crl] diff --git a/test/config/zlint.toml b/test/config/zlint.toml index b044d1d3436..3c9709a97e6 100644 --- a/test/config/zlint.toml +++ b/test/config/zlint.toml @@ -15,6 +15,9 @@ ignore_lints = [ # issued under the "classic" profile, but have removed it from our "tlsserver" # and "shortlived" profiles. "pkilint:cabf.serverauth.subscriber_rsa_digitalsignature_and_keyencipherment_present", + # ctlint requires CCADB data to verify SCT signatures; our test issuers are + # not in CCADB so this warning fires for every issued certificate. + "ctlint:cannot_verify_sct_signature_without_issuer_spki,_which_could_not_be_found_in_the_available_ccadb_data", ] [e_pkimetal_lint_cabf_serverauth_crl] From 7b649dbccf540befe8276956645471cec6adc099 Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 03:07:43 +0000 Subject: [PATCH 2/9] linter: support reaching pkimetal over a unix socket Adds a Socket option to PKIMetalConfig that, when set, makes the linter dial pkimetal over a unix domain socket via a custom http.Transport. This lets boulder-ca and cert-checker run pkimetal as a sidecar with networking disabled, reducing the production attack surface. Reconfigures the integration-test pkimetal sidecar to exercise this: runs it with network_mode: none, listening only on a shared-volume unix socket configured via /config/config.yaml (env vars don't bind keys without viper defaults, and webserverPath has none). Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 18 ++++++++-- linter/lints/rfc/lint_cert_via_pkimetal.go | 41 +++++++++++++++++++--- linter/lints/rfc/lint_crl_via_pkimetal.go | 4 +-- test/config-next/zlint.toml | 4 +-- test/config/zlint.toml | 4 +-- test/entrypoint.sh | 4 +-- test/pkimetal-config.yaml | 7 ++++ test/wait-for-socket.sh | 18 ++++++++++ 8 files changed, 85 insertions(+), 15 deletions(-) create mode 100644 test/pkimetal-config.yaml create mode 100755 test/wait-for-socket.sh diff --git a/docker-compose.yml b/docker-compose.yml index cba62ac154d..658f8eb94e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -21,6 +21,7 @@ services: - .:/boulder:cached - ./.gocache:/root/.cache/go-build:cached - ./test/certs/.softhsm-tokens/:/var/lib/softhsm/tokens/:cached + - pkimetal-socket:/var/run/pkimetal networks: bouldernet: ipv4_address: 10.77.77.77 @@ -145,8 +146,16 @@ services: bpkimetal: image: ghcr.io/pkimetal/pkimetal:v1.41.0 - networks: - - bouldernet + # Run as root so pkimetal can create the socket in the shared named + # volume, which docker initializes root-owned. + user: root + volumes: + - pkimetal-socket:/var/run/pkimetal + # pkimetal only honors the unix socket settings via its config file + # (env vars work only for keys with viper defaults, and webserverPath + # has none). + - ./test/pkimetal-config.yaml:/config/config.yaml:ro + network_mode: none bvitess: # The `letsencrypt/boulder-vtcomboserver:latest` tag is automatically built @@ -181,6 +190,11 @@ services: aliases: - boulder-vitess +volumes: + # Shared between bpkimetal (which listens on a unix socket here) and any + # boulder container that needs to reach pkimetal. + pkimetal-socket: + networks: # This network represents the data-center internal network. It is used for # boulder services and their infrastructure, such as consul, mariadb, and diff --git a/linter/lints/rfc/lint_cert_via_pkimetal.go b/linter/lints/rfc/lint_cert_via_pkimetal.go index 31fc08d8135..aee44e23a00 100644 --- a/linter/lints/rfc/lint_cert_via_pkimetal.go +++ b/linter/lints/rfc/lint_cert_via_pkimetal.go @@ -6,10 +6,12 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "net/url" "slices" "strings" + "sync" "time" "github.com/zmap/zcrypto/x509" @@ -20,10 +22,33 @@ import ( // PKIMetalConfig and its execute method provide a shared basis for linting // both certs and CRLs using PKIMetal. type PKIMetalConfig struct { - Addr string `toml:"addr" comment:"The address where a pkilint REST API can be reached."` + Addr string `toml:"addr" comment:"The address where a pkilint REST API can be reached. Ignored if socket is set."` + Socket string `toml:"socket" comment:"Path to a unix socket where a pkilint REST API is listening. Takes precedence over addr."` Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."` Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."` IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` + + clientOnce sync.Once + client *http.Client +} + +func (pkim *PKIMetalConfig) httpClient() *http.Client { + pkim.clientOnce.Do(func() { + if pkim.Socket == "" { + pkim.client = http.DefaultClient + return + } + socket := pkim.Socket + pkim.client = &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + }, + }, + } + }) + return pkim.client } func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResult, error) { @@ -35,7 +60,13 @@ func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResu ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - apiURL, err := url.JoinPath(pkim.Addr, endpoint) + // When dialing a unix socket, the URL host is ignored by our custom + // transport, so any syntactically valid base works. + base := pkim.Addr + if pkim.Socket != "" { + base = "http://pkimetal" + } + apiURL, err := url.JoinPath(base, endpoint) if err != nil { return nil, fmt.Errorf("constructing pkimetal url: %w", err) } @@ -56,7 +87,7 @@ func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResu req.Header.Add("Content-Type", "application/x-www-form-urlencoded") req.Header.Add("Accept", "application/json") - resp, err := http.DefaultClient.Do(req) + resp, err := pkim.httpClient().Do(req) if err != nil { return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout) } @@ -141,8 +172,8 @@ func (l *certViaPKIMetal) Configure() any { func (l *certViaPKIMetal) CheckApplies(c *x509.Certificate) bool { // This lint applies to all certificates issued by Boulder, as long as it has - // been configured with an address to reach out to. If not, skip it. - return l.Addr != "" + // been configured with an address or socket to reach out to. If not, skip it. + return l.Addr != "" || l.Socket != "" } func (l *certViaPKIMetal) Execute(c *x509.Certificate) *lint.LintResult { diff --git a/linter/lints/rfc/lint_crl_via_pkimetal.go b/linter/lints/rfc/lint_crl_via_pkimetal.go index c927eebe525..73ef163b81f 100644 --- a/linter/lints/rfc/lint_crl_via_pkimetal.go +++ b/linter/lints/rfc/lint_crl_via_pkimetal.go @@ -33,8 +33,8 @@ func (l *crlViaPKIMetal) Configure() any { func (l *crlViaPKIMetal) CheckApplies(c *x509.RevocationList) bool { // This lint applies to all CRLs issued by Boulder, as long as it has - // been configured with an address to reach out to. If not, skip it. - return l.Addr != "" + // been configured with an address or socket to reach out to. If not, skip it. + return l.Addr != "" || l.Socket != "" } func (l *crlViaPKIMetal) Execute(c *x509.RevocationList) *lint.LintResult { diff --git a/test/config-next/zlint.toml b/test/config-next/zlint.toml index e359cb58fa8..e624c4a394b 100644 --- a/test/config-next/zlint.toml +++ b/test/config-next/zlint.toml @@ -1,5 +1,5 @@ [e_pkimetal_lint_cabf_serverauth_cert] -addr = "http://bpkimetal:8080" +socket = "/var/run/pkimetal/pkimetal.sock" severity = "notice" timeout = 2000000000 # 2 seconds ignore_lints = [ @@ -25,7 +25,7 @@ ignore_lints = [ ] [e_pkimetal_lint_cabf_serverauth_crl] -addr = "http://bpkimetal:8080" +socket = "/var/run/pkimetal/pkimetal.sock" severity = "notice" timeout = 2000000000 # 2 seconds ignore_lints = [] diff --git a/test/config/zlint.toml b/test/config/zlint.toml index 3c9709a97e6..325da361547 100644 --- a/test/config/zlint.toml +++ b/test/config/zlint.toml @@ -1,5 +1,5 @@ [e_pkimetal_lint_cabf_serverauth_cert] -addr = "http://bpkimetal:8080" +socket = "/var/run/pkimetal/pkimetal.sock" severity = "notice" timeout = 2000000000 # 2 seconds ignore_lints = [ @@ -21,7 +21,7 @@ ignore_lints = [ ] [e_pkimetal_lint_cabf_serverauth_crl] -addr = "http://bpkimetal:8080" +socket = "/var/run/pkimetal/pkimetal.sock" severity = "notice" timeout = 2000000000 # 2 seconds ignore_lints = [] diff --git a/test/entrypoint.sh b/test/entrypoint.sh index f24758e2afe..f772341b4ca 100755 --- a/test/entrypoint.sh +++ b/test/entrypoint.sh @@ -46,8 +46,8 @@ configure_database_endpoints ./test/wait-for-it.sh boulder-mariadb 3306 ./test/wait-for-it.sh boulder-proxysql 6033 -# make sure we can reach pkilint -./test/wait-for-it.sh bpkimetal 8080 +# make sure pkimetal's unix socket is ready +./test/wait-for-socket.sh /var/run/pkimetal/pkimetal.sock if [[ $# -eq 0 ]]; then exec python3 ./start.py diff --git a/test/pkimetal-config.yaml b/test/pkimetal-config.yaml new file mode 100644 index 00000000000..10b464974e9 --- /dev/null +++ b/test/pkimetal-config.yaml @@ -0,0 +1,7 @@ +server: + # Disable the TCP webserver; we only listen on a unix socket so callers + # do not need any network access to reach pkimetal. + webserverPort: 0 + webserverPath: /var/run/pkimetal/pkimetal.sock + # 0o666: callers in any user namespace can connect to the socket. + socketPermissions: 0o666 diff --git a/test/wait-for-socket.sh b/test/wait-for-socket.sh new file mode 100755 index 00000000000..7f6422f2cde --- /dev/null +++ b/test/wait-for-socket.sh @@ -0,0 +1,18 @@ +#!/bin/bash + +set -e -u + +socket="${1}" +max_tries=40 + +for n in $(seq 1 "${max_tries}"); do + if [ -S "${socket}" ]; then + echo "Socket ${socket} is ready" + exit 0 + fi + echo "$(date) - still waiting for socket ${socket}" + sleep 1 +done + +echo "timed out waiting for socket ${socket}" +exit 1 From b48b473cce6b75683700c707c0df34ddacba50fb Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 03:18:54 +0000 Subject: [PATCH 3/9] Update some comments --- docker-compose.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 658f8eb94e9..91222a98ada 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -148,12 +148,10 @@ services: image: ghcr.io/pkimetal/pkimetal:v1.41.0 # Run as root so pkimetal can create the socket in the shared named # volume, which docker initializes root-owned. + # This is not something we'd want to do in prod. user: root volumes: - pkimetal-socket:/var/run/pkimetal - # pkimetal only honors the unix socket settings via its config file - # (env vars work only for keys with viper defaults, and webserverPath - # has none). - ./test/pkimetal-config.yaml:/config/config.yaml:ro network_mode: none From bf624239668e13b59a1a5a67eabc7be261da26a3 Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 03:24:57 +0000 Subject: [PATCH 4/9] linter: drop Addr option from PKIMetalConfig Socket is now the only way to configure PKIMetal, and the preceding commit was the sole place we still used Addr. Drop the field, its conditional paths in httpClient and execute, and the Addr-half of CheckApplies. Co-Authored-By: Claude Opus 4.6 (1M context) --- linter/lints/rfc/lint_cert_via_pkimetal.go | 20 +++++--------------- linter/lints/rfc/lint_crl_via_pkimetal.go | 4 ++-- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/linter/lints/rfc/lint_cert_via_pkimetal.go b/linter/lints/rfc/lint_cert_via_pkimetal.go index aee44e23a00..1d12925d081 100644 --- a/linter/lints/rfc/lint_cert_via_pkimetal.go +++ b/linter/lints/rfc/lint_cert_via_pkimetal.go @@ -22,8 +22,7 @@ import ( // PKIMetalConfig and its execute method provide a shared basis for linting // both certs and CRLs using PKIMetal. type PKIMetalConfig struct { - Addr string `toml:"addr" comment:"The address where a pkilint REST API can be reached. Ignored if socket is set."` - Socket string `toml:"socket" comment:"Path to a unix socket where a pkilint REST API is listening. Takes precedence over addr."` + Socket string `toml:"socket" comment:"Path to a unix socket where a pkilint REST API is listening."` Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."` Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."` IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` @@ -34,10 +33,6 @@ type PKIMetalConfig struct { func (pkim *PKIMetalConfig) httpClient() *http.Client { pkim.clientOnce.Do(func() { - if pkim.Socket == "" { - pkim.client = http.DefaultClient - return - } socket := pkim.Socket pkim.client = &http.Client{ Transport: &http.Transport{ @@ -60,13 +55,8 @@ func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResu ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() - // When dialing a unix socket, the URL host is ignored by our custom - // transport, so any syntactically valid base works. - base := pkim.Addr - if pkim.Socket != "" { - base = "http://pkimetal" - } - apiURL, err := url.JoinPath(base, endpoint) + // Host is ignored by our unix-socket transport, so any valid base works. + apiURL, err := url.JoinPath("http://pkimetal", endpoint) if err != nil { return nil, fmt.Errorf("constructing pkimetal url: %w", err) } @@ -172,8 +162,8 @@ func (l *certViaPKIMetal) Configure() any { func (l *certViaPKIMetal) CheckApplies(c *x509.Certificate) bool { // This lint applies to all certificates issued by Boulder, as long as it has - // been configured with an address or socket to reach out to. If not, skip it. - return l.Addr != "" || l.Socket != "" + // been configured with a socket to reach out to. If not, skip it. + return l.Socket != "" } func (l *certViaPKIMetal) Execute(c *x509.Certificate) *lint.LintResult { diff --git a/linter/lints/rfc/lint_crl_via_pkimetal.go b/linter/lints/rfc/lint_crl_via_pkimetal.go index 73ef163b81f..6ebf74b3383 100644 --- a/linter/lints/rfc/lint_crl_via_pkimetal.go +++ b/linter/lints/rfc/lint_crl_via_pkimetal.go @@ -33,8 +33,8 @@ func (l *crlViaPKIMetal) Configure() any { func (l *crlViaPKIMetal) CheckApplies(c *x509.RevocationList) bool { // This lint applies to all CRLs issued by Boulder, as long as it has - // been configured with an address or socket to reach out to. If not, skip it. - return l.Addr != "" || l.Socket != "" + // been configured with a socket to reach out to. If not, skip it. + return l.Socket != "" } func (l *crlViaPKIMetal) Execute(c *x509.RevocationList) *lint.LintResult { From d912f3b2b3e05dcb21d7fbb1c5c15ff01aae365b Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 03:43:41 +0000 Subject: [PATCH 5/9] test: let pkimetal run as its default user Set driver_opts on the pkimetal-socket named volume so the tmpfs it backs is initialized with uid=1001 ownership, matching the pkimetal user in the image. pkimetal can then create the socket without needing the container to run as root, and we can drop the SocketPermissions override and fall back to pkimetal's 0o600 default (the boulder container connects as root, which bypasses mode checks anyway). Co-Authored-By: Claude Opus 4.6 (1M context) --- docker-compose.yml | 13 ++++++++----- test/pkimetal-config.yaml | 2 -- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 91222a98ada..4662c732f4b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -146,10 +146,6 @@ services: bpkimetal: image: ghcr.io/pkimetal/pkimetal:v1.41.0 - # Run as root so pkimetal can create the socket in the shared named - # volume, which docker initializes root-owned. - # This is not something we'd want to do in prod. - user: root volumes: - pkimetal-socket:/var/run/pkimetal - ./test/pkimetal-config.yaml:/config/config.yaml:ro @@ -190,8 +186,15 @@ services: volumes: # Shared between bpkimetal (which listens on a unix socket here) and any - # boulder container that needs to reach pkimetal. + # boulder container that needs to reach pkimetal. Owned by uid 1001 so + # the default pkimetal user can create the socket without us running the + # container as root. pkimetal-socket: + driver: local + driver_opts: + type: tmpfs + device: tmpfs + o: "uid=1001,gid=1001,mode=0755" networks: # This network represents the data-center internal network. It is used for diff --git a/test/pkimetal-config.yaml b/test/pkimetal-config.yaml index 10b464974e9..93a1eface57 100644 --- a/test/pkimetal-config.yaml +++ b/test/pkimetal-config.yaml @@ -3,5 +3,3 @@ server: # do not need any network access to reach pkimetal. webserverPort: 0 webserverPath: /var/run/pkimetal/pkimetal.sock - # 0o666: callers in any user namespace can connect to the socket. - socketPermissions: 0o666 From 9f5c6d39d72734da219d37486bebdc98f4097bc1 Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 03:47:32 +0000 Subject: [PATCH 6/9] fix claude blabber --- docker-compose.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4662c732f4b..18ad6c06db9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -187,8 +187,7 @@ services: volumes: # Shared between bpkimetal (which listens on a unix socket here) and any # boulder container that needs to reach pkimetal. Owned by uid 1001 so - # the default pkimetal user can create the socket without us running the - # container as root. + # the default pkimetal user in the container can create the socket. pkimetal-socket: driver: local driver_opts: From 747fce496f8086e0577d885c56a9b84ba042b377 Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Mon, 13 Apr 2026 23:59:55 -0400 Subject: [PATCH 7/9] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- linter/lints/rfc/lint_cert_via_pkimetal.go | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/linter/lints/rfc/lint_cert_via_pkimetal.go b/linter/lints/rfc/lint_cert_via_pkimetal.go index 1d12925d081..477996fd3b3 100644 --- a/linter/lints/rfc/lint_cert_via_pkimetal.go +++ b/linter/lints/rfc/lint_cert_via_pkimetal.go @@ -22,7 +22,7 @@ import ( // PKIMetalConfig and its execute method provide a shared basis for linting // both certs and CRLs using PKIMetal. type PKIMetalConfig struct { - Socket string `toml:"socket" comment:"Path to a unix socket where a pkilint REST API is listening."` + Socket string `toml:"socket" comment:"Path to a unix socket where pkimetal is listening."` Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."` Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."` IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` @@ -34,13 +34,14 @@ type PKIMetalConfig struct { func (pkim *PKIMetalConfig) httpClient() *http.Client { pkim.clientOnce.Do(func() { socket := pkim.Socket + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + } pkim.client = &http.Client{ - Transport: &http.Transport{ - DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { - var d net.Dialer - return d.DialContext(ctx, "unix", socket) - }, - }, + Transport: transport, } }) return pkim.client From ab490d115736639c5117ad8e87027eab0ec5894d Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Tue, 14 Apr 2026 21:15:14 -0400 Subject: [PATCH 8/9] clarify comment --- test/pkimetal-config.yaml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/pkimetal-config.yaml b/test/pkimetal-config.yaml index 93a1eface57..c662c38feb1 100644 --- a/test/pkimetal-config.yaml +++ b/test/pkimetal-config.yaml @@ -1,5 +1,4 @@ server: - # Disable the TCP webserver; we only listen on a unix socket so callers - # do not need any network access to reach pkimetal. + # Disable the TCP webserver and only listen on a unix socket webserverPort: 0 webserverPath: /var/run/pkimetal/pkimetal.sock From 4f97ffa5478caadb4945e9d68d598a540922598a Mon Sep 17 00:00:00 2001 From: Matthew McPherrin Date: Wed, 15 Apr 2026 20:29:04 -0400 Subject: [PATCH 9/9] Move pkimetal client to its own package --- linter/lints/rfc/lint_cert_via_pkimetal.go | 140 +------------------- linter/lints/rfc/lint_crl_via_pkimetal.go | 10 +- linter/pkimetal/client.go | 146 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 138 deletions(-) create mode 100644 linter/pkimetal/client.go diff --git a/linter/lints/rfc/lint_cert_via_pkimetal.go b/linter/lints/rfc/lint_cert_via_pkimetal.go index 477996fd3b3..ee337b736ed 100644 --- a/linter/lints/rfc/lint_cert_via_pkimetal.go +++ b/linter/lints/rfc/lint_cert_via_pkimetal.go @@ -1,143 +1,15 @@ package rfc import ( - "context" - "encoding/base64" - "encoding/json" - "fmt" - "io" - "net" - "net/http" - "net/url" - "slices" - "strings" - "sync" - "time" - "github.com/zmap/zcrypto/x509" "github.com/zmap/zlint/v3/lint" "github.com/zmap/zlint/v3/util" -) - -// PKIMetalConfig and its execute method provide a shared basis for linting -// both certs and CRLs using PKIMetal. -type PKIMetalConfig struct { - Socket string `toml:"socket" comment:"Path to a unix socket where pkimetal is listening."` - Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."` - Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."` - IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` - - clientOnce sync.Once - client *http.Client -} - -func (pkim *PKIMetalConfig) httpClient() *http.Client { - pkim.clientOnce.Do(func() { - socket := pkim.Socket - transport := http.DefaultTransport.(*http.Transport).Clone() - transport.Proxy = nil - transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { - var d net.Dialer - return d.DialContext(ctx, "unix", socket) - } - pkim.client = &http.Client{ - Transport: transport, - } - }) - return pkim.client -} - -func (pkim *PKIMetalConfig) execute(endpoint string, der []byte) (*lint.LintResult, error) { - timeout := pkim.Timeout - if timeout == 0 { - timeout = 100 * time.Millisecond - } - - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - - // Host is ignored by our unix-socket transport, so any valid base works. - apiURL, err := url.JoinPath("http://pkimetal", endpoint) - if err != nil { - return nil, fmt.Errorf("constructing pkimetal url: %w", err) - } - - // reqForm matches PKIMetal's documented form-urlencoded request format. It - // does not include the "profile" field, as its default value ("autodetect") - // is good for our purposes. - // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194 - reqForm := url.Values{} - reqForm.Set("b64input", base64.StdEncoding.EncodeToString(der)) - reqForm.Set("severity", pkim.Severity) - reqForm.Set("format", "json") - req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(reqForm.Encode())) - if err != nil { - return nil, fmt.Errorf("creating pkimetal request: %w", err) - } - req.Header.Add("Content-Type", "application/x-www-form-urlencoded") - req.Header.Add("Accept", "application/json") - - resp, err := pkim.httpClient().Do(req) - if err != nil { - return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status) - } - - resJSON, err := io.ReadAll(resp.Body) - if err != nil { - return nil, fmt.Errorf("reading response from pkimetal API: %s", err) - } - - // finding matches the repeated portion of PKIMetal's documented JSON response. - // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221 - type finding struct { - Linter string `json:"linter"` - Finding string `json:"finding"` - Severity string `json:"severity"` - Code string `json:"code"` - Field string `json:"field"` - } - - var res []finding - err = json.Unmarshal(resJSON, &res) - if err != nil { - return nil, fmt.Errorf("parsing response from pkimetal API: %s", err) - } - - var findings []string - for _, finding := range res { - var id string - if finding.Code != "" { - id = fmt.Sprintf("%s:%s", finding.Linter, finding.Code) - } else { - id = fmt.Sprintf("%s:%s", finding.Linter, strings.ReplaceAll(strings.ToLower(finding.Finding), " ", "_")) - } - if slices.Contains(pkim.IgnoreLints, id) { - continue - } - desc := fmt.Sprintf("%s from %s: %s", finding.Severity, id, finding.Finding) - findings = append(findings, desc) - } - - if len(findings) != 0 { - // Group the findings by severity, for human readers. - slices.Sort(findings) - return &lint.LintResult{ - Status: lint.Error, - Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")), - }, nil - } - - return &lint.LintResult{Status: lint.Pass}, nil -} + "github.com/letsencrypt/boulder/linter/pkimetal" +) type certViaPKIMetal struct { - PKIMetalConfig + pkimetal.Client } func init() { @@ -158,17 +30,17 @@ func NewCertViaPKIMetal() lint.CertificateLintInterface { } func (l *certViaPKIMetal) Configure() any { - return l + return &l.Config } func (l *certViaPKIMetal) CheckApplies(c *x509.Certificate) bool { // This lint applies to all certificates issued by Boulder, as long as it has // been configured with a socket to reach out to. If not, skip it. - return l.Socket != "" + return l.Enabled() } func (l *certViaPKIMetal) Execute(c *x509.Certificate) *lint.LintResult { - res, err := l.execute("lintcert", c.Raw) + res, err := l.Client.Execute("lintcert", c.Raw) if err != nil { return &lint.LintResult{ Status: lint.Error, diff --git a/linter/lints/rfc/lint_crl_via_pkimetal.go b/linter/lints/rfc/lint_crl_via_pkimetal.go index 6ebf74b3383..67b7f0350d6 100644 --- a/linter/lints/rfc/lint_crl_via_pkimetal.go +++ b/linter/lints/rfc/lint_crl_via_pkimetal.go @@ -4,10 +4,12 @@ import ( "github.com/zmap/zcrypto/x509" "github.com/zmap/zlint/v3/lint" "github.com/zmap/zlint/v3/util" + + "github.com/letsencrypt/boulder/linter/pkimetal" ) type crlViaPKIMetal struct { - PKIMetalConfig + pkimetal.Client } func init() { @@ -28,17 +30,17 @@ func NewCrlViaPKIMetal() lint.RevocationListLintInterface { } func (l *crlViaPKIMetal) Configure() any { - return l + return &l.Config } func (l *crlViaPKIMetal) CheckApplies(c *x509.RevocationList) bool { // This lint applies to all CRLs issued by Boulder, as long as it has // been configured with a socket to reach out to. If not, skip it. - return l.Socket != "" + return l.Enabled() } func (l *crlViaPKIMetal) Execute(c *x509.RevocationList) *lint.LintResult { - res, err := l.execute("lintcrl", c.Raw) + res, err := l.Client.Execute("lintcrl", c.Raw) if err != nil { return &lint.LintResult{ Status: lint.Error, diff --git a/linter/pkimetal/client.go b/linter/pkimetal/client.go new file mode 100644 index 00000000000..4637e6a56f2 --- /dev/null +++ b/linter/pkimetal/client.go @@ -0,0 +1,146 @@ +package pkimetal + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "slices" + "strings" + "sync" + "time" + + "github.com/zmap/zlint/v3/lint" +) + +// Config holds configuration for linting both certs and CRLs using PKIMetal. +// Zlint will deserialize toml here. +type Config struct { + Socket string `toml:"socket" comment:"Path to a unix socket where pkimetal is listening."` + Severity string `toml:"severity" comment:"The minimum severity of findings to report (meta, debug, info, notice, warning, error, bug, or fatal)."` + Timeout time.Duration `toml:"timeout" comment:"How long, in nanoseconds, to wait before giving up."` + IgnoreLints []string `toml:"ignore_lints" comment:"The unique Validator:Code IDs of lint findings which should be ignored."` +} + +type Client struct { + Config + + clientOnce sync.Once + httpClient *http.Client +} + +// Enabled returns true if the client has a socket configured. +func (pkim *Client) Enabled() bool { + return pkim != nil && pkim.Socket != "" +} + +// Execute linting in pkimetal. +func (pkim *Client) Execute(endpoint string, der []byte) (*lint.LintResult, error) { + timeout := pkim.Timeout + if timeout == 0 { + timeout = 100 * time.Millisecond + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Host is ignored by our unix-socket transport, so any valid base works. + apiURL, err := url.JoinPath("http://pkimetal", endpoint) + if err != nil { + return nil, fmt.Errorf("constructing pkimetal url: %w", err) + } + + // reqForm matches PKIMetal's documented form-urlencoded request format. It + // does not include the "profile" field, as its default value ("autodetect") + // is good for our purposes. + // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L179-L194 + reqForm := url.Values{} + reqForm.Set("b64input", base64.StdEncoding.EncodeToString(der)) + reqForm.Set("severity", pkim.Severity) + reqForm.Set("format", "json") + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, strings.NewReader(reqForm.Encode())) + if err != nil { + return nil, fmt.Errorf("creating pkimetal request: %w", err) + } + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + req.Header.Add("Accept", "application/json") + + resp, err := pkim.getHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("making POST request to pkimetal API: %s (timeout %s)", err, timeout) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("got status %d (%s) from pkimetal API", resp.StatusCode, resp.Status) + } + + resJSON, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response from pkimetal API: %s", err) + } + + // finding matches the repeated portion of PKIMetal's documented JSON response. + // https://github.com/pkimetal/pkimetal/blob/578ac224a7ca3775af51b47fce16c95753d9ac8d/doc/openapi.yaml#L201-L221 + type finding struct { + Linter string `json:"linter"` + Finding string `json:"finding"` + Severity string `json:"severity"` + Code string `json:"code"` + Field string `json:"field"` + } + + var res []finding + err = json.Unmarshal(resJSON, &res) + if err != nil { + return nil, fmt.Errorf("parsing response from pkimetal API: %s", err) + } + + var findings []string + for _, finding := range res { + var id string + if finding.Code != "" { + id = fmt.Sprintf("%s:%s", finding.Linter, finding.Code) + } else { + id = fmt.Sprintf("%s:%s", finding.Linter, strings.ReplaceAll(strings.ToLower(finding.Finding), " ", "_")) + } + if slices.Contains(pkim.IgnoreLints, id) { + continue + } + desc := fmt.Sprintf("%s from %s: %s", finding.Severity, id, finding.Finding) + findings = append(findings, desc) + } + + if len(findings) != 0 { + // Group the findings by severity, for human readers. + slices.Sort(findings) + return &lint.LintResult{ + Status: lint.Error, + Details: fmt.Sprintf("got %d lint findings from pkimetal API: %s", len(findings), strings.Join(findings, "; ")), + }, nil + } + + return &lint.LintResult{Status: lint.Pass}, nil +} + +func (pkim *Client) getHTTPClient() *http.Client { + // Create an http client on first use, as there's not a great place to do this setup ahead of time. + pkim.clientOnce.Do(func() { + socket := pkim.Socket + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socket) + } + pkim.httpClient = &http.Client{ + Transport: transport, + } + }) + return pkim.httpClient +}