Compare commits
30 Commits
v0.2.1
...
a2528e0526
Author | SHA1 | Date | |
---|---|---|---|
a2528e0526 | |||
9b0d1eceae | |||
45dfdf0afc | |||
6e69377d1a | |||
8114d667ec | |||
176901d960 | |||
1991963cab | |||
b0f0467346 | |||
028788f357 | |||
090fb4b423 | |||
eb5c5c0e43 | |||
8181ac8287 | |||
3fe908226d | |||
8a36588c62 | |||
90ce7edd28 | |||
11bed9c8b1 | |||
2fe228858f | |||
e9d2634819 | |||
9d226df44f | |||
71412ace5e | |||
2350e3cbc1 | |||
d045938ff8 | |||
72424379e0 | |||
54296f7526 | |||
6682be6eb1 | |||
9b7b995e97 | |||
b387a016be | |||
e4469fde96 | |||
bea0285007 | |||
a8308e0f4f |
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
|
6
.gitignore
vendored
6
.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.
|
||||||
@@ -13,5 +14,6 @@ lab-ca*
|
|||||||
# Exclude MacOS Finder metadata files
|
# Exclude MacOS Finder metadata files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
# Exclude default certificate and private key files directories
|
# Exclude default certificate and private key files directories
|
||||||
/certs/
|
/ca
|
||||||
/private/
|
# Don't share VS Code files
|
||||||
|
.vscode/
|
||||||
|
200
README.md
200
README.md
@@ -2,22 +2,23 @@
|
|||||||
|
|
||||||
This repository contains a simple CLI tool for managing a Certificate Authority (CA).
|
This repository contains a simple CLI tool for managing a Certificate Authority (CA).
|
||||||
|
|
||||||
It has been designed to be as easy to use as possible and provides a basic set of CA features:
|
It is designed to be easy to use and provides a basic set of CA features:
|
||||||
|
|
||||||
- Create a CA and a self-signed CA certificate
|
- Create a CA and a self-signed CA certificate
|
||||||
- Create and sign a few most common types of certificates:
|
- Create and sign common types of certificates:
|
||||||
- Server certificate
|
- Server certificate
|
||||||
- Client certificate
|
- Client certificate
|
||||||
- Code signing certificate
|
- Code signing certificate
|
||||||
- Email certificate
|
- Email certificate
|
||||||
- Sign a CSR to create a certificate
|
|
||||||
- Revoke a certificate
|
- Revoke a certificate
|
||||||
- List issued certificates
|
- List issued certificates
|
||||||
- Create a CRL (Certificate Revocation List)
|
- Create a CRL (Certificate Revocation List)
|
||||||
|
|
||||||
|
> **NOTE:** Certificate types can be combined (e.g. `server,client`).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
The tool is designed to be used from the command line. It has a simple command structure:
|
The tool is used from the command line. It has a simple command structure:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
lab-ca <command> [options]
|
lab-ca <command> [options]
|
||||||
@@ -25,23 +26,26 @@ lab-ca <command> [options]
|
|||||||
|
|
||||||
The main commands available are:
|
The main commands available are:
|
||||||
|
|
||||||
- `initca` — Initialize a new CA and create a self-signed CA certificate.
|
- `initca` — Initialize a new CA and create a self-signed CA certificate and key.
|
||||||
- `issue` — Issue a new certificate signed by the CA.
|
- `issue` — Issue a new certificate signed by the CA (single certificate, command-line options).
|
||||||
- `provision` — Provision multiple certificates from a batch file (HCL) in one go.
|
- `provision` — Provision multiple certificates from a batch file (HCL) in one go.
|
||||||
- `revoke` — Revoke a certificate by name or serial number.
|
- `revoke` — Revoke a certificate by name or serial number.
|
||||||
- `crl` — Generate a Certificate Revocation List (CRL) from revoked certificates.
|
- `crl` — Generate a Certificate Revocation List (CRL) from revoked certificates.
|
||||||
|
- `list` — List issued certificates (optionally including revoked).
|
||||||
- `version` — Show version information for the tool.
|
- `version` — Show version information for the tool.
|
||||||
|
|
||||||
Run the command with `-h` or `--help` or without any arguments to see the usage information. Each command has its own set of options, arguments, and a help message.
|
Run the command with `-h` or `--help` or without any arguments to see usage information. Each command has its own set of options, arguments, and a help message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### CA Initialization
|
### CA Initialization
|
||||||
|
|
||||||
Create a new CA configuration file:
|
Create a new CA configuration file (HCL):
|
||||||
|
|
||||||
```
|
```hcl
|
||||||
ca "example_ca" {
|
ca "example_ca" {
|
||||||
name = "Example CA"
|
name = "Example CA"
|
||||||
country = "PL"
|
country = "US"
|
||||||
organization = "ACME Corp"
|
organization = "ACME Corp"
|
||||||
key_size = 4096
|
key_size = 4096
|
||||||
validity = "10y"
|
validity = "10y"
|
||||||
@@ -49,41 +53,67 @@ ca "example_ca" {
|
|||||||
paths {
|
paths {
|
||||||
certificates = "certs"
|
certificates = "certs"
|
||||||
private_keys = "private"
|
private_keys = "private"
|
||||||
|
state_file = "ca_state.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **NOTE:** `lab-ca` uses HCL (HashiCorp Configuration Language) for configuration files. You can find more information about HCL [here](https://github.com/hashicorp/hcl).
|
- The `ca` block's label is used as a logical name for the CA and for the default state file name.
|
||||||
|
- The `paths` block defines where certificates, private keys, and the CA state file are stored.
|
||||||
|
- On Linux/macOS, the private keys directory is created with `0700` permissions.
|
||||||
|
- The command does not encrypt private keys. Do not use in production.
|
||||||
|
|
||||||
The `ca` block's label has no function, but may be used to identify the CA in the future.
|
---
|
||||||
|
|
||||||
The following attributes are available:
|
### Listing Certificates
|
||||||
|
|
||||||
- `name` - the name of the CA
|
List all issued (non-revoked) certificates:
|
||||||
- `country` - the country of the CA
|
|
||||||
- `organization` - the organization of the CA
|
|
||||||
- `organizational_unit` - the organizational unit of the CA (optional)
|
|
||||||
- `locality` - the locality of the CA (optional)
|
|
||||||
- `province` - the province of the CA (optional)
|
|
||||||
- `email` - the email address of the CA (optional)
|
|
||||||
- `key_size` - the size of the CA key in bits (default: 4096)
|
|
||||||
- `validity` - the validity period of the CA certificate (default: 10 years)
|
|
||||||
- `paths` - paths to store certificates and private keys
|
|
||||||
|
|
||||||
The `paths` block defines where the command will store the generated certificates and private keys. On Linux and macOS, the directory specified for private keys will be created with `0700` permissions. However, the command does not check if the directory has correct permissions, so you should ensure that the directory is not accessible by other users. On Windows, both directories will be created with the default ACL for the current user. You have to secure the private keys directory yourself.
|
```bash
|
||||||
|
lab-ca list
|
||||||
|
```
|
||||||
|
|
||||||
> **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment.
|
To include revoked certificates:
|
||||||
|
|
||||||
## Certificate Issuance and Provisioning
|
```bash
|
||||||
|
lab-ca list --revoked
|
||||||
|
```
|
||||||
|
|
||||||
To issue a new certificate, you can use the `issue` command and specify the certificate definition on the command line, or use the `provision` command to provide a file with multiple certificate definitions for batch processing.
|
---
|
||||||
|
|
||||||
The definition file also uses HCL syntax. Here is an example of a certificate definition file:
|
### Issuing a Certificate
|
||||||
|
|
||||||
|
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]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--name` (required): Name for the certificate and key files (used as subject if `--subject` is omitted)
|
||||||
|
- `--subject`: Subject Common Name or full DN (optional, defaults to `--name`)
|
||||||
|
- `--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
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Provisioning Certificates (Batch)
|
||||||
|
|
||||||
|
Provision multiple certificates from a batch file (HCL):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lab-ca provision --file <certificates.hcl> [--overwrite] [--verbose]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example HCL Provisioning File
|
||||||
|
|
||||||
```hcl
|
```hcl
|
||||||
defaults {
|
defaults {
|
||||||
subject = "{{ .Name }}.example.org"
|
subject = "{{ .Name }}.example.org"
|
||||||
type = "server"
|
type = "server,client"
|
||||||
validity = "1y"
|
validity = "1y"
|
||||||
san = ["DNS:{{ .Name }}.example.org"]
|
san = ["DNS:{{ .Name }}.example.org"]
|
||||||
}
|
}
|
||||||
@@ -95,26 +125,116 @@ variables = {
|
|||||||
|
|
||||||
certificate "service1" {
|
certificate "service1" {
|
||||||
# from default: subject = "{{ .Name }}.example.org"
|
# from default: subject = "{{ .Name }}.example.org"
|
||||||
# from default: type = "server"
|
# from default: type = "server,client"
|
||||||
# from default: validity = "1y"
|
# from default: validity = "1y"
|
||||||
# from default: san = ["DNS:{{ .Name }}.example.org"]
|
# from default: san = ["DNS:service1.example.org"]
|
||||||
}
|
}
|
||||||
|
|
||||||
certificate "service2" {
|
certificate "service2" {
|
||||||
subject = "{{ .Name }}.example.net"
|
subject = "{{ .Name }}.{{ .Domain }}" # result: service2.example.net
|
||||||
# from default: type = "server"
|
san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:service2.example.net" ]
|
||||||
# from default: validity = "1y"
|
|
||||||
san = ["DNS:{{ .Name }}.example.net"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
certificate "service3" {}
|
certificate "service3" {}
|
||||||
|
|
||||||
certificate "service4" {
|
certificate "user1" {
|
||||||
subject = "{{ .Name }}.{{ .Domain }}"
|
subject = "CN=User One,emailAddress=user1@example.org,O=Example,C=US"
|
||||||
san = ["DNS:{{ .Name }}.{{ .Domain }}"]
|
type = "client,email"
|
||||||
|
validity = "1y"
|
||||||
|
san = ["email:user1@example.net"]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Values specified in the `defaults` block will be used for all certificates unless overridden in individual certificate definitions. Go-style template syntax is also supported, so you can use `{{ .Name }}` to refer to the certificate name, and variables from the `variables` map can be used in templates as well.
|
- The `defaults` block provides default values for all certificates unless overridden.
|
||||||
|
- The `variables` map can be used in Go template expressions in any field.
|
||||||
|
- Each `certificate` block defines a certificate to be issued. The block label is available as `{{ .Name }}` in templates.
|
||||||
|
- Fields:
|
||||||
|
- `subject`: Subject string (can be a template)
|
||||||
|
- `type`: Comma-separated usages (server, client, code-signing, email)
|
||||||
|
- `validity`: Validity period (e.g. `1y`, `6m`, `30d`)
|
||||||
|
- `san`: List of SANs (e.g. `DNS:...`, `IP:...`, `email:...`)
|
||||||
|
|
||||||
You can use DNS or IP SANs for server certificates (`server` and `server-only`), and email SANs for email certificates (`email`). The command will check if the SAN is valid based on the type of certificate.
|
---
|
||||||
|
|
||||||
|
### Revoking a Certificate
|
||||||
|
|
||||||
|
Revoke a certificate by name or serial number:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lab-ca revoke --name <name> [--reason <reason>]
|
||||||
|
lab-ca revoke --serial <serial> [--reason <reason>]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--reason` can be one of: `unspecified`, `keyCompromise`, `caCompromise`, `affiliationChanged`, `superseded`, `cessationOfOperation`, `certificateHold`, `removeFromCRL` (default: `cessationOfOperation`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Generating a CRL
|
||||||
|
|
||||||
|
Generate a Certificate Revocation List (CRL) from revoked certificates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lab-ca crl [--crl-file <path>] [--validity-days <days>]
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--crl-file`: Output path for CRL file (default: `crl.pem`)
|
||||||
|
- `--validity-days`: CRL validity in days (default: 30)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Version
|
||||||
|
|
||||||
|
Show version information:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lab-ca version
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration and Templates
|
||||||
|
|
||||||
|
- All configuration and provisioning files use HCL (HashiCorp Configuration Language).
|
||||||
|
- Go template syntax is supported in `subject`, `san`, and other string fields. The following variables are available:
|
||||||
|
- `.Name`: The certificate name (from the block label)
|
||||||
|
- Any key from the `variables` map
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
subject = "{{ .Name }}.{{ .Domain }}"
|
||||||
|
san = ["DNS:{{ .Name }}.{{ .Domain }}"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Certificate Types and SANs
|
||||||
|
|
||||||
|
- `server`: For server certificates. SANs should be DNS or IP.
|
||||||
|
- `client`: For client certificates. SANs can be email or DNS.
|
||||||
|
- `email`: For S/MIME/email certificates. SANs should be email.
|
||||||
|
- `code-signing`: For code signing certificates.
|
||||||
|
|
||||||
|
The tool checks that SANs are valid for the selected certificate type(s). Certificate usage can be combined (e.g. `server,client`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example: Real-World Provisioning File
|
||||||
|
|
||||||
|
See `examples/example-certificates.hcl` for a more advanced provisioning file with templates and variables.
|
||||||
|
|
||||||
|
## Building the Tool
|
||||||
|
|
||||||
|
The repository includes a `build.sh` script to build the CLI tool. It updates the version in `version.go` and builds the binary.
|
||||||
|
|
||||||
|
To ignore changes made to `version.go` in Git, you can run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git update-index --assume-unchanged version.go
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The tool does not encrypt private keys. Protect your private keys directory.
|
||||||
|
- Not intended for production use.
|
||||||
|
- For more information about HCL, see: https://github.com/hashicorp/hcl
|
||||||
|
19
build.sh
19
build.sh
@@ -6,4 +6,21 @@ if [ $? -eq 0 ]; then
|
|||||||
else
|
else
|
||||||
VERSION="dev"
|
VERSION="dev"
|
||||||
fi
|
fi
|
||||||
go build -ldflags "-X main.Version=$VERSION" -o lab-ca
|
|
||||||
|
# Hardcode the version into main.go
|
||||||
|
sed -i '' "s/^var Version = .*/var Version = \"$VERSION\"/" version.go
|
||||||
|
|
||||||
|
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
|
||||||
|
go build -o $OUTPUT_DIR/lab-ca
|
||||||
|
312
ca.go
312
ca.go
@@ -24,9 +24,10 @@ import (
|
|||||||
type Paths struct {
|
type Paths struct {
|
||||||
Certificates string `hcl:"certificates"`
|
Certificates string `hcl:"certificates"`
|
||||||
PrivateKeys string `hcl:"private_keys"`
|
PrivateKeys string `hcl:"private_keys"`
|
||||||
|
StatePath string `hcl:"state_file"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type _CAConfig struct {
|
type CAConfig struct {
|
||||||
Label string `hcl:",label"`
|
Label string `hcl:",label"`
|
||||||
Name string `hcl:"name"`
|
Name string `hcl:"name"`
|
||||||
Country string `hcl:"country"`
|
Country string `hcl:"country"`
|
||||||
@@ -41,12 +42,12 @@ type _CAConfig struct {
|
|||||||
Paths Paths `hcl:"paths,block"`
|
Paths Paths `hcl:"paths,block"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *_CAConfig) StateName() string {
|
func (c *CAConfig) GetStateFileName() string {
|
||||||
return c.Label + "_state.json"
|
return c.Label + "_state.json"
|
||||||
}
|
}
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
Current _CAConfig `hcl:"ca,block"`
|
CA CAConfig `hcl:"ca,block"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CertificateDefinition struct {
|
type CertificateDefinition struct {
|
||||||
@@ -57,7 +58,7 @@ type CertificateDefinition struct {
|
|||||||
SAN []string `hcl:"san,optional"`
|
SAN []string `hcl:"san,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (def *CertificateDefinition) fillDefaultValues(defaults *CertificateDefaults) {
|
func (def *CertificateDefinition) FillDefaultValues(defaults *CertificateDefaults) {
|
||||||
if defaults == nil {
|
if defaults == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -71,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...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,17 +143,27 @@ func (c *Certificates) LoadFromFile(path string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Global CA configuration and state variables
|
// Global CA configuration and state variables
|
||||||
var CAConfigPath string
|
var caConfigPath string
|
||||||
var CAState *_CAState
|
var caConfig *CAConfig
|
||||||
var CAConfig *_CAConfig
|
|
||||||
var CAKey *rsa.PrivateKey
|
var caStatePath string
|
||||||
var CACert *x509.Certificate
|
var caState *CAState
|
||||||
|
|
||||||
|
var caKey *rsa.PrivateKey
|
||||||
|
var caCert *x509.Certificate
|
||||||
|
|
||||||
// LoadCAConfig parses and validates the CA config from the given path and stores it in the CAConfig global variable
|
// LoadCAConfig parses and validates the CA config from the given path and stores it in the CAConfig global variable
|
||||||
func LoadCAConfig() error {
|
func LoadCAConfig() error {
|
||||||
fmt.Printf("Loading CA config from %s\n", CAConfigPath)
|
if verbose {
|
||||||
|
cwd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Printf("The current working dirctory: \"%s\"\n", cwd)
|
||||||
|
fmt.Printf("Loading CA config from \"%s\"... ", caConfigPath)
|
||||||
|
}
|
||||||
parser := hclparse.NewParser()
|
parser := hclparse.NewParser()
|
||||||
file, diags := parser.ParseHCLFile(CAConfigPath)
|
file, diags := parser.ParseHCLFile(caConfigPath)
|
||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
return fmt.Errorf("failed to parse HCL: %s", diags.Error())
|
return fmt.Errorf("failed to parse HCL: %s", diags.Error())
|
||||||
}
|
}
|
||||||
@@ -161,16 +172,24 @@ func LoadCAConfig() error {
|
|||||||
if diags.HasErrors() {
|
if diags.HasErrors() {
|
||||||
return fmt.Errorf("failed to decode HCL: %s", diags.Error())
|
return fmt.Errorf("failed to decode HCL: %s", diags.Error())
|
||||||
}
|
}
|
||||||
if (_CAConfig{}) == config.Current {
|
if (CAConfig{}) == config.CA {
|
||||||
return fmt.Errorf("no 'ca' block found in config file")
|
return fmt.Errorf("no 'ca' block found in config file")
|
||||||
}
|
}
|
||||||
if config.Current.Label == "" {
|
if config.CA.Label == "" {
|
||||||
return fmt.Errorf("the 'ca' block must have a label (e.g., ca \"mylabel\" {...})")
|
return fmt.Errorf("the 'ca' block must have a label (e.g., ca \"mylabel\" {...})")
|
||||||
}
|
}
|
||||||
if err := config.Current.Validate(); err != nil {
|
if err := config.CA.Validate(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
CAConfig = &config.Current
|
|
||||||
|
// If the state file is specified as an absolute path, use it directly.
|
||||||
|
if filepath.IsAbs(config.CA.Paths.StatePath) {
|
||||||
|
caStatePath = config.CA.Paths.StatePath
|
||||||
|
} else {
|
||||||
|
caStatePath = filepath.Join(filepath.Dir(caConfigPath), config.CA.Paths.StatePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
caConfig = &config.CA
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,8 +203,8 @@ func LoadCA() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load CA key and certificate
|
// Load CA key and certificate
|
||||||
caCertPath := filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem")
|
caCertPath := filepath.Join(caConfig.Paths.Certificates, "ca_cert.pem")
|
||||||
caKeyPath := filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem")
|
caKeyPath := filepath.Join(caConfig.Paths.PrivateKeys, "ca_key.pem")
|
||||||
|
|
||||||
caCertPEM, err := os.ReadFile(caCertPath)
|
caCertPEM, err := os.ReadFile(caCertPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -200,7 +219,7 @@ func LoadCA() error {
|
|||||||
if caCertBlock == nil {
|
if caCertBlock == nil {
|
||||||
return fmt.Errorf("failed to parse CA certificate PEM")
|
return fmt.Errorf("failed to parse CA certificate PEM")
|
||||||
}
|
}
|
||||||
CACert, err = x509.ParseCertificate(caCertBlock.Bytes)
|
caCert, err = x509.ParseCertificate(caCertBlock.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse CA certificate: %v", err)
|
return fmt.Errorf("failed to parse CA certificate: %v", err)
|
||||||
}
|
}
|
||||||
@@ -208,7 +227,7 @@ func LoadCA() error {
|
|||||||
if caKeyBlock == nil {
|
if caKeyBlock == nil {
|
||||||
return fmt.Errorf("failed to parse CA key PEM")
|
return fmt.Errorf("failed to parse CA key PEM")
|
||||||
}
|
}
|
||||||
CAKey, err = x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
|
caKey, err = x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to parse CA private key: %v", err)
|
return fmt.Errorf("failed to parse CA private key: %v", err)
|
||||||
}
|
}
|
||||||
@@ -271,15 +290,10 @@ func parseValidity(validity string) (time.Duration, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SavePEM(filename string, data []byte, secure bool, overwrite 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 {
|
||||||
@@ -297,7 +311,7 @@ func (p *Paths) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *_CAConfig) Validate() error {
|
func (c *CAConfig) Validate() error {
|
||||||
if c.Name == "" {
|
if c.Name == "" {
|
||||||
return fmt.Errorf("CA 'name' is required")
|
return fmt.Errorf("CA 'name' is required")
|
||||||
}
|
}
|
||||||
@@ -321,38 +335,39 @@ func (c *_CAConfig) Validate() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func InitCA(overwrite bool) error {
|
func InitCA() error {
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
err = LoadCAConfig()
|
err = LoadCAConfig()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: %v\n", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create certificates directory with 0755, private keys with 0700
|
// Create certificates directory with 0755, private keys with 0700
|
||||||
if CAConfig.Paths.Certificates != "" {
|
if caConfig.Paths.Certificates != "" {
|
||||||
if err := os.MkdirAll(CAConfig.Paths.Certificates, 0755); err != nil {
|
if err := os.MkdirAll(caConfig.Paths.Certificates, 0755); err != nil {
|
||||||
fmt.Printf("Error creating certificates directory '%s': %v\n", CAConfig.Paths.Certificates, err)
|
fmt.Printf("Error creating certificates directory '%s': %v\n", caConfig.Paths.Certificates, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if CAConfig.Paths.PrivateKeys != "" {
|
if caConfig.Paths.PrivateKeys != "" {
|
||||||
if err := os.MkdirAll(CAConfig.Paths.PrivateKeys, 0700); err != nil {
|
if err := os.MkdirAll(caConfig.Paths.PrivateKeys, 0700); err != nil {
|
||||||
fmt.Printf("Error creating private keys directory '%s': %v\n", CAConfig.Paths.PrivateKeys, err)
|
fmt.Printf("Error creating private keys directory '%s': %v\n", caConfig.Paths.PrivateKeys, err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize CAState empty state with serial starting from 1
|
// Initialize CAState empty state with serial starting from 1
|
||||||
CAState = &_CAState{
|
caState = &CAState{
|
||||||
Serial: 1, // Start serial from 1
|
Serial: 1, // Start serial from 1
|
||||||
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
UpdatedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
Certificates: []CertificateRecord{},
|
Certificates: []CertificateRecord{},
|
||||||
}
|
}
|
||||||
|
|
||||||
keySize := CAConfig.KeySize
|
keySize := caConfig.KeySize
|
||||||
if keySize == 0 {
|
if keySize == 0 {
|
||||||
keySize = 4096
|
keySize = 4096
|
||||||
}
|
}
|
||||||
@@ -366,40 +381,41 @@ func InitCA(overwrite bool) error {
|
|||||||
return fmt.Errorf("failed to generate serial number: %v", err)
|
return fmt.Errorf("failed to generate serial number: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if CAConfig.Validity == "" {
|
if caConfig.Validity == "" {
|
||||||
CAConfig.Validity = "5y" // Use default validity of 5 years
|
caConfig.Validity = "5y" // Use default validity of 5 years
|
||||||
}
|
}
|
||||||
|
|
||||||
validity, err := parseValidity(CAConfig.Validity)
|
validity, err := parseValidity(caConfig.Validity)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// Store CA certificate creation time
|
// Store CA certificate creation time
|
||||||
CAState.CreatedAt = now.UTC().Format(time.RFC3339)
|
caState.CreatedAt = now.UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
tmpl := x509.Certificate{
|
tmpl := x509.Certificate{
|
||||||
SerialNumber: serialNumber,
|
SerialNumber: serialNumber,
|
||||||
Subject: pkix.Name{
|
Subject: pkix.Name{
|
||||||
Country: []string{CAConfig.Country},
|
Country: []string{caConfig.Country},
|
||||||
Organization: []string{CAConfig.Organization},
|
Organization: []string{caConfig.Organization},
|
||||||
OrganizationalUnit: optionalSlice(CAConfig.OrganizationalUnit),
|
OrganizationalUnit: optionalSlice(caConfig.OrganizationalUnit),
|
||||||
Locality: optionalSlice(CAConfig.Locality),
|
Locality: optionalSlice(caConfig.Locality),
|
||||||
Province: optionalSlice(CAConfig.Province),
|
Province: optionalSlice(caConfig.Province),
|
||||||
CommonName: CAConfig.Name,
|
CommonName: caConfig.Name,
|
||||||
},
|
},
|
||||||
NotBefore: now,
|
NotBefore: now,
|
||||||
NotAfter: now.Add(validity),
|
NotAfter: now.Add(validity),
|
||||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign,
|
||||||
BasicConstraintsValid: true,
|
BasicConstraintsValid: true, // This is a CA certificate
|
||||||
IsCA: true,
|
IsCA: true, // This is a CA certificate
|
||||||
|
MaxPathLenZero: true, // Allow issuing end-entity certificates
|
||||||
}
|
}
|
||||||
// Add email if present
|
// Add email if present
|
||||||
if CAConfig.Email != "" {
|
if caConfig.Email != "" {
|
||||||
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
|
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
|
||||||
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
|
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
|
||||||
Value: CAConfig.Email,
|
Value: caConfig.Email,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||||
@@ -409,17 +425,17 @@ func InitCA(overwrite bool) error {
|
|||||||
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)})
|
||||||
|
|
||||||
if err := SavePEM(filepath.Join(CAConfig.Paths.Certificates, "ca_cert.pem"), certPEM, false, overwrite); err != nil {
|
if err := SavePEM(filepath.Join(caConfig.Paths.Certificates, "ca_cert.pem"), certPEM, false); err != nil {
|
||||||
fmt.Println("Error saving CA certificate:", err)
|
fmt.Println("Error saving CA certificate:", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := SavePEM(filepath.Join(CAConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true, overwrite); err != nil {
|
if err := SavePEM(filepath.Join(caConfig.Paths.PrivateKeys, "ca_key.pem"), keyPEM, true); err != nil {
|
||||||
fmt.Println("Error saving CA key:", err)
|
fmt.Println("Error saving CA key:", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// set last updated time in the CAState
|
// set last updated time in the CAState
|
||||||
CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
caState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
// Save the state
|
// Save the state
|
||||||
|
|
||||||
err = SaveCAState()
|
err = SaveCAState()
|
||||||
@@ -432,17 +448,21 @@ func InitCA(overwrite bool) 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, overwrite, verbose bool) 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.
|
||||||
|
if caState.FindByName(def.Name, false) != nil {
|
||||||
|
return false, fmt.Errorf("certificate %s already exists and is valid.", def.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize Subject if not specified
|
// Initialize Subject if not specified
|
||||||
@@ -451,18 +471,44 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -473,7 +519,7 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
|
|||||||
|
|
||||||
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
|
||||||
@@ -492,6 +538,8 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
|
|||||||
NotBefore: dateIssued,
|
NotBefore: dateIssued,
|
||||||
NotAfter: expires,
|
NotAfter: expires,
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
IsCA: false,
|
||||||
|
BasicConstraintsValid: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range def.SAN {
|
for _, s := range def.SAN {
|
||||||
@@ -504,71 +552,96 @@ func issueSingleCertificate(def CertificateDefinition, overwrite, verbose bool)
|
|||||||
} 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch def.Type {
|
// Split usage types by comma
|
||||||
|
types := strings.SplitSeq(def.Type, ",")
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{}
|
||||||
|
|
||||||
|
// Collect selected usage types
|
||||||
|
for certType := range types {
|
||||||
|
switch certType {
|
||||||
case "client":
|
case "client":
|
||||||
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
|
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageClientAuth)
|
||||||
case "server":
|
case "server":
|
||||||
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
|
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageServerAuth)
|
||||||
case "server-only":
|
|
||||||
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
|
|
||||||
case "code-signing":
|
case "code-signing":
|
||||||
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}
|
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageCodeSigning)
|
||||||
case "email":
|
case "email":
|
||||||
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}
|
certTmpl.ExtKeyUsage = append(certTmpl.ExtKeyUsage, x509.ExtKeyUsageEmailProtection)
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("unknown certificate type. Use one of: client, server, server-only, 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)})
|
||||||
|
|
||||||
basename := def.Name
|
certFile := filepath.Join(caConfig.Paths.Certificates, def.Name+".crt.pem")
|
||||||
if basename == "" {
|
keyFile := filepath.Join(caConfig.Paths.PrivateKeys, def.Name+".key.pem")
|
||||||
basename = def.Subject
|
if err := SavePEM(certFile, certPEM, false); err != nil {
|
||||||
|
return false, fmt.Errorf("error saving certificate: %v", err)
|
||||||
}
|
}
|
||||||
certFile := filepath.Join(CAConfig.Paths.Certificates, basename+".crt.pem")
|
if err := SavePEM(keyFile, keyPEM, true); err != nil {
|
||||||
keyFile := filepath.Join(CAConfig.Paths.PrivateKeys, basename+".key.pem")
|
return false, fmt.Errorf("error saving key: %v", err)
|
||||||
if err := SavePEM(certFile, certPEM, false, overwrite); err != nil {
|
|
||||||
return fmt.Errorf("error saving certificate: %v", err)
|
|
||||||
}
|
}
|
||||||
if err := SavePEM(keyFile, keyPEM, true, overwrite); err != nil {
|
err = caState.UpdateCAStateAfterIssue(
|
||||||
return fmt.Errorf("error saving key: %v", err)
|
caConfig.SerialType,
|
||||||
|
def.Name,
|
||||||
|
def.Subject,
|
||||||
|
def.Type,
|
||||||
|
serialNumber,
|
||||||
|
validityDur,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("error updating CA state: %v", err)
|
||||||
}
|
}
|
||||||
if verbose {
|
|
||||||
fmt.Printf(`
|
if !verbose {
|
||||||
Certificate:
|
fmt.Printf("done.\n")
|
||||||
|
} else {
|
||||||
|
fmt.Printf(`done.
|
||||||
|
Certificate generated:
|
||||||
Name: %s
|
Name: %s
|
||||||
Subject: %s
|
Subject: %s
|
||||||
Type: %s
|
Type: %s
|
||||||
Validity: %s
|
Validity: %s
|
||||||
SAN: %v
|
SANs:
|
||||||
`,
|
`,
|
||||||
def.Name,
|
def.Name,
|
||||||
def.Subject,
|
def.Subject,
|
||||||
def.Type,
|
def.Type,
|
||||||
def.Validity,
|
def.Validity,
|
||||||
def.SAN,
|
|
||||||
)
|
)
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
CAState.UpdateCAStateAfterIssue(
|
}
|
||||||
CAConfig.SerialType,
|
}
|
||||||
basename,
|
|
||||||
serialNumber,
|
if err := SaveCAState(); err != nil {
|
||||||
validityDur,
|
// If saving CA state fails, we still return success for the certificate issuance
|
||||||
)
|
fmt.Printf("WARNING: %v\n", err)
|
||||||
return nil
|
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 {
|
||||||
@@ -598,7 +671,7 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
|
|||||||
// to render templates and fill missing fields from defaults
|
// to render templates and fill missing fields from defaults
|
||||||
for i := range certDefs.Certificates {
|
for i := range certDefs.Certificates {
|
||||||
// Fill missing fields from defaults, if provided
|
// Fill missing fields from defaults, if provided
|
||||||
certDefs.Certificates[i].fillDefaultValues(certDefs.Defaults)
|
certDefs.Certificates[i].FillDefaultValues(certDefs.Defaults)
|
||||||
// Render templates in the definition using the variables map
|
// Render templates in the definition using the variables map
|
||||||
// with added definition name.
|
// with added definition name.
|
||||||
variables := certDefs.Variables
|
variables := certDefs.Variables
|
||||||
@@ -612,29 +685,21 @@ func ProvisionCertificates(filePath string, overwrite bool, dryRun bool, verbose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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, len(certDefs.Certificates), 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], overwrite, verbose)
|
|
||||||
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 {
|
||||||
@@ -644,38 +709,23 @@ 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, overwrite, verbose)
|
|
||||||
|
|
||||||
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=...)
|
||||||
|
87
certdb.go
87
certdb.go
@@ -10,12 +10,11 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// _CAState represents the persisted CA state in JSON
|
// CAState represents the persisted CA state in JSON
|
||||||
type _CAState struct {
|
type CAState struct {
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
Serial int `json:"serial,omitempty"`
|
Serial int `json:"serial,omitempty"`
|
||||||
@@ -26,6 +25,8 @@ type _CAState struct {
|
|||||||
// CertificateRecord represents a single certificate record in the CA state
|
// CertificateRecord represents a single certificate record in the CA state
|
||||||
type CertificateRecord struct {
|
type CertificateRecord struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Type string `json:"type"`
|
||||||
Issued string `json:"issued"`
|
Issued string `json:"issued"`
|
||||||
Expires string `json:"expires"`
|
Expires string `json:"expires"`
|
||||||
Serial string `json:"serial"`
|
Serial string `json:"serial"`
|
||||||
@@ -33,21 +34,49 @@ type CertificateRecord struct {
|
|||||||
RevokeReason int `json:"revokeReason,omitempty"`
|
RevokeReason int `json:"revokeReason,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func caStatePath() string {
|
// Look for a certifcate by its name
|
||||||
return filepath.Join(filepath.Dir(CAConfigPath), CAConfig.StateName())
|
func (c *CAState) FindByName(name string, all bool) *CertificateRecord {
|
||||||
|
for i := range c.Certificates {
|
||||||
|
cert := &c.Certificates[i]
|
||||||
|
if cert.RevokedAt != "" && !all {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cert.Name == name {
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look for a certificate by its serial
|
||||||
|
func (c *CAState) FindBySerial(serial string, all bool) *CertificateRecord {
|
||||||
|
for i := range c.Certificates {
|
||||||
|
cert := &c.Certificates[i]
|
||||||
|
if cert.RevokedAt != "" && !all {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cert.Serial == serial {
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadCAState loads the CA state from a JSON file
|
// LoadCAState loads the CA state from a JSON file
|
||||||
func LoadCAState() error {
|
func LoadCAState() error {
|
||||||
path := caStatePath()
|
fmt.Printf("Loading CA state from %s\n", caStatePath)
|
||||||
fmt.Printf("Loading CA state from %s\n", path)
|
f, err := os.Open(caStatePath)
|
||||||
f, err := os.Open(path)
|
|
||||||
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()
|
||||||
CAState = &_CAState{}
|
caState = &CAState{}
|
||||||
if err := json.NewDecoder(f).Decode(CAState); err != nil {
|
if err := json.NewDecoder(f).Decode(caState); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -55,46 +84,47 @@ func LoadCAState() error {
|
|||||||
|
|
||||||
// SaveCAState saves the CA state to a JSON file
|
// SaveCAState saves the CA state to a JSON file
|
||||||
func SaveCAState() error {
|
func SaveCAState() error {
|
||||||
CAState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
caState.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
f, err := os.Create(caStatePath())
|
f, err := os.Create(caStatePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
enc := json.NewEncoder(f)
|
enc := json.NewEncoder(f)
|
||||||
enc.SetIndent("", " ")
|
enc.SetIndent("", " ")
|
||||||
return enc.Encode(CAState)
|
return enc.Encode(caState)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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, basename 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)
|
||||||
serialStr := ""
|
serialStr := ""
|
||||||
switch serialType {
|
switch serialType {
|
||||||
case "sequential":
|
case "sequential":
|
||||||
serialStr = fmt.Sprintf("%d", CAState.Serial)
|
serialStr = fmt.Sprintf("%d", caState.Serial)
|
||||||
CAState.Serial++
|
caState.Serial++
|
||||||
case "random":
|
case "random":
|
||||||
serialStr = fmt.Sprintf("%x", serialNumber)
|
serialStr = fmt.Sprintf("%x", serialNumber)
|
||||||
default:
|
default:
|
||||||
serialStr = fmt.Sprintf("%v", serialNumber)
|
serialStr = fmt.Sprintf("%v", serialNumber)
|
||||||
}
|
}
|
||||||
s.AddCertificate(basename, issued, expires, serialStr)
|
s.AddCertificate(name, subject, certType, issued, expires, serialStr)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *_CAState) AddCertificate(name, 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,
|
||||||
|
Subject: subject,
|
||||||
|
Type: certType,
|
||||||
Issued: issued,
|
Issued: issued,
|
||||||
Expires: expires,
|
Expires: expires,
|
||||||
Serial: serial,
|
Serial: serial,
|
||||||
@@ -103,10 +133,9 @@ func (s *_CAState) AddCertificate(name, issued, expires, serial string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
||||||
@@ -129,11 +158,11 @@ func (s *_CAState) RevokeCertificate(serial string, reason int) error {
|
|||||||
|
|
||||||
// GenerateCRL generates a CRL file from revoked certificates and writes it to the given path
|
// GenerateCRL generates a CRL file from revoked certificates and writes it to the given path
|
||||||
// validityDays defines the number of days for which the CRL is valid (NextUpdate - ThisUpdate)
|
// validityDays defines the number of days for which the CRL is valid (NextUpdate - ThisUpdate)
|
||||||
func (s *_CAState) GenerateCRL(crlPath string, validityDays int) error {
|
func (s *CAState) GenerateCRL(crlPath string, validityDays int) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return fmt.Errorf("CAState is nil in GenerateCRL")
|
return fmt.Errorf("CAState is nil in GenerateCRL")
|
||||||
}
|
}
|
||||||
if CACert == nil || CAKey == nil {
|
if caCert == nil || caKey == nil {
|
||||||
return fmt.Errorf("CA certificate or key not loaded")
|
return fmt.Errorf("CA certificate or key not loaded")
|
||||||
}
|
}
|
||||||
var revokedCerts []pkix.RevokedCertificate
|
var revokedCerts []pkix.RevokedCertificate
|
||||||
@@ -162,14 +191,14 @@ func (s *_CAState) GenerateCRL(crlPath string, validityDays int) error {
|
|||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
nextUpdate := now.Add(time.Duration(validityDays) * 24 * time.Hour) // validityDays * 24 * 60 * 60 * 1000 milliseconds
|
nextUpdate := now.Add(time.Duration(validityDays) * 24 * time.Hour) // validityDays * 24 * 60 * 60 * 1000 milliseconds
|
||||||
template := &x509.RevocationList{
|
template := &x509.RevocationList{
|
||||||
SignatureAlgorithm: CACert.SignatureAlgorithm,
|
SignatureAlgorithm: caCert.SignatureAlgorithm,
|
||||||
RevokedCertificates: revokedCerts,
|
RevokedCertificates: revokedCerts,
|
||||||
Number: big.NewInt(int64(s.CRLNumber + 1)),
|
Number: big.NewInt(int64(s.CRLNumber + 1)),
|
||||||
ThisUpdate: now,
|
ThisUpdate: now,
|
||||||
NextUpdate: nextUpdate,
|
NextUpdate: nextUpdate,
|
||||||
Issuer: CACert.Subject,
|
Issuer: caCert.Subject,
|
||||||
}
|
}
|
||||||
crlBytes, err := x509.CreateRevocationList(nil, template, CACert, CAKey)
|
crlBytes, err := x509.CreateRevocationList(nil, template, caCert, caKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create CRL: %v", err)
|
return fmt.Errorf("failed to create CRL: %v", err)
|
||||||
}
|
}
|
||||||
|
@@ -9,5 +9,6 @@ ca "example_ca" {
|
|||||||
paths {
|
paths {
|
||||||
certificates = "certs"
|
certificates = "certs"
|
||||||
private_keys = "private"
|
private_keys = "private"
|
||||||
|
state_file = "ca_state.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
defaults {
|
defaults {
|
||||||
subject = "{{ .Name }}.koszewscy.waw.pl"
|
subject = "{{ .Name }}.koszewscy.waw.pl"
|
||||||
type = "server"
|
type = "server,client"
|
||||||
validity = "1y"
|
validity = "1y"
|
||||||
san = ["DNS:{{ .Name }}.koszewscy.waw.pl"]
|
san = ["DNS:{{ .Name }}.koszewscy.waw.pl"]
|
||||||
}
|
}
|
||||||
@@ -30,3 +30,10 @@ certificate "prometheus" {
|
|||||||
subject = "{{ .Name }}.{{ .Domain }}" # result: prometheus.koszewscy.email
|
subject = "{{ .Name }}.{{ .Domain }}" # result: prometheus.koszewscy.email
|
||||||
san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:prometheus.koszewscy.email" ]
|
san = ["DNS:{{ .Name }}.{{ .Domain }}"] # result: [ "DNS:prometheus.koszewscy.email" ]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
certificate "slawek" {
|
||||||
|
subject = "CN=Slawomir Koszewski,emailAddress=slawek@koszewscy.waw.pl,O=Koszewscy,C=PL"
|
||||||
|
type = "client,email"
|
||||||
|
validity = "1y"
|
||||||
|
san = ["email:slawek@koszewscy.email"]
|
||||||
|
}
|
3
ignore-changes-to-version-go.sh
Executable file
3
ignore-changes-to-version-go.sh
Executable file
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
git update-index --assume-unchanged version.go
|
74
main.go
74
main.go
@@ -7,24 +7,33 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Version = "dev"
|
// Global flags available to all commands
|
||||||
|
var dryRun bool
|
||||||
|
var verbose bool
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
|
||||||
var overwrite bool
|
// list command flags
|
||||||
|
var listRevoked bool
|
||||||
|
|
||||||
|
// issue command flags
|
||||||
|
var name string
|
||||||
var subject string
|
var subject string
|
||||||
var certType string
|
var certType string
|
||||||
var validity string
|
var validity string
|
||||||
var san []string
|
var san []string
|
||||||
var name string
|
|
||||||
var dryRun bool
|
// provision command flags
|
||||||
var verbose bool
|
var provisionFile string
|
||||||
|
|
||||||
|
// crl command flags
|
||||||
var crlFile string
|
var crlFile string
|
||||||
var crlValidityDays int
|
var crlValidityDays int
|
||||||
|
|
||||||
|
// revoke command flags
|
||||||
var revokeName string
|
var revokeName string
|
||||||
var revokeSerial string
|
var revokeSerial string
|
||||||
var revokeReasonStr string
|
var revokeReasonStr string
|
||||||
var provisionFile string
|
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "lab-ca",
|
Use: "lab-ca",
|
||||||
@@ -36,21 +45,43 @@ 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-path", "ca_config.hcl", "Path to CA configuration file")
|
rootCmd.PersistentFlags().StringVar(&caConfigPath, "config", "ca_config.hcl", "Path to CA configuration file")
|
||||||
|
|
||||||
// lab-ca initca command
|
// lab-ca initca command
|
||||||
var initCmd = &cobra.Command{
|
var initCmd = &cobra.Command{
|
||||||
Use: "initca",
|
Use: "initca",
|
||||||
Short: "Generate a new CA certificate and key",
|
Short: "Generate a new CA certificate and key",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
InitCA(overwrite)
|
InitCA()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
rootCmd.AddCommand(initCmd)
|
rootCmd.AddCommand(initCmd)
|
||||||
|
|
||||||
|
// lab-ca list command
|
||||||
|
var listCmd = &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "List issued certificates",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
err := LoadCA()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "ERROR: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
for _, certDef := range caState.Certificates {
|
||||||
|
if certDef.RevokedAt != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("Certificate %s\n", certDef.Name)
|
||||||
|
fmt.Printf("\tSubject: %s\n\tType: %s\n\tIssued at: %s\n",
|
||||||
|
certDef.Subject, certDef.Type, certDef.Issued)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
listCmd.Flags().BoolVar(&listRevoked, "revoked", false, "List all certificates, including revoked ones")
|
||||||
|
rootCmd.AddCommand(listCmd)
|
||||||
|
|
||||||
// lab-ca issue command
|
// lab-ca issue command
|
||||||
var issueCmd = &cobra.Command{
|
var issueCmd = &cobra.Command{
|
||||||
Use: "issue",
|
Use: "issue",
|
||||||
@@ -62,7 +93,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)
|
||||||
@@ -73,7 +104,8 @@ func main() {
|
|||||||
|
|
||||||
issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)")
|
issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)")
|
||||||
issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (optional, defaults to --name)")
|
issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (optional, defaults to --name)")
|
||||||
issueCmd.Flags().StringVar(&certType, "type", "server", "Certificate type: client, server, server-only, code-signing, email")
|
issueCmd.Flags().StringVar(&certType, "type", "server",
|
||||||
|
"Certificate type: client, server, code-signing, email.\nCombine by specifying more than one separated by comma.")
|
||||||
issueCmd.Flags().StringArrayVar(&san, "san", nil,
|
issueCmd.Flags().StringArrayVar(&san, "san", nil,
|
||||||
"Subject Alternative Name (SAN). Use multiple times for multiple values.\nFormat: dns:example.com, ip:1.2.3.4, email:user@example.com")
|
"Subject Alternative Name (SAN). Use multiple times for multiple values.\nFormat: dns:example.com, ip:1.2.3.4, email:user@example.com")
|
||||||
issueCmd.Flags().StringVar(&validity, "validity", "1y", "Certificate validity (e.g. 2y, 6m, 30d). Overrides config file for this certificate.")
|
issueCmd.Flags().StringVar(&validity, "validity", "1y", "Certificate validity (e.g. 2y, 6m, 30d). Overrides config file for this certificate.")
|
||||||
@@ -86,7 +118,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)
|
||||||
@@ -115,7 +147,7 @@ func main() {
|
|||||||
serial := ""
|
serial := ""
|
||||||
if revokeName != "" {
|
if revokeName != "" {
|
||||||
found := false
|
found := false
|
||||||
for _, rec := range CAState.Certificates {
|
for _, rec := range caState.Certificates {
|
||||||
if rec.Name == revokeName {
|
if rec.Name == revokeName {
|
||||||
serial = rec.Serial
|
serial = rec.Serial
|
||||||
found = true
|
found = true
|
||||||
@@ -148,7 +180,7 @@ func main() {
|
|||||||
fmt.Println()
|
fmt.Println()
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if err := CAState.RevokeCertificate(serial, reasonCode); err != nil {
|
if err := caState.RevokeCertificate(serial, reasonCode); err != nil {
|
||||||
fmt.Printf("ERROR: %v\n", err)
|
fmt.Printf("ERROR: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -172,7 +204,7 @@ func main() {
|
|||||||
if crlValidityDays <= 0 {
|
if crlValidityDays <= 0 {
|
||||||
crlValidityDays = 30 // default to 30 days
|
crlValidityDays = 30 // default to 30 days
|
||||||
}
|
}
|
||||||
err := CAState.GenerateCRL(crlFile, crlValidityDays)
|
err := caState.GenerateCRL(crlFile, crlValidityDays)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("ERROR generating CRL: %v\n", err)
|
fmt.Printf("ERROR generating CRL: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -189,7 +221,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)
|
||||||
@@ -199,15 +231,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")
|
||||||
|
11
print-certificates.sh
Executable file
11
print-certificates.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
for cert in ca/certs/*.pem
|
||||||
|
do
|
||||||
|
echo -e "${GREEN}----- Certificate: $(basename $cert)${NC}"
|
||||||
|
openssl x509 -in "$cert" -noout -text
|
||||||
|
echo -e "${GREEN}----- End of Certificate -----${NC}\n"
|
||||||
|
done
|
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
|
41
run-test.sh
41
run-test.sh
@@ -1,16 +1,30 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
GREEN='\033[0;32m'
|
GREEN='\033[0;32m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
LAB_CA="build/lab-ca"
|
||||||
|
PROVISION_CONFIG="examples/example-certificates.hcl"
|
||||||
# Build and install
|
# Build and install
|
||||||
go install
|
# Build script for lab-ca with version injection from git tag
|
||||||
rm -rf certs private *.json crl*.pem
|
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
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo -e "${GREEN}Build failed!${NC}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo -e "${GREEN}Build successful! Version: $VERSION${NC}"
|
||||||
|
|
||||||
|
rm -rf ca
|
||||||
|
|
||||||
echo -e "\n${GREEN}Initializing CA...${NC}"
|
echo -e "\n${GREEN}Initializing CA...${NC}"
|
||||||
lab-ca initca || exit 1
|
$LAB_CA initca || exit 1
|
||||||
|
|
||||||
echo -e "\n${GREEN}Issuing single certificate with incorrect argument..${NC}"
|
echo -e "\n${GREEN}Issuing single certificate with incorrect argument..${NC}"
|
||||||
lab-ca issue --name "blackpanther2.koszewscy.waw.pl"
|
$LAB_CA issue --name "blackpanther2.koszewscy.waw.pl"
|
||||||
if [ $? -ne 0 ]; then
|
if [ $? -ne 0 ]; then
|
||||||
echo -e "${GREEN}Failed to issue certificate.${NC} - that's fine it was intended."
|
echo -e "${GREEN}Failed to issue certificate.${NC} - that's fine it was intended."
|
||||||
else
|
else
|
||||||
@@ -19,24 +33,27 @@ else
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo -e "\n${GREEN}Issuing single certificate..${NC}"
|
echo -e "\n${GREEN}Issuing single certificate..${NC}"
|
||||||
lab-ca issue --name "blackpanther2" --subject "blackpanther2.koszewscy.waw.pl" || exit 1
|
$LAB_CA issue --name "blackpanther2" --subject "blackpanther2.koszewscy.waw.pl" || exit 1
|
||||||
|
|
||||||
echo -e "\n${GREEN}Issuing multiple certificates from file...${NC}"
|
echo -e "\n${GREEN}Issuing multiple certificates from file...${NC}"
|
||||||
lab-ca provision --file examples/example-certificates.hcl --verbose || exit 1
|
$LAB_CA provision --file $PROVISION_CONFIG --verbose || exit 1
|
||||||
|
|
||||||
echo -e "\n${GREEN}Revoking a certificate by name...${NC}"
|
echo -e "\n${GREEN}Revoking a certificate by name...${NC}"
|
||||||
lab-ca revoke --name "loki" || exit 1
|
$LAB_CA revoke --name "loki" || exit 1
|
||||||
|
|
||||||
echo -e "\n${GREEN}Generating CRL...${NC}"
|
echo -e "\n${GREEN}Generating CRL...${NC}"
|
||||||
lab-ca crl --validity-days 7 --crl-file crl-1.pem || exit 1
|
$LAB_CA crl --validity-days 7 --crl-file crl-1.pem || exit 1
|
||||||
openssl crl -noout -text -in crl-1.pem
|
openssl crl -noout -text -in crl-1.pem
|
||||||
|
|
||||||
echo -e "\n${GREEN}Revoking a second certificate by name...${NC}"
|
echo -e "\n${GREEN}Revoking a second certificate by name...${NC}"
|
||||||
lab-ca revoke --name "alloy" || exit 1
|
$LAB_CA revoke --name "alloy" || exit 1
|
||||||
|
|
||||||
echo -e "\n${GREEN}Generating a second CRL...${NC}"
|
echo -e "\n${GREEN}Generating a second CRL...${NC}"
|
||||||
lab-ca crl --validity-days 7 --crl-file crl-2.pem || exit 1
|
$LAB_CA crl --validity-days 7 --crl-file crl-2.pem || exit 1
|
||||||
openssl crl -noout -text -in crl-2.pem
|
openssl crl -noout -text -in crl-2.pem
|
||||||
|
|
||||||
echo -e "\n${GREEN}Dumping CA state...${NC}"
|
echo -e "\n${GREEN}Dumping CA state...${NC}"
|
||||||
jq '.' example_ca_state.json
|
jq '.' ca/ca_state.json
|
||||||
|
|
||||||
|
# Finished
|
||||||
|
echo -e "\n${GREEN}All operations completed successfully!${NC}"
|
7
set-version.sh
Executable file
7
set-version.sh
Executable file
@@ -0,0 +1,7 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
VERSION=${1:-$(git describe --tags --always --dirty 2>/dev/null || echo "dev")}
|
||||||
|
# Allow git to track changes to version.go
|
||||||
|
git update-index --no-assume-unchanged version.go
|
||||||
|
# Hardcode the version into main.go
|
||||||
|
sed -i '' "s/^var Version = .*/var Version = \"$VERSION\"/" version.go
|
3
version.go
Normal file
3
version.go
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
var Version = "v0.3.2"
|
Reference in New Issue
Block a user