22 Commits

Author SHA1 Message Date
affa895cfe Fixes to the documentation. Added missing --revoked logic for the list command.
Some checks failed
Release / release (push) Failing after 53s
2025-12-10 21:33:23 +01:00
714a38d612 Fix error messages for consistency in ca.go and certdb.go 2025-12-10 21:13:51 +01:00
5d2675450d Define global Version variable in main.go 2025-12-10 21:12:33 +01:00
8ee0b326d0 Removed leftover build.sh script. 2025-12-10 21:11:35 +01:00
e3c812592b Replaced build system. 2025-12-10 21:09:52 +01:00
2ddd22631b Version v0.4.0
Some checks failed
Release / release (push) Failing after 38s
2025-08-04 22:13:35 +02:00
a2528e0526 Added a new test script.
Some checks failed
Release / release (push) Failing after 1m6s
2025-08-04 22:09:23 +02:00
9b0d1eceae Re-enginnered information output during issuance phase. 2025-08-04 22:09:11 +02:00
45dfdf0afc Removed --overwrite flag. 2025-08-03 12:02:47 +02:00
6e69377d1a Enhance SAN handling in issueSingleCertificate: extract CN from DN if present 2025-08-03 09:38:31 +02:00
8114d667ec Fixed a bug that prevented adding a mandatory SAN if the certificate type is server. 2025-08-03 07:37:55 +02:00
176901d960 Small fix for set-version.sh.
Some checks failed
Release / release (push) Failing after 45s
2025-08-02 13:57:27 +02:00
1991963cab Bug fixes for referening and not copying objects and few others. 2025-08-02 13:51:14 +02:00
b0f0467346 Added helper scripts that will lock and unlock changes to version.go. 2025-08-02 13:50:39 +02:00
028788f357 Updated build.sh to embed version information to the binary using changes to the version.go file. 2025-08-02 13:45:45 +02:00
090fb4b423 Moved Version variable to a separate file. 2025-08-02 13:42:25 +02:00
eb5c5c0e43 Updated tea repository name. 2025-07-30 09:01:00 +02:00
8181ac8287 Added missing go mod tidy. 2025-07-30 08:58:58 +02:00
3fe908226d Fixed unchange placeholder names. 2025-07-30 08:56:17 +02:00
8a36588c62 Added manual trigger. 2025-07-30 08:49:49 +02:00
90ce7edd28 Fixed incorrect workflow directory name. 2025-07-30 08:47:09 +02:00
11bed9c8b1 Added release workflow. 2025-07-30 08:37:21 +02:00
8 changed files with 223 additions and 128 deletions

View File

@@ -0,0 +1,54 @@
name: Release
on:
workflow_dispatch:
push:
tags:
- 'v*.*.*'
jobs:
release:
runs-on: ubuntu-latest
steps:
# 1. Checkout source code
- name: Checkout
uses: actions/checkout@v4
# 2. Setup Go environment
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.5'
# 3. Build binary with Version injected
- name: Build binary
run: |
VERSION=${GITEA_REF_NAME}
echo "Building version $VERSION"
go mod tidy
go build -ldflags "-s -w -X main.Version=$VERSION" -o lab-ca .
# 4. Install the tea CLI
- name: Install tea CLI
run: go install code.gitea.io/tea@latest
# 5. Authenticate tea CLI
- name: Login to Gitea
run: |
tea login add --name ci --url $GITEA_URL --token $GITEA_TOKEN
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
GITEA_URL: ${{ secrets.GITEA_URL }}
# 6. Create or update release
- name: Create or update release
run: |
tea release create $GITEA_REF_NAME \
--title "$GITEA_REF_NAME" \
--note "Automated release for $GITEA_REF_NAME" || \
echo "Release already exists, skipping create."
# 7. Upload binary to the release
- name: Upload binary
run: tea release upload $GITEA_REF_NAME lab-ca

19
Makefile Normal file
View File

@@ -0,0 +1,19 @@
# Get version from git tags
VERSION := $(shell git describe --tags --always 2>/dev/null)
.PHONY: clean
build/lab-ca: main.go ca.go certdb.go
@mkdir -p build
ifneq ($(VERSION),)
@echo "Building version: $(VERSION)"
go build -o build/lab-ca -ldflags "-X main.Version=$(VERSION)"
else
@echo "Building without version information"
go build -o build/lab-ca .
endif
lab-ca: build/lab-ca
clean:
rm -f build/lab-ca

View File

@@ -86,7 +86,7 @@ lab-ca list --revoked
Issue a new certificate from the command line:
```bash
lab-ca issue --name <name> [--subject <subject>] [--type <type>] [--validity <period>] [--san <SAN> ...] [--overwrite] [--dry-run] [--verbose]
lab-ca issue --name <name> [--subject <subject>] [--type <type>] [--validity <period>] [--san <SAN> ...] [--dry-run] [--verbose]
```
- `--name` (required): Name for the certificate and key files (used as subject if `--subject` is omitted)
@@ -94,7 +94,6 @@ lab-ca issue --name <name> [--subject <subject>] [--type <type>] [--validity <pe
- `--type`: Certificate type: `client`, `server`, `code-signing`, `email` (comma-separated for multiple usages; default: `server`)
- `--validity`: Validity period (e.g. `2y`, `6m`, `30d`; default: `1y`)
- `--san`: Subject Alternative Name (repeatable; e.g. `dns:example.com`, `ip:1.2.3.4`, `email:user@example.com`)
- `--overwrite`: Allow overwriting existing files
- `--dry-run`: Validate and show what would be created, but do not write files
- `--verbose`: Print detailed information
@@ -105,7 +104,7 @@ lab-ca issue --name <name> [--subject <subject>] [--type <type>] [--validity <pe
Provision multiple certificates from a batch file (HCL):
```bash
lab-ca provision --file <certificates.hcl> [--overwrite] [--verbose]
lab-ca provision --file <certificates.hcl> [--verbose]
```
#### Example HCL Provisioning File
@@ -221,6 +220,12 @@ The tool checks that SANs are valid for the selected certificate type(s). Certif
See `examples/example-certificates.hcl` for a more advanced provisioning file with templates and variables.
## Building the Tool
The repository includes a `Makefile` to build the CLI tool. It automatically determines the version from Git tags and builds the binary.
To build the tool, run the `make` command. The binary will be created as `build/lab-ca`.
---
## Notes

View File

@@ -1,22 +0,0 @@
#!/bin/sh
# Build script for lab-ca with version injection from git tag
git describe --tags --always --dirty > /dev/null 2>&1
if [ $? -eq 0 ]; then
VERSION=$(git describe --tags --always --dirty)
else
VERSION="dev"
fi
if echo $VERSION | grep -q 'dirty$'; then
echo "Building in development mode, output directory is set to 'build'."
OUTPUT_DIR=build
# Make sure the output directory exists, create it if it is not
mkdir -p $OUTPUT_DIR
else
echo "Building with version: $VERSION"
OUTPUT_DIR=$GOHOME/bin
fi
# Build the Lab CA binary with version information
go build -ldflags "-X main.Version=$VERSION" -o $OUTPUT_DIR/lab-ca

189
ca.go
View File

@@ -72,7 +72,7 @@ func (def *CertificateDefinition) FillDefaultValues(defaults *CertificateDefault
def.Validity = defaults.Validity
}
if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
def.SAN = defaults.SAN
def.SAN = append([]string(nil), defaults.SAN...)
}
}
@@ -291,14 +291,9 @@ func parseValidity(validity string) (time.Duration, error) {
}
func SavePEM(filename string, data []byte, secure bool) error {
if !overwrite {
if _, err := os.Stat(filename); err == nil {
return fmt.Errorf("file %s already exists (overwrite not allowed)", filename)
} else if !os.IsNotExist(err) {
return fmt.Errorf("could not check file %s: %v", filename, err)
}
if _, err := os.Stat(filename); err == nil {
return fmt.Errorf("file %s already exists", filename)
}
if secure {
return os.WriteFile(filename, data, 0600)
} else {
@@ -453,22 +448,31 @@ func InitCA() error {
return nil
}
// Helper: issue a single certificate and key, save to files, return error if any
func issueSingleCertificate(def CertificateDefinition) error {
func issueSingleCertificate(def CertificateDefinition, i int, n int) (bool, error) {
// Validate Name
isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name)
if err != nil {
return fmt.Errorf("error validating certificate name: %v", err)
return false, fmt.Errorf("error validating certificate name: %v", err)
}
if !isValidName {
return fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore")
return false, fmt.Errorf("certificate name must be specified and contain only letters, numbers, dash, or underscore")
}
// Check if the certificate is in database, fail if it is.
// Check if the certificate is in database, skip if it already exists and is valid.
if caState.FindByName(def.Name, false) != nil {
return fmt.Errorf("certificate %s already exists and is valid.", def.Name)
if !dryRun {
fmt.Printf("skipped (already exists).\n")
} else {
msg := fmt.Sprintf("Certificate '%s' already exists and is valid (would skip).", def.Name)
if n > 1 {
fmt.Printf("[%d/%d] %s\n", i+1, n, msg)
} else {
fmt.Printf("%s\n", msg)
}
}
return false, nil
}
// Initialize Subject if not specified
@@ -477,18 +481,44 @@ func issueSingleCertificate(def CertificateDefinition) error {
}
// Add default dns SAN for server/server-only if none specified
if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 {
def.SAN = append(def.SAN, "dns:"+def.Subject)
if strings.Contains(def.Type, "server") && len(def.SAN) == 0 {
// Extract CN if subject is a DN, else use subject as is
cn := def.Subject
if isDNFormat(def.Subject) {
dn := parseDistinguishedName(def.Subject)
if dn.CommonName != "" {
cn = dn.CommonName
}
}
def.SAN = append(def.SAN, "dns:"+cn)
}
// Check if the certificate is being issued in dry run mode
if dryRun {
msg := fmt.Sprintf("Would issue certificate for '%s' (dry run).", def.Subject)
if n > 1 {
fmt.Printf("[%d/%d] %s\n", i+1, n, msg)
} else {
fmt.Printf("%s\n", msg)
}
return false, nil
} else {
msg := fmt.Sprintf("Issuing certificate for '%s'... ", def.Subject)
if n > 1 {
fmt.Printf("[%d/%d] %s", i+1, n, msg)
} else {
fmt.Printf("%s", msg)
}
}
priv, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("failed to generate private key: %v", err)
return false, fmt.Errorf("failed to generate private key: %v", err)
}
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return fmt.Errorf("failed to generate serial number: %v", err)
return false, fmt.Errorf("failed to generate serial number: %v", err)
}
var validityDur time.Duration
@@ -499,7 +529,7 @@ func issueSingleCertificate(def CertificateDefinition) error {
validityDur, err = parseValidity(validity)
if err != nil {
return fmt.Errorf("invalid validity value: %v", err)
return false, fmt.Errorf("invalid validity value: %v", err)
}
var subjectPKIX pkix.Name
@@ -532,7 +562,7 @@ func issueSingleCertificate(def CertificateDefinition) error {
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
} else {
return fmt.Errorf("invalid SAN format: %s", s)
return false, fmt.Errorf("invalid SAN format: %s", s)
}
}
@@ -552,13 +582,13 @@ func issueSingleCertificate(def CertificateDefinition) error {
case "email":
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
default:
return fmt.Errorf("unknown certificate type. Use one of: client, server, code-signing, email")
return false, fmt.Errorf("unknown certificate type. Use one of: client, server, code-signing, email")
}
}
certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %v", err)
return false, fmt.Errorf("failed to create certificate: %v", err)
}
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
@@ -566,29 +596,12 @@ func issueSingleCertificate(def CertificateDefinition) error {
certFile := filepath.Join(caConfig.Paths.Certificates, def.Name+".crt.pem")
keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem")
if err := SavePEM(certFile, certPEM, false); err != nil {
return fmt.Errorf("error saving certificate: %v", err)
return false, fmt.Errorf("error saving certificate: %v", err)
}
if err := SavePEM(keyFile, keyPEM, true); err != nil {
return fmt.Errorf("error saving key: %v", err)
return false, fmt.Errorf("error saving key: %v", err)
}
if verbose {
fmt.Printf(`
Certificate:
Name: %s
Subject: %s
Type: %s
Validity: %s
SAN: %v
`,
def.Name,
def.Subject,
def.Type,
def.Validity,
def.SAN,
)
}
caState.UpdateCAStateAfterIssue(
err = caState.UpdateCAStateAfterIssue(
caConfig.SerialType,
def.Name,
def.Subject,
@@ -596,11 +609,49 @@ Certificate:
serialNumber,
validityDur,
)
return nil
if err != nil {
return false, fmt.Errorf("error updating CA state: %v", err)
}
if !verbose {
fmt.Printf("done.\n")
} else {
fmt.Printf(`done.
Certificate generated:
Name: %s
Subject: %s
Type: %s
Validity: %s
SANs:
`,
def.Name,
def.Subject,
def.Type,
def.Validity,
)
for _, san := range def.SAN {
parts := strings.SplitN(san, ":", 2)
if len(parts) == 2 {
fmt.Printf(" %s (%s)\n", parts[1], parts[0])
} else {
fmt.Printf(" %s\n", san)
}
}
}
if err := SaveCAState(); err != nil {
// If saving CA state fails, we still return success for the certificate issuance
fmt.Printf("WARNING: %v\n", err)
fmt.Println("CA state not saved, but certificate issued and saved successfully.")
return true, fmt.Errorf("error saving CA state: %v", err)
}
return true, nil
}
// A prototype of certificate provisioning function
func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose bool) error {
func ProvisionCertificates(filePath string) error {
err := LoadCA()
if err != nil {
@@ -614,12 +665,12 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
// Load certificates provisioning configuration from the file (HCL syntax)
err = certDefs.LoadFromFile(filePath)
if err != nil {
return fmt.Errorf("Error loading certificates file: %v", err)
return fmt.Errorf("error loading certificates file: %v", err)
}
// The certificate provisioning file must contain at least one certificate definition
if len(certDefs.Certificates) < 1 {
return fmt.Errorf("No certificates defined in %s", filePath)
return fmt.Errorf("no certificates defined in %s", filePath)
}
// We will be counting successes and errors
@@ -647,27 +698,18 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
n := len(certDefs.Certificates)
// No errors so far, now we can issue certificates
for i := range certDefs.Certificates {
fmt.Printf("[%d/%d] Issuing %s... ", i+1, n, certDefs.Certificates[i].Name)
if dryRun {
fmt.Printf("(dry run)\n")
successes++
continue
}
err = issueSingleCertificate(certDefs.Certificates[i])
issued, err := issueSingleCertificate(certDefs.Certificates[i], i, n)
if err != nil {
fmt.Printf("error: %v\n", err)
errors++
} else {
if !verbose {
fmt.Printf("done\n")
if issued {
successes++
}
successes++
}
}
fmt.Printf("Provisioning complete: %d succeeded, %d failed.\n", successes, errors)
fmt.Printf("Provisioning complete: %d succeeded, %d failed, %d skipped.\n", successes, errors, n-successes-errors)
err = SaveCAState()
if err != nil {
@@ -677,38 +719,23 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
return nil
}
func IssueCertificate(certDef CertificateDefinition, overwrite bool, dryRun bool, verbose bool) error {
func IssueCertificate(def CertificateDefinition) error {
err := LoadCA()
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
os.Exit(1)
}
if certDef.Subject == "" {
certDef.Subject = certDef.Name
if def.Subject == "" {
def.Subject = def.Name
}
// Render templates in the certificae subject and SAN fields
variables := map[string]string{"Name": certDef.Name}
certDef.RenderTemplates(variables)
variables := map[string]string{"Name": def.Name}
def.RenderTemplates(variables)
if dryRun {
fmt.Printf("Would issue %s certificate for '%s' (dry run)\n", certDef.Type, certDef.Subject)
return nil
}
err = issueSingleCertificate(certDef)
if err != nil {
return err
}
fmt.Printf("%s certificate and key for '%s' generated.\n", certDef.Type, certDef.Subject)
if err := SaveCAState(); err != nil {
fmt.Printf("Error saving CA state: %v\n", err)
}
return nil
_, err = issueSingleCertificate(def, 1, 1)
return err
}
// Helper: check if string looks like a DN (contains at least CN=...)

View File

@@ -36,41 +36,42 @@ type CertificateRecord struct {
// Look for a certifcate by its name
func (c *CAState) FindByName(name string, all bool) *CertificateRecord {
for _, cert := range c.Certificates {
for i := range c.Certificates {
cert := &c.Certificates[i]
if cert.RevokedAt != "" && !all {
continue
}
if cert.Name == name {
return &cert
return cert
}
}
return nil
}
// Look for a certificate by its serial
func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord {
for _, cert := range c.Certificates {
for i := range c.Certificates {
cert := &c.Certificates[i]
if cert.RevokedAt != "" && !all {
continue
}
if cert.Serial == serial {
return &cert
return cert
}
}
return nil
}
// func caStatePath() string {
// return filepath.Join(filepath.Dir(caConfigPath), caConfig.GetStateFileName())
// }
// LoadCAState loads the CA state from a JSON file
func LoadCAState() error {
fmt.Printf("Loading CA state from %s\n", caStatePath)
f, err := os.Open(caStatePath)
if err != nil {
if os.IsNotExist(err) {
// File does not exist, treat as empty state
caState = &CAState{}
return nil
}
return err
}
defer f.Close()
@@ -97,8 +98,7 @@ func SaveCAState() error {
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate
func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject string, certType string, serialNumber any, validity time.Duration) error {
if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n")
os.Exit(1)
return fmt.Errorf("CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error")
}
issued := time.Now().UTC().Format(time.RFC3339)
expires := time.Now().Add(validity).UTC().Format(time.RFC3339)
@@ -119,7 +119,7 @@ func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject strin
func (s *CAState) AddCertificate(name, subject, certType, issued, expires, serial string) {
if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n")
os.Exit(1)
return
}
rec := CertificateRecord{
Name: name,
@@ -135,8 +135,7 @@ func (s *CAState) AddCertificate(name, subject, certType, issued, expires, seria
// RevokeCertificate revokes a certificate by serial number and reason code, updates state, and saves to disk
func (s *CAState) RevokeCertificate(serial string, reason int) error {
if s == nil {
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n")
os.Exit(1)
return fmt.Errorf("CAState is nil in RevokeCertificate. This indicates a programming error")
}
revoked := false
revokedAt := time.Now().UTC().Format(time.RFC3339)

14
main.go
View File

@@ -7,12 +7,10 @@ import (
"github.com/spf13/cobra"
)
var Version = ""
// Global flags available to all commands
var overwrite bool
var dryRun bool
var verbose bool
var Version = "v0.4.0"
func main() {
@@ -48,7 +46,6 @@ func main() {
}
// Define persistent flags (global for all commands)
rootCmd.PersistentFlags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
rootCmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate")
rootCmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)")
rootCmd.PersistentFlags().StringVar(&caConfigPath, "config", "ca_config.hcl", "Path to CA configuration file")
@@ -74,10 +71,13 @@ func main() {
os.Exit(1)
}
for _, certDef := range caState.Certificates {
if certDef.RevokedAt != "" {
if certDef.RevokedAt != "" && !listRevoked {
continue
}
fmt.Printf("Certificate %s\n", certDef.Name)
if certDef.RevokedAt != "" {
fmt.Printf("\tStatus: REVOKED (at %s)\n", certDef.RevokedAt)
}
fmt.Printf("\tSubject: %s\n\tType: %s\n\tIssued at: %s\n",
certDef.Subject, certDef.Type, certDef.Issued)
}
@@ -97,7 +97,7 @@ func main() {
Type: certType,
Validity: validity,
SAN: san,
}, overwrite, dryRun, verbose)
})
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
@@ -122,7 +122,7 @@ func main() {
Short: "Provision certificates from a batch file (HCL)",
Run: func(cmd *cobra.Command, args []string) {
err := ProvisionCertificates(provisionFile, overwrite, false, verbose)
err := ProvisionCertificates(provisionFile)
if err != nil {
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)

13
run-test-2.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
if [ "-v" == "$1" ]; then
VERBOSE="--verbose"
shift
else
VERBOSE=""
fi
./build.sh
rm -rf docker_ca
build/lab-ca $VERBOSE --config docker_ca.hcl initca
build/lab-ca $VERBOSE --config docker_ca.hcl provision --file docker.hcl