Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| affa895cfe | |||
| 714a38d612 | |||
| 5d2675450d | |||
| 8ee0b326d0 | |||
| e3c812592b | |||
| 2ddd22631b | |||
| a2528e0526 | |||
| 9b0d1eceae | |||
| 45dfdf0afc | |||
| 6e69377d1a | |||
| 8114d667ec | |||
| 176901d960 | |||
| 1991963cab | |||
| b0f0467346 | |||
| 028788f357 | |||
| 090fb4b423 | |||
| eb5c5c0e43 | |||
| 8181ac8287 | |||
| 3fe908226d | |||
| 8a36588c62 | |||
| 90ce7edd28 | |||
| 11bed9c8b1 | |||
| 2fe228858f |
54
.gitea/workflows/release.yml
Normal file
54
.gitea/workflows/release.yml
Normal 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
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -2,6 +2,7 @@
|
|||||||
**/go.sum
|
**/go.sum
|
||||||
# Ignore the binary output
|
# Ignore the binary output
|
||||||
lab-ca*
|
lab-ca*
|
||||||
|
build
|
||||||
# Ignore any certificate files
|
# Ignore any certificate files
|
||||||
*.pem
|
*.pem
|
||||||
# Ignore CA configuration and certificate definitions.
|
# Ignore CA configuration and certificate definitions.
|
||||||
|
|||||||
19
Makefile
Normal file
19
Makefile
Normal 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
|
||||||
11
README.md
11
README.md
@@ -86,7 +86,7 @@ lab-ca list --revoked
|
|||||||
Issue a new certificate from the command line:
|
Issue a new certificate from the command line:
|
||||||
|
|
||||||
```bash
|
```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)
|
- `--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`)
|
- `--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`)
|
- `--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`)
|
- `--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
|
- `--dry-run`: Validate and show what would be created, but do not write files
|
||||||
- `--verbose`: Print detailed information
|
- `--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):
|
Provision multiple certificates from a batch file (HCL):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lab-ca provision --file <certificates.hcl> [--overwrite] [--verbose]
|
lab-ca provision --file <certificates.hcl> [--verbose]
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Example HCL Provisioning File
|
#### 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.
|
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
|
## Notes
|
||||||
|
|||||||
9
build.sh
9
build.sh
@@ -1,9 +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
|
|
||||||
go build -ldflags "-X main.Version=$VERSION" -o lab-ca
|
|
||||||
185
ca.go
185
ca.go
@@ -72,7 +72,7 @@ func (def *CertificateDefinition) FillDefaultValues(defaults *CertificateDefault
|
|||||||
def.Validity = defaults.Validity
|
def.Validity = defaults.Validity
|
||||||
}
|
}
|
||||||
if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
|
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 {
|
func SavePEM(filename string, data []byte, secure bool) error {
|
||||||
if !overwrite {
|
|
||||||
if _, err := os.Stat(filename); err == nil {
|
if _, err := os.Stat(filename); err == nil {
|
||||||
return fmt.Errorf("file %s already exists (overwrite not allowed)", filename)
|
return fmt.Errorf("file %s already exists", filename)
|
||||||
} else if !os.IsNotExist(err) {
|
|
||||||
return fmt.Errorf("could not check file %s: %v", filename, err)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if secure {
|
if secure {
|
||||||
return os.WriteFile(filename, data, 0600)
|
return os.WriteFile(filename, data, 0600)
|
||||||
} else {
|
} else {
|
||||||
@@ -453,22 +448,31 @@ func InitCA() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper: issue a single certificate and key, save to files, return error if any
|
func issueSingleCertificate(def CertificateDefinition, i int, n int) (bool, error) {
|
||||||
func issueSingleCertificate(def CertificateDefinition) error {
|
|
||||||
// Validate Name
|
// Validate Name
|
||||||
|
|
||||||
isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name)
|
isValidName, err := regexp.MatchString(`^[A-Za-z0-9_-]+$`, def.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("error validating certificate name: %v", err)
|
return false, fmt.Errorf("error validating certificate name: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !isValidName {
|
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 {
|
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
|
// 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
|
// Add default dns SAN for server/server-only if none specified
|
||||||
if (def.Type == "server" || def.Type == "server-only") && len(def.SAN) == 0 {
|
if strings.Contains(def.Type, "server") && len(def.SAN) == 0 {
|
||||||
def.SAN = append(def.SAN, "dns:"+def.Subject)
|
// 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)
|
priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||||
if err != nil {
|
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)
|
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||||
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||||
if err != nil {
|
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
|
var validityDur time.Duration
|
||||||
@@ -499,7 +529,7 @@ func issueSingleCertificate(def CertificateDefinition) error {
|
|||||||
|
|
||||||
validityDur, err = parseValidity(validity)
|
validityDur, err = parseValidity(validity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("invalid validity value: %v", err)
|
return false, fmt.Errorf("invalid validity value: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var subjectPKIX pkix.Name
|
var subjectPKIX pkix.Name
|
||||||
@@ -532,7 +562,7 @@ func issueSingleCertificate(def CertificateDefinition) error {
|
|||||||
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
|
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
|
||||||
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
|
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
|
||||||
} else {
|
} 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":
|
case "email":
|
||||||
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
|
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
|
||||||
default:
|
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)
|
certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey)
|
||||||
if err != nil {
|
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})
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
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")
|
certFile := filepath.Join(caConfig.Paths.Certificates, def.Name+".crt.pem")
|
||||||
keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem")
|
keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem")
|
||||||
if err := SavePEM(certFile, certPEM, false); err != nil {
|
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 {
|
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 {
|
err = caState.UpdateCAStateAfterIssue(
|
||||||
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(
|
|
||||||
caConfig.SerialType,
|
caConfig.SerialType,
|
||||||
def.Name,
|
def.Name,
|
||||||
def.Subject,
|
def.Subject,
|
||||||
@@ -596,11 +609,49 @@ Certificate:
|
|||||||
serialNumber,
|
serialNumber,
|
||||||
validityDur,
|
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
|
// A prototype of certificate provisioning function
|
||||||
func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose bool) error {
|
func ProvisionCertificates(filePath string) error {
|
||||||
err := LoadCA()
|
err := LoadCA()
|
||||||
|
|
||||||
if err != nil {
|
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)
|
// Load certificates provisioning configuration from the file (HCL syntax)
|
||||||
err = certDefs.LoadFromFile(filePath)
|
err = certDefs.LoadFromFile(filePath)
|
||||||
if err != nil {
|
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
|
// The certificate provisioning file must contain at least one certificate definition
|
||||||
if len(certDefs.Certificates) < 1 {
|
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
|
// We will be counting successes and errors
|
||||||
@@ -647,27 +698,18 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
|
|||||||
n := len(certDefs.Certificates)
|
n := len(certDefs.Certificates)
|
||||||
// No errors so far, now we can issue certificates
|
// No errors so far, now we can issue certificates
|
||||||
for i := range certDefs.Certificates {
|
for i := range certDefs.Certificates {
|
||||||
fmt.Printf("[%d/%d] Issuing %s... ", i+1, n, certDefs.Certificates[i].Name)
|
issued, err := issueSingleCertificate(certDefs.Certificates[i], i, n)
|
||||||
|
|
||||||
if dryRun {
|
|
||||||
fmt.Printf("(dry run)\n")
|
|
||||||
successes++
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
err = issueSingleCertificate(certDefs.Certificates[i])
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("error: %v\n", err)
|
fmt.Printf("error: %v\n", err)
|
||||||
errors++
|
errors++
|
||||||
} else {
|
} else {
|
||||||
if !verbose {
|
if issued {
|
||||||
fmt.Printf("done\n")
|
|
||||||
}
|
|
||||||
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()
|
err = SaveCAState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -677,40 +719,25 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func IssueCertificate(certDef CertificateDefinition, overwrite bool, dryRun bool, verbose bool) error {
|
func IssueCertificate(def CertificateDefinition) error {
|
||||||
err := LoadCA()
|
err := LoadCA()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
if certDef.Subject == "" {
|
if def.Subject == "" {
|
||||||
certDef.Subject = certDef.Name
|
def.Subject = def.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render templates in the certificae subject and SAN fields
|
// Render templates in the certificae subject and SAN fields
|
||||||
variables := map[string]string{"Name": certDef.Name}
|
variables := map[string]string{"Name": def.Name}
|
||||||
certDef.RenderTemplates(variables)
|
def.RenderTemplates(variables)
|
||||||
|
|
||||||
if dryRun {
|
_, err = issueSingleCertificate(def, 1, 1)
|
||||||
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
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper: check if string looks like a DN (contains at least CN=...)
|
// Helper: check if string looks like a DN (contains at least CN=...)
|
||||||
func isDNFormat(s string) bool {
|
func isDNFormat(s string) bool {
|
||||||
return len(s) > 0 && strings.Contains(s, "CN=")
|
return len(s) > 0 && strings.Contains(s, "CN=")
|
||||||
|
|||||||
29
certdb.go
29
certdb.go
@@ -36,41 +36,42 @@ type CertificateRecord struct {
|
|||||||
|
|
||||||
// Look for a certifcate by its name
|
// Look for a certifcate by its name
|
||||||
func (c *CAState) FindByName(name string, all bool) *CertificateRecord {
|
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 {
|
if cert.RevokedAt != "" && !all {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if cert.Name == name {
|
if cert.Name == name {
|
||||||
return &cert
|
return cert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look for a certificate by its serial
|
// Look for a certificate by its serial
|
||||||
func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord {
|
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 {
|
if cert.RevokedAt != "" && !all {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if cert.Serial == serial {
|
if cert.Serial == serial {
|
||||||
return &cert
|
return cert
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// func caStatePath() string {
|
|
||||||
// return filepath.Join(filepath.Dir(caConfigPath), caConfig.GetStateFileName())
|
|
||||||
// }
|
|
||||||
|
|
||||||
// LoadCAState loads the CA state from a JSON file
|
// LoadCAState loads the CA state from a JSON file
|
||||||
func LoadCAState() error {
|
func LoadCAState() error {
|
||||||
fmt.Printf("Loading CA state from %s\n", caStatePath)
|
fmt.Printf("Loading CA state from %s\n", caStatePath)
|
||||||
f, err := os.Open(caStatePath)
|
f, err := os.Open(caStatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
// File does not exist, treat as empty state
|
||||||
|
caState = &CAState{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
@@ -97,8 +98,7 @@ func SaveCAState() error {
|
|||||||
// UpdateCAStateAfterIssue updates the CA state JSON after issuing a certificate
|
// 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 {
|
func (s *CAState) UpdateCAStateAfterIssue(serialType, name string, subject string, certType string, serialNumber any, validity time.Duration) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error.\n")
|
return fmt.Errorf("CAState is nil in UpdateCAStateAfterIssue. This indicates a programming error")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
issued := time.Now().UTC().Format(time.RFC3339)
|
issued := time.Now().UTC().Format(time.RFC3339)
|
||||||
expires := time.Now().Add(validity).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) {
|
func (s *CAState) AddCertificate(name, subject, certType, issued, expires, serial string) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n")
|
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in AddCertificate. This indicates a programming error.\n")
|
||||||
os.Exit(1)
|
return
|
||||||
}
|
}
|
||||||
rec := CertificateRecord{
|
rec := CertificateRecord{
|
||||||
Name: name,
|
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
|
// 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 {
|
func (s *CAState) RevokeCertificate(serial string, reason int) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
fmt.Fprintf(os.Stderr, "FATAL: CAState is nil in RevokeCertificate. This indicates a programming error.\n")
|
return fmt.Errorf("CAState is nil in RevokeCertificate. This indicates a programming error")
|
||||||
os.Exit(1)
|
|
||||||
}
|
}
|
||||||
revoked := false
|
revoked := false
|
||||||
revokedAt := time.Now().UTC().Format(time.RFC3339)
|
revokedAt := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|||||||
26
main.go
26
main.go
@@ -7,12 +7,10 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
|
||||||
|
|
||||||
// Global flags available to all commands
|
// Global flags available to all commands
|
||||||
var overwrite bool
|
|
||||||
var dryRun bool
|
var dryRun bool
|
||||||
var verbose bool
|
var verbose bool
|
||||||
|
var Version = "v0.4.0"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
@@ -48,7 +46,6 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Define persistent flags (global for all commands)
|
// 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(&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().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")
|
rootCmd.PersistentFlags().StringVar(&caConfigPath, "config", "ca_config.hcl", "Path to CA configuration file")
|
||||||
@@ -74,10 +71,13 @@ func main() {
|
|||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
for _, certDef := range caState.Certificates {
|
for _, certDef := range caState.Certificates {
|
||||||
if certDef.RevokedAt != "" {
|
if certDef.RevokedAt != "" && !listRevoked {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Printf("Certificate %s\n", certDef.Name)
|
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",
|
fmt.Printf("\tSubject: %s\n\tType: %s\n\tIssued at: %s\n",
|
||||||
certDef.Subject, certDef.Type, certDef.Issued)
|
certDef.Subject, certDef.Type, certDef.Issued)
|
||||||
}
|
}
|
||||||
@@ -97,7 +97,7 @@ func main() {
|
|||||||
Type: certType,
|
Type: certType,
|
||||||
Validity: validity,
|
Validity: validity,
|
||||||
SAN: san,
|
SAN: san,
|
||||||
}, overwrite, dryRun, verbose)
|
})
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||||
@@ -122,7 +122,7 @@ func main() {
|
|||||||
Short: "Provision certificates from a batch file (HCL)",
|
Short: "Provision certificates from a batch file (HCL)",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
|
||||||
err := ProvisionCertificates(provisionFile, overwrite, false, verbose)
|
err := ProvisionCertificates(provisionFile)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||||
@@ -225,7 +225,7 @@ func main() {
|
|||||||
Use: "version",
|
Use: "version",
|
||||||
Short: "Show version information",
|
Short: "Show version information",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
fmt.Printf("lab-ca version: %s\n", Version)
|
fmt.Printf("lab-ca version: %s\n", getVersionDescription())
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rootCmd.AddCommand(versionCmd)
|
rootCmd.AddCommand(versionCmd)
|
||||||
@@ -235,15 +235,23 @@ func main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getVersionDescription() string {
|
||||||
|
if Version == "" {
|
||||||
|
return "no version information was compiled in"
|
||||||
|
}
|
||||||
|
return Version
|
||||||
|
}
|
||||||
|
|
||||||
func printMainHelp() {
|
func printMainHelp() {
|
||||||
fmt.Printf("lab-ca - Certificate Authority Utility\n")
|
fmt.Printf("lab-ca - Certificate Authority Utility\n")
|
||||||
fmt.Printf("Version: %s\n", Version)
|
fmt.Printf("Version: %s\n", getVersionDescription())
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Usage:")
|
fmt.Println("Usage:")
|
||||||
fmt.Println(" lab-ca <command> [options]")
|
fmt.Println(" lab-ca <command> [options]")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Available commands:")
|
fmt.Println("Available commands:")
|
||||||
fmt.Println(" initca Generate a new CA certificate and key")
|
fmt.Println(" initca Generate a new CA certificate and key")
|
||||||
|
fmt.Println(" list List issued certificates")
|
||||||
fmt.Println(" issue Issue a new certificate")
|
fmt.Println(" issue Issue a new certificate")
|
||||||
fmt.Println(" provision Provision certificates from a batch file (HCL)")
|
fmt.Println(" provision Provision certificates from a batch file (HCL)")
|
||||||
fmt.Println(" revoke Revoke a certificate by name or serial number")
|
fmt.Println(" revoke Revoke a certificate by name or serial number")
|
||||||
|
|||||||
13
run-test-2.sh
Executable file
13
run-test-2.sh
Executable 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
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
LAB_CA="./lab-ca"
|
LAB_CA="build/lab-ca"
|
||||||
PROVISION_CONFIG="examples/example-certificates.hcl"
|
PROVISION_CONFIG="examples/example-certificates.hcl"
|
||||||
# Build and install
|
# Build and install
|
||||||
# Build script for lab-ca with version injection from git tag
|
# Build script for lab-ca with version injection from git tag
|
||||||
|
|||||||
Reference in New Issue
Block a user