Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cfb80355bb | |||
| 77160395a0 | |||
| 37d5e04421 | |||
| dc54eeacbc |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.idea
|
||||
cmd/cert-bundler/testdata/pkg/*
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
CHANGELOG
|
||||
|
||||
v1.13.1 - 2025-11-17
|
||||
|
||||
Add:
|
||||
- Dockerfile for cert-bundler.
|
||||
|
||||
v1.13.0 - 2025-11-16
|
||||
|
||||
Add:
|
||||
|
||||
28
cmd/cert-bundler/Dockerfile
Normal file
28
cmd/cert-bundler/Dockerfile
Normal file
@@ -0,0 +1,28 @@
|
||||
# Build and runtime image for cert-bundler
|
||||
# Usage (from repo root or cmd/cert-bundler directory):
|
||||
# docker build -t cert-bundler:latest -f cmd/cert-bundler/Dockerfile .
|
||||
# docker run --rm -v "$PWD":/work cert-bundler:latest
|
||||
# This expects a /work/bundle.yaml file in the mounted directory and
|
||||
# will write generated bundles to /work/bundle.
|
||||
|
||||
# Build stage
|
||||
FROM golang:1.24.3-alpine AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Copy go module files and download dependencies first for better caching
|
||||
RUN go install git.wntrmute.dev/kyle/goutils/cmd/cert-bundler@v1.13.1 && \
|
||||
mv /go/bin/cert-bundler /usr/local/bin/cert-bundler
|
||||
|
||||
# Runtime stage (kept as golang:alpine per requirement)
|
||||
FROM golang:1.24.3-alpine
|
||||
|
||||
# Create a work directory that users will typically mount into
|
||||
WORKDIR /work
|
||||
VOLUME ["/work"]
|
||||
|
||||
# Copy the built binary from the builder stage
|
||||
COPY --from=build /usr/local/bin/cert-bundler /usr/local/bin/cert-bundler
|
||||
|
||||
# Default command: read bundle.yaml from current directory and output to ./bundle
|
||||
ENTRYPOINT ["/usr/local/bin/cert-bundler"]
|
||||
CMD ["-c", "/work/bundle.yaml", "-o", "/work/bundle"]
|
||||
@@ -17,8 +17,9 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
"gopkg.in/yaml.v2"
|
||||
|
||||
"git.wntrmute.dev/kyle/goutils/certlib"
|
||||
)
|
||||
|
||||
// Config represents the top-level YAML configuration.
|
||||
@@ -299,12 +300,18 @@ func prepareArchiveFiles(
|
||||
) ([]fileEntry, error) {
|
||||
var archiveFiles []fileEntry
|
||||
|
||||
// Track used filenames to avoid collisions inside archives
|
||||
usedNames := make(map[string]int)
|
||||
|
||||
// Handle a single bundle file
|
||||
if outputs.IncludeSingle && len(singleFileCerts) > 0 {
|
||||
files, err := encodeCertsToFiles(singleFileCerts, "bundle", encoding, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode single bundle: %w", err)
|
||||
}
|
||||
for i := range files {
|
||||
files[i].name = makeUniqueName(files[i].name, usedNames)
|
||||
}
|
||||
archiveFiles = append(archiveFiles, files...)
|
||||
}
|
||||
|
||||
@@ -316,6 +323,9 @@ func prepareArchiveFiles(
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encode individual cert %s: %w", cp.path, err)
|
||||
}
|
||||
for i := range files {
|
||||
files[i].name = makeUniqueName(files[i].name, usedNames)
|
||||
}
|
||||
archiveFiles = append(archiveFiles, files...)
|
||||
}
|
||||
}
|
||||
@@ -323,8 +333,9 @@ func prepareArchiveFiles(
|
||||
// Generate manifest if requested
|
||||
if outputs.Manifest {
|
||||
manifestContent := generateManifest(archiveFiles)
|
||||
manifestName := makeUniqueName("MANIFEST", usedNames)
|
||||
archiveFiles = append(archiveFiles, fileEntry{
|
||||
name: "MANIFEST",
|
||||
name: manifestName,
|
||||
content: manifestContent,
|
||||
})
|
||||
}
|
||||
@@ -573,3 +584,29 @@ func generateHashFile(path string, files []string) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// makeUniqueName ensures that each file name within the archive is unique by appending
|
||||
// an incremental numeric suffix before the extension when collisions occur.
|
||||
// Example: "root.pem" -> "root-2.pem", "root-3.pem", etc.
|
||||
func makeUniqueName(name string, used map[string]int) string {
|
||||
// If unused, mark and return as-is
|
||||
if _, ok := used[name]; !ok {
|
||||
used[name] = 1
|
||||
return name
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
base := strings.TrimSuffix(name, ext)
|
||||
// Track a counter per base+ext key
|
||||
key := base + ext
|
||||
counter := max(used[key], 1)
|
||||
for {
|
||||
counter++
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, counter, ext)
|
||||
if _, exists := used[candidate]; !exists {
|
||||
used[key] = counter
|
||||
used[candidate] = 1
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
This project is an exploration into the utility of Jetbrains' Junie
|
||||
to write smaller but tedious programs.
|
||||
|
||||
Task: build a certificate bundling tool in cmd/cert-bundler. It
|
||||
creates archives of certificates chains.
|
||||
|
||||
A YAML file for this looks something like:
|
||||
|
||||
``` yaml
|
||||
config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
core_certs:
|
||||
certs:
|
||||
- root: roots/core-ca.pem
|
||||
intermediates:
|
||||
- int/cca1.pem
|
||||
- int/cca2.pem
|
||||
- int/cca3.pem
|
||||
- root: roots/ssh-ca.pem
|
||||
intermediates:
|
||||
- ssh/ssh_dmz1.pem
|
||||
- ssh/ssh_internal.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
```
|
||||
|
||||
Some requirements:
|
||||
|
||||
1. First, all the certificates should be loaded.
|
||||
2. For each root, each of the indivudal intermediates should be
|
||||
checked to make sure they are properly signed by the root CA.
|
||||
3. The program should optionally take an expiration period (defaulting
|
||||
to one year), specified in config.expiration, and if any certificate
|
||||
is within that expiration period, a warning should be printed.
|
||||
4. If outputs.include_single is true, all certificates under chains
|
||||
should be concatenated into a single file.
|
||||
5. If outputs.include_individual is true, all certificates under
|
||||
chains should be included at the root level (e.g. int/cca2.pem
|
||||
would be cca2.pem in the archive).
|
||||
6. If bundle.manifest is true, a "MANIFEST" file is created with
|
||||
SHA256 sums of each file included in the archive.
|
||||
7. For each of the formats, create an archive file in the output
|
||||
directory (specified with `-o`) with that format.
|
||||
- If zip is included, create a .zip file.
|
||||
- If tgz is included, create a .tar.gz file with default compression
|
||||
levels.
|
||||
- All archive files should include any generated files (single
|
||||
and/or individual) in the top-level directory.
|
||||
8. In the output directory, create a file with the same name as
|
||||
config.hashes that contains the SHA256 sum of all files created.
|
||||
|
||||
-----
|
||||
|
||||
The outputs.include_single and outputs.include_individual describe
|
||||
what should go in the final archive. If both are specified, the output
|
||||
archive should include both a single bundle.pem and each individual
|
||||
certificate, for example.
|
||||
|
||||
-----
|
||||
|
||||
As it stands, given the following `bundle.yaml`:
|
||||
|
||||
``` yaml
|
||||
config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
core_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
- root: pems/isrg-root-x1.pem
|
||||
intermediates:
|
||||
- pems/le-e7.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: false
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
google_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: false
|
||||
manifest: true
|
||||
formats:
|
||||
- tgz
|
||||
lets_encrypt:
|
||||
certs:
|
||||
- root: pems/isrg-root-x1.pem
|
||||
intermediates:
|
||||
- pems/le-e7.pem
|
||||
outputs:
|
||||
include_single: false
|
||||
include_individual: true
|
||||
manifest: false
|
||||
formats:
|
||||
- zip
|
||||
```
|
||||
|
||||
The program outputs the following files:
|
||||
|
||||
- bundle.sha256
|
||||
- core_certs_0.tgz (contains individual certs)
|
||||
- core_certs_0.zip (contains individual certs)
|
||||
- core_certs_1.tgz (contains core_certs.pem)
|
||||
- core_certs_1.zip (contains core_certs.pem)
|
||||
- google_certs_0.tgz
|
||||
- lets_encrypt_0.zip
|
||||
|
||||
It should output
|
||||
|
||||
- bundle.sha256
|
||||
- core_certs.tgz
|
||||
- core_certs.zip
|
||||
- google_certs.tgz
|
||||
- lets_encrypt.zip
|
||||
|
||||
core_certs.* should contain `bundle.pem` and all the individual
|
||||
certs. There should be no _$n$ variants of archives.
|
||||
|
||||
-----
|
||||
|
||||
Add an additional field to outputs: encoding. It should accept one of
|
||||
`der`, `pem`, or `both`. If `der`, certificates should be output as a
|
||||
`.crt` file containing a DER-encoded certificate. If `pem`, certificates
|
||||
should be output as a `.pem` file containing a PEM-encoded certificate.
|
||||
If both, both the `.crt` and `.pem` certificate should be included.
|
||||
|
||||
For example, given the previous config, if `encoding` is der, the
|
||||
google_certs.tgz archive should contain
|
||||
|
||||
- bundle.crt
|
||||
- MANIFEST
|
||||
|
||||
Or with lets_encrypt.zip:
|
||||
|
||||
- isrg-root-x1.crt
|
||||
- le-e7.crt
|
||||
|
||||
However, if `encoding` is pem, the lets_encrypt.zip archive should contain:
|
||||
|
||||
- isrg-root-x1.pem
|
||||
- le-e7.pem
|
||||
|
||||
And if it `encoding` is both, the lets_encrypt.zip archive should contain:
|
||||
|
||||
- isrg-root-x1.crt
|
||||
- isrg-root-x1.pem
|
||||
- le-e7.crt
|
||||
- le-e7.pem
|
||||
|
||||
-----
|
||||
|
||||
The tgz format should output a `.tar.gz` file instead of a `.tgz` file.
|
||||
|
||||
-----
|
||||
|
||||
Move the format extensions to a global variable.
|
||||
|
||||
-----
|
||||
|
||||
Write a README.txt with a description of the bundle.yaml format.
|
||||
|
||||
Additionally, update the help text for the program (e.g. with `-h`)
|
||||
to provide the same detailed information.
|
||||
|
||||
-----
|
||||
|
||||
It may be easier to embed the README.txt in the program on build.
|
||||
|
||||
-----
|
||||
|
||||
For the archive (tar.gz and zip) writers, make sure errors are
|
||||
checked at the end, and don't just defer the close operations.
|
||||
|
||||
|
||||
13
cmd/cert-bundler/testdata/bundle.yaml
vendored
13
cmd/cert-bundler/testdata/bundle.yaml
vendored
@@ -2,6 +2,19 @@ config:
|
||||
hashes: bundle.sha256
|
||||
expiry: 1y
|
||||
chains:
|
||||
weird:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
intermediates:
|
||||
- pems/goog-wr2.pem
|
||||
- root: pems/isrg-root-x1.pem
|
||||
outputs:
|
||||
include_single: true
|
||||
include_individual: true
|
||||
manifest: true
|
||||
formats:
|
||||
- zip
|
||||
- tgz
|
||||
core_certs:
|
||||
certs:
|
||||
- root: pems/gts-r1.pem
|
||||
|
||||
4
cmd/cert-bundler/testdata/pkg/bundle.sha256
vendored
4
cmd/cert-bundler/testdata/pkg/bundle.sha256
vendored
@@ -1,4 +0,0 @@
|
||||
5ed8bf9ed693045faa8a5cb0edc4a870052e56aef6291ce8b1604565affbc2a4 core_certs.zip
|
||||
e59eddc590d2f7b790a87c5b56e81697088ab54be382c0e2c51b82034006d308 core_certs.tgz
|
||||
51b9b63b1335118079e90700a3a5b847c363808e9116e576ca84f301bc433289 google_certs.tgz
|
||||
3d1910ca8835c3ded1755a8c7d6c48083c2f3ff68b2bfbf932aaf27e29d0a232 lets_encrypt.zip
|
||||
BIN
cmd/cert-bundler/testdata/pkg/core_certs.tgz
vendored
BIN
cmd/cert-bundler/testdata/pkg/core_certs.tgz
vendored
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/core_certs.zip
vendored
BIN
cmd/cert-bundler/testdata/pkg/core_certs.zip
vendored
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/google_certs.tgz
vendored
BIN
cmd/cert-bundler/testdata/pkg/google_certs.tgz
vendored
Binary file not shown.
BIN
cmd/cert-bundler/testdata/pkg/lets_encrypt.zip
vendored
BIN
cmd/cert-bundler/testdata/pkg/lets_encrypt.zip
vendored
Binary file not shown.
Reference in New Issue
Block a user