Implemented basic functionality.
This commit is contained in:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -4,5 +4,7 @@
|
|||||||
lab-ca*
|
lab-ca*
|
||||||
# Ignore any certificate files
|
# Ignore any certificate files
|
||||||
*.pem
|
*.pem
|
||||||
# Ignore the default CA configuration file
|
# Ignore CA configuration and certificate definitions.
|
||||||
ca_config.hcl
|
*.hcl
|
||||||
|
# Include example files
|
||||||
|
!/examples/*.hcl
|
||||||
|
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Sławomir Koszewski (slawek@koszewscy.waw.pl)
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
88
README.md
88
README.md
@@ -14,3 +14,91 @@ It has been designed to as easy to use as possible and provides a basic set of C
|
|||||||
- Revoke a certificate
|
- Revoke a certificate
|
||||||
- List issued certificates
|
- List issued certificates
|
||||||
- Create a CRL (Certificate Revocation List)
|
- Create a CRL (Certificate Revocation List)
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The tool is designed to be used from the command line. It has a simple command structure:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lab-ca <command> [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
There are two commands available:
|
||||||
|
|
||||||
|
- `initca` - initialize a new CA - this command creates a new CA and a self-signed CA certificate.
|
||||||
|
- `issue` - issue a new certificate - this command creates a new certificate signed by the CA.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### CA Initialization
|
||||||
|
|
||||||
|
Create a new CA configuration file:
|
||||||
|
|
||||||
|
```
|
||||||
|
ca "example_ca" {
|
||||||
|
name = "Example CA"
|
||||||
|
country = "PL"
|
||||||
|
organization = "ACME Corp"
|
||||||
|
key_size = 4096
|
||||||
|
validity = "10y"
|
||||||
|
|
||||||
|
paths {
|
||||||
|
certificates = "certs"
|
||||||
|
private_keys = "private"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> **NOTE:** `lab-ca` uses HCL (HashiCorp Configuration Language) for configuration files. You can find more information about HCL [here](https://github.com/hashicorp/hcl).
|
||||||
|
|
||||||
|
`ca` block's label has not function, but may be used to identify the CA in the future.
|
||||||
|
|
||||||
|
The following attributes are available:
|
||||||
|
|
||||||
|
- `name` - the name of the CA
|
||||||
|
- `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 default ACL for the current user. You have to secure the private keys directory yourself.
|
||||||
|
|
||||||
|
> **NOTE:** The command does not encrypt private keys. It is not designed to be used in a production environment.
|
||||||
|
|
||||||
|
## Certoficate Issuance
|
||||||
|
|
||||||
|
To issue a new certificate you can use the `issue` command and specify the certificate defintion in the command line or you can use batch mode and provide a file with certificate definitions.
|
||||||
|
|
||||||
|
The definition file also uses HCL syntax. Here is an example of a certificate definition:
|
||||||
|
|
||||||
|
```hcl
|
||||||
|
defaults {
|
||||||
|
subject = "{{ .Name }}.example.com"
|
||||||
|
type = "server"
|
||||||
|
validity = "1y"
|
||||||
|
san = ["DNS:{{ .Name }}.example.com"]
|
||||||
|
}
|
||||||
|
|
||||||
|
certificate "grafana" {
|
||||||
|
# from default: subject = "{{ .Name }}.example.com" # result: grafana.example.com
|
||||||
|
# from default: type = "server"
|
||||||
|
# from default: validity = "1y"
|
||||||
|
# from default: san = ["DNS:{{ .Name }}.example.com"] # result: [ "DNS:grafana.example.com" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
certificate "loki" {
|
||||||
|
subject = "{{ .Name }}.example.net" # result: loki.example.net
|
||||||
|
# from default: type = "server"
|
||||||
|
# from default: validity = "1y"
|
||||||
|
san = ["DNS:{{ .Name }}.example.net"] # result: [ "DNS:loki.example.net" ]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Values specified in the `defaults` block will be used for all certificates unless they are overridden in the individual certificate definitions. Go style template syntax is also supported, so you can use `{{ .Name }}` to refer to the certificate name.
|
||||||
|
|
||||||
|
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 type of the certificate.
|
||||||
|
9
build.sh
Executable file
9
build.sh
Executable file
@@ -0,0 +1,9 @@
|
|||||||
|
#!/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
|
405
ca.go
405
ca.go
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
@@ -8,8 +9,11 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math/big"
|
"math/big"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"text/template"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
gohcl "github.com/hashicorp/hcl/v2/gohcl"
|
gohcl "github.com/hashicorp/hcl/v2/gohcl"
|
||||||
@@ -26,7 +30,11 @@ type CA struct {
|
|||||||
Name string `hcl:"name"`
|
Name string `hcl:"name"`
|
||||||
Country string `hcl:"country"`
|
Country string `hcl:"country"`
|
||||||
Organization string `hcl:"organization"`
|
Organization string `hcl:"organization"`
|
||||||
SerialType string `hcl:"serial_type"`
|
OrganizationalUnit string `hcl:"organizational_unit,optional"`
|
||||||
|
Locality string `hcl:"locality,optional"`
|
||||||
|
Province string `hcl:"province,optional"`
|
||||||
|
Email string `hcl:"email,optional"`
|
||||||
|
SerialType string `hcl:"serial_type,optional"`
|
||||||
KeySize int `hcl:"key_size,optional"`
|
KeySize int `hcl:"key_size,optional"`
|
||||||
Validity string `hcl:"validity,optional"`
|
Validity string `hcl:"validity,optional"`
|
||||||
Paths Paths `hcl:"paths,block"`
|
Paths Paths `hcl:"paths,block"`
|
||||||
@@ -36,6 +44,26 @@ type Configuration struct {
|
|||||||
CA CA `hcl:"ca,block"`
|
CA CA `hcl:"ca,block"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CertificateDef struct {
|
||||||
|
Name string `hcl:",label"`
|
||||||
|
Subject string `hcl:"subject,optional"`
|
||||||
|
Type string `hcl:"type,optional"`
|
||||||
|
Validity string `hcl:"validity,optional"`
|
||||||
|
SAN []string `hcl:"san,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CertificateDefaults struct {
|
||||||
|
Subject string `hcl:"subject,optional"`
|
||||||
|
Type string `hcl:"type,optional"`
|
||||||
|
Validity string `hcl:"validity,optional"`
|
||||||
|
SAN []string `hcl:"san,optional"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Certificates struct {
|
||||||
|
Defaults *CertificateDefaults `hcl:"defaults,block"`
|
||||||
|
Certificates []CertificateDef `hcl:"certificate,block"`
|
||||||
|
}
|
||||||
|
|
||||||
func LoadCA(path string) (*CA, error) {
|
func LoadCA(path string) (*CA, error) {
|
||||||
parser := hclparse.NewParser()
|
parser := hclparse.NewParser()
|
||||||
file, diags := parser.ParseHCLFile(path)
|
file, diags := parser.ParseHCLFile(path)
|
||||||
@@ -56,6 +84,21 @@ func LoadCA(path string) (*CA, error) {
|
|||||||
return &config.CA, nil
|
return &config.CA, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse certificates.hcl file with defaults support
|
||||||
|
func LoadCertificatesFile(path string) ([]CertificateDef, *CertificateDefaults, error) {
|
||||||
|
parser := hclparse.NewParser()
|
||||||
|
file, diags := parser.ParseHCLFile(path)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return nil, nil, fmt.Errorf("failed to parse HCL: %s", diags.Error())
|
||||||
|
}
|
||||||
|
var certsFile Certificates
|
||||||
|
diags = gohcl.DecodeBody(file.Body, nil, &certsFile)
|
||||||
|
if diags.HasErrors() {
|
||||||
|
return nil, nil, fmt.Errorf("failed to decode HCL: %s", diags.Error())
|
||||||
|
}
|
||||||
|
return certsFile.Certificates, certsFile.Defaults, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseValidity(validity string) (time.Duration, error) {
|
func parseValidity(validity string) (time.Duration, error) {
|
||||||
if validity == "" {
|
if validity == "" {
|
||||||
return time.Hour * 24 * 365 * 5, nil // default 5 years
|
return time.Hour * 24 * 365 * 5, nil // default 5 years
|
||||||
@@ -107,6 +150,9 @@ func GenerateCA(ca *CA) ([]byte, []byte, error) {
|
|||||||
Subject: pkix.Name{
|
Subject: pkix.Name{
|
||||||
Country: []string{ca.Country},
|
Country: []string{ca.Country},
|
||||||
Organization: []string{ca.Organization},
|
Organization: []string{ca.Organization},
|
||||||
|
OrganizationalUnit: optionalSlice(ca.OrganizationalUnit),
|
||||||
|
Locality: optionalSlice(ca.Locality),
|
||||||
|
Province: optionalSlice(ca.Province),
|
||||||
CommonName: ca.Name,
|
CommonName: ca.Name,
|
||||||
},
|
},
|
||||||
NotBefore: now,
|
NotBefore: now,
|
||||||
@@ -115,6 +161,13 @@ func GenerateCA(ca *CA) ([]byte, []byte, error) {
|
|||||||
BasicConstraintsValid: true,
|
BasicConstraintsValid: true,
|
||||||
IsCA: true,
|
IsCA: true,
|
||||||
}
|
}
|
||||||
|
// Add email if present
|
||||||
|
if ca.Email != "" {
|
||||||
|
tmpl.Subject.ExtraNames = append(tmpl.Subject.ExtraNames, pkix.AttributeTypeAndValue{
|
||||||
|
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
|
||||||
|
Value: ca.Email,
|
||||||
|
})
|
||||||
|
}
|
||||||
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
@@ -160,6 +213,7 @@ func (c *CA) Validate() error {
|
|||||||
if c.Organization == "" {
|
if c.Organization == "" {
|
||||||
return fmt.Errorf("CA 'organization' is required")
|
return fmt.Errorf("CA 'organization' is required")
|
||||||
}
|
}
|
||||||
|
// SerialType is now optional; default to 'random' if empty
|
||||||
if c.SerialType == "" {
|
if c.SerialType == "" {
|
||||||
c.SerialType = "random"
|
c.SerialType = "random"
|
||||||
}
|
}
|
||||||
@@ -209,7 +263,12 @@ func InitCA(configPath string, overwrite bool) {
|
|||||||
fmt.Println("CA certificate and key generated.")
|
fmt.Println("CA certificate and key generated.")
|
||||||
}
|
}
|
||||||
|
|
||||||
func IssueCertificate(configPath, subject string, overwrite bool) {
|
func IssueCertificate(configPath, name string, subject, certType, validityFlag string, san []string, overwrite bool) {
|
||||||
|
// Add default dns SAN for server/server-only if none specified
|
||||||
|
if (certType == "server" || certType == "server-only") && len(san) == 0 {
|
||||||
|
san = append(san, "dns:"+subject)
|
||||||
|
}
|
||||||
|
|
||||||
ca, err := LoadCA(configPath)
|
ca, err := LoadCA(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println("Error loading config:", err)
|
fmt.Println("Error loading config:", err)
|
||||||
@@ -262,16 +321,66 @@ func IssueCertificate(configPath, subject string, overwrite bool) {
|
|||||||
fmt.Println("Failed to generate serial number:", err)
|
fmt.Println("Failed to generate serial number:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validity time.Duration
|
||||||
|
if validityFlag != "" {
|
||||||
|
validity, err = parseValidity(validityFlag)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("Invalid validity value:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
validity = 365 * 24 * time.Hour // default 1 year
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse subject as DN if it looks like a DN, otherwise use as CommonName only
|
||||||
|
var subjectPKIX pkix.Name
|
||||||
|
if isDNFormat(subject) {
|
||||||
|
subjectPKIX = parseDistinguishedName(subject)
|
||||||
|
} else {
|
||||||
|
subjectPKIX = pkix.Name{CommonName: subject}
|
||||||
|
}
|
||||||
|
|
||||||
certTmpl := x509.Certificate{
|
certTmpl := x509.Certificate{
|
||||||
SerialNumber: serialNumber,
|
SerialNumber: serialNumber,
|
||||||
Subject: pkix.Name{
|
Subject: subjectPKIX,
|
||||||
CommonName: subject,
|
|
||||||
},
|
|
||||||
NotBefore: time.Now(),
|
NotBefore: time.Now(),
|
||||||
NotAfter: time.Now().AddDate(1, 0, 0), // 1 year
|
NotAfter: time.Now().Add(validity),
|
||||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth, x509.ExtKeyUsageServerAuth},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle SANs
|
||||||
|
for _, s := range san {
|
||||||
|
sLower := strings.ToLower(s)
|
||||||
|
var val string
|
||||||
|
if n, _ := fmt.Sscanf(sLower, "dns:%s", &val); n == 1 {
|
||||||
|
certTmpl.DNSNames = append(certTmpl.DNSNames, val)
|
||||||
|
} else if n, _ := fmt.Sscanf(sLower, "ip:%s", &val); n == 1 {
|
||||||
|
certTmpl.IPAddresses = append(certTmpl.IPAddresses, net.ParseIP(val))
|
||||||
|
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
|
||||||
|
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("Invalid SAN format: %s\n", s)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch certType {
|
||||||
|
case "client":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
|
||||||
|
case "server":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
|
||||||
|
case "server-only":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
|
||||||
|
case "code-signing":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}
|
||||||
|
case "email":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}
|
||||||
|
default:
|
||||||
|
fmt.Println("Unknown certificate type. Use one of: client, server, server-only, code-signing, email.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
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 {
|
||||||
fmt.Println("Failed to create certificate:", err)
|
fmt.Println("Failed to create certificate:", err)
|
||||||
@@ -280,8 +389,12 @@ func IssueCertificate(configPath, subject string, overwrite bool) {
|
|||||||
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)})
|
||||||
|
|
||||||
certFile := filepath.Join(ca.Paths.Certificates, subject+".crt.pem")
|
basename := name
|
||||||
keyFile := filepath.Join(ca.Paths.PrivateKeys, subject+".key.pem")
|
if basename == "" {
|
||||||
|
basename = subject
|
||||||
|
}
|
||||||
|
certFile := filepath.Join(ca.Paths.Certificates, basename+"."+certType+".crt.pem")
|
||||||
|
keyFile := filepath.Join(ca.Paths.PrivateKeys, basename+"."+certType+".key.pem")
|
||||||
if err := SavePEM(certFile, certPEM, false, overwrite); err != nil {
|
if err := SavePEM(certFile, certPEM, false, overwrite); err != nil {
|
||||||
fmt.Println("Error saving certificate:", err)
|
fmt.Println("Error saving certificate:", err)
|
||||||
return
|
return
|
||||||
@@ -290,5 +403,277 @@ func IssueCertificate(configPath, subject string, overwrite bool) {
|
|||||||
fmt.Println("Error saving key:", err)
|
fmt.Println("Error saving key:", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Printf("Certificate and key for '%s' generated.\n", subject)
|
fmt.Printf("%s certificate and key for '%s' generated.\n", certType, subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract defaults from certificates.hcl (now using new LoadCertificatesFile signature)
|
||||||
|
func GetCertificateDefaults(path string) CertificateDef {
|
||||||
|
_, defaults, err := LoadCertificatesFile(path)
|
||||||
|
if err != nil || defaults == nil {
|
||||||
|
return CertificateDef{}
|
||||||
|
}
|
||||||
|
return CertificateDef{
|
||||||
|
Type: defaults.Type,
|
||||||
|
Validity: defaults.Validity,
|
||||||
|
SAN: defaults.SAN,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue certificate with custom basename and dry-run support
|
||||||
|
func IssueCertificateWithBasename(configPath, basename, subject, certType, validityFlag string, san []string, overwrite, dryRun bool) error {
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("Would issue certificate: name=%s, subject=%s, type=%s, validity=%s, SAN=%v\n", basename, subject, certType, validityFlag, san)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Call IssueCertificate but override basename logic
|
||||||
|
return issueCertificateInternal(configPath, basename, subject, certType, validityFlag, san, overwrite)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal: like IssueCertificate but with explicit basename
|
||||||
|
func issueCertificateInternal(configPath, basename, subject, certType, validityFlag string, san []string, overwrite bool) error {
|
||||||
|
// Add default dns SAN for server/server-only if none specified
|
||||||
|
if (certType == "server" || certType == "server-only") && len(san) == 0 {
|
||||||
|
san = append(san, "dns:"+subject)
|
||||||
|
}
|
||||||
|
|
||||||
|
ca, err := LoadCA(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error loading config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertPath := filepath.Join(ca.Paths.Certificates, "ca_cert.pem")
|
||||||
|
caKeyPath := filepath.Join(ca.Paths.PrivateKeys, "ca_key.pem")
|
||||||
|
|
||||||
|
caCertPEM, err := os.ReadFile(caCertPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error reading CA certificate file: %v", err)
|
||||||
|
}
|
||||||
|
caKeyPEM, err := os.ReadFile(caKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Error reading CA key file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
caCertBlock, _ := pem.Decode(caCertPEM)
|
||||||
|
if caCertBlock == nil {
|
||||||
|
return fmt.Errorf("Failed to parse CA certificate PEM")
|
||||||
|
}
|
||||||
|
caCert, err := x509.ParseCertificate(caCertBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to parse CA certificate: %v", err)
|
||||||
|
}
|
||||||
|
caKeyBlock, _ := pem.Decode(caKeyPEM)
|
||||||
|
if caKeyBlock == nil {
|
||||||
|
return fmt.Errorf("Failed to parse CA key PEM")
|
||||||
|
}
|
||||||
|
caKey, err := x509.ParsePKCS1PrivateKey(caKeyBlock.Bytes)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to parse CA private key: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
priv, err := rsa.GenerateKey(rand.Reader, 4096)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to generate private key: %v", err)
|
||||||
|
}
|
||||||
|
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
|
||||||
|
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to generate serial number: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var validity time.Duration
|
||||||
|
if validityFlag != "" {
|
||||||
|
validity, err = parseValidity(validityFlag)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Invalid validity value: %v", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
validity = 365 * 24 * time.Hour // default 1 year
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse subject as DN if it looks like a DN, otherwise use as CommonName only
|
||||||
|
var subjectPKIX pkix.Name
|
||||||
|
if isDNFormat(subject) {
|
||||||
|
subjectPKIX = parseDistinguishedName(subject)
|
||||||
|
} else {
|
||||||
|
subjectPKIX = pkix.Name{CommonName: subject}
|
||||||
|
}
|
||||||
|
|
||||||
|
certTmpl := x509.Certificate{
|
||||||
|
SerialNumber: serialNumber,
|
||||||
|
Subject: subjectPKIX,
|
||||||
|
NotBefore: time.Now(),
|
||||||
|
NotAfter: time.Now().Add(validity),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle SANs
|
||||||
|
for _, s := range san {
|
||||||
|
sLower := strings.ToLower(s)
|
||||||
|
var val string
|
||||||
|
if n, _ := fmt.Sscanf(sLower, "dns:%s", &val); n == 1 {
|
||||||
|
certTmpl.DNSNames = append(certTmpl.DNSNames, val)
|
||||||
|
} else if n, _ := fmt.Sscanf(sLower, "ip:%s", &val); n == 1 {
|
||||||
|
certTmpl.IPAddresses = append(certTmpl.IPAddresses, net.ParseIP(val))
|
||||||
|
} else if n, _ := fmt.Sscanf(sLower, "email:%s", &val); n == 1 {
|
||||||
|
certTmpl.EmailAddresses = append(certTmpl.EmailAddresses, val)
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("Invalid SAN format: %s", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch certType {
|
||||||
|
case "client":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}
|
||||||
|
case "server":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}
|
||||||
|
case "server-only":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}
|
||||||
|
case "code-signing":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageCodeSigning}
|
||||||
|
case "email":
|
||||||
|
certTmpl.ExtKeyUsage = []x509.ExtKeyUsage{x509.ExtKeyUsageEmailProtection}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("Unknown certificate type. Use one of: client, server, server-only, code-signing, email.")
|
||||||
|
}
|
||||||
|
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, &certTmpl, caCert, &priv.PublicKey, caKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("Failed to create certificate: %v", err)
|
||||||
|
}
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
|
||||||
|
|
||||||
|
certFile := filepath.Join(ca.Paths.Certificates, basename+".crt.pem")
|
||||||
|
keyFile := filepath.Join(ca.Paths.PrivateKeys, basename+".key.pem")
|
||||||
|
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 {
|
||||||
|
return fmt.Errorf("Error saving key: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: check if string looks like a DN (contains at least CN=...)
|
||||||
|
func isDNFormat(s string) bool {
|
||||||
|
return len(s) > 0 && strings.Contains(s, "CN=")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: parse DN string into pkix.Name (supports CN, C, O, OU, L, ST, emailAddress)
|
||||||
|
func parseDistinguishedName(dn string) pkix.Name {
|
||||||
|
var name pkix.Name
|
||||||
|
parts := strings.Split(dn, ",")
|
||||||
|
for _, part := range parts {
|
||||||
|
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
|
||||||
|
if len(kv) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1])
|
||||||
|
switch key {
|
||||||
|
case "CN":
|
||||||
|
name.CommonName = val
|
||||||
|
case "C":
|
||||||
|
name.Country = append(name.Country, val)
|
||||||
|
case "O":
|
||||||
|
name.Organization = append(name.Organization, val)
|
||||||
|
case "OU":
|
||||||
|
name.OrganizationalUnit = append(name.OrganizationalUnit, val)
|
||||||
|
case "L":
|
||||||
|
name.Locality = append(name.Locality, val)
|
||||||
|
case "ST":
|
||||||
|
name.Province = append(name.Province, val)
|
||||||
|
case "emailAddress":
|
||||||
|
name.ExtraNames = append(name.ExtraNames, pkix.AttributeTypeAndValue{
|
||||||
|
Type: []int{1, 2, 840, 113549, 1, 9, 1}, // emailAddress OID
|
||||||
|
Value: val,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: apply Go template to a string using CertificateDef and CertificateDefaults as data
|
||||||
|
func applyTemplate(s string, def CertificateDef, defaults *CertificateDefaults) (string, error) {
|
||||||
|
data := struct {
|
||||||
|
CertificateDef
|
||||||
|
Defaults *CertificateDefaults
|
||||||
|
}{
|
||||||
|
CertificateDef: def,
|
||||||
|
Defaults: defaults,
|
||||||
|
}
|
||||||
|
tmpl, err := template.New("").Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
return s, err
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render all string fields in CertificateDef using Go templates and return a new CertificateDef
|
||||||
|
func renderCertificateDefTemplates(def CertificateDef, defaults *CertificateDefaults) CertificateDef {
|
||||||
|
newDef := def
|
||||||
|
// Subject: use def.Subject if set, else defaults.Subject (rendered)
|
||||||
|
if def.Subject != "" {
|
||||||
|
if rendered, err := applyTemplate(def.Subject, def, defaults); err == nil {
|
||||||
|
newDef.Subject = rendered
|
||||||
|
}
|
||||||
|
} else if defaults != nil && defaults.Subject != "" {
|
||||||
|
if rendered, err := applyTemplate(defaults.Subject, def, defaults); err == nil {
|
||||||
|
newDef.Subject = rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Type: use def.Type if set, else defaults.Type (rendered)
|
||||||
|
if def.Type != "" {
|
||||||
|
if rendered, err := applyTemplate(def.Type, def, defaults); err == nil {
|
||||||
|
newDef.Type = rendered
|
||||||
|
}
|
||||||
|
} else if defaults != nil && defaults.Type != "" {
|
||||||
|
if rendered, err := applyTemplate(defaults.Type, def, defaults); err == nil {
|
||||||
|
newDef.Type = rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Validity: use def.Validity if set, else defaults.Validity (rendered)
|
||||||
|
if def.Validity != "" {
|
||||||
|
if rendered, err := applyTemplate(def.Validity, def, defaults); err == nil {
|
||||||
|
newDef.Validity = rendered
|
||||||
|
}
|
||||||
|
} else if defaults != nil && defaults.Validity != "" {
|
||||||
|
if rendered, err := applyTemplate(defaults.Validity, def, defaults); err == nil {
|
||||||
|
newDef.Validity = rendered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// SAN: use def.SAN if set, else defaults.SAN (rendered)
|
||||||
|
if len(def.SAN) > 0 {
|
||||||
|
newSAN := make([]string, len(def.SAN))
|
||||||
|
for i, s := range def.SAN {
|
||||||
|
if rendered, err := applyTemplate(s, def, defaults); err == nil {
|
||||||
|
newSAN[i] = rendered
|
||||||
|
} else {
|
||||||
|
newSAN[i] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newDef.SAN = newSAN
|
||||||
|
} else if defaults != nil && len(defaults.SAN) > 0 {
|
||||||
|
newSAN := make([]string, len(defaults.SAN))
|
||||||
|
for i, s := range defaults.SAN {
|
||||||
|
if rendered, err := applyTemplate(s, def, defaults); err == nil {
|
||||||
|
newSAN[i] = rendered
|
||||||
|
} else {
|
||||||
|
newSAN[i] = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newDef.SAN = newSAN
|
||||||
|
}
|
||||||
|
return newDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: convert optional string to []string or nil
|
||||||
|
func optionalSlice(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{s}
|
||||||
}
|
}
|
||||||
|
13
examples/ca_config.hcl
Normal file
13
examples/ca_config.hcl
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
ca "example_ca" {
|
||||||
|
name = "Example CA"
|
||||||
|
country = "PL"
|
||||||
|
organization = "ACME Corp"
|
||||||
|
serial_type = "random"
|
||||||
|
key_size = 4096
|
||||||
|
validity = "10y"
|
||||||
|
|
||||||
|
paths {
|
||||||
|
certificates = "certs"
|
||||||
|
private_keys = "private"
|
||||||
|
}
|
||||||
|
}
|
20
examples/example-certificates.hcl
Normal file
20
examples/example-certificates.hcl
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
defaults {
|
||||||
|
subject = "{{ .Name }}.koszewscy.waw.pl"
|
||||||
|
type = "server"
|
||||||
|
validity = "1y"
|
||||||
|
san = ["DNS:{{ .Name }}.koszewscy.waw.pl"]
|
||||||
|
}
|
||||||
|
|
||||||
|
certificate "grafana" {
|
||||||
|
# from default: subject = "{{ .Name }}.koszewscy.waw.pl" # result: grafana.koszewscy.waw.pl
|
||||||
|
# from default: type = "server"
|
||||||
|
# from default: validity = "1y"
|
||||||
|
# from default: san = ["DNS:{{ .Name }}.koszewscy.waw.pl"] # result: [ "DNS:grafana.koszewscy.waw.pl" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
certificate "loki" {
|
||||||
|
subject = "{{ .Name }}.koszewscy.email" # result: loki.koszewscy.email
|
||||||
|
# from default: type = "server"
|
||||||
|
# from default: validity = "1y"
|
||||||
|
san = ["DNS:{{ .Name }}.koszewscy.email"] # result: [ "DNS:loki.koszewscy.email" ]
|
||||||
|
}
|
113
main.go
113
main.go
@@ -7,10 +7,19 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var Version = "dev"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath string
|
var configPath string
|
||||||
var overwrite bool
|
var overwrite bool
|
||||||
var subject string
|
var subject string
|
||||||
|
var certType string
|
||||||
|
var validity string
|
||||||
|
var san []string
|
||||||
|
var name string
|
||||||
|
var fromFile string
|
||||||
|
var dryRun bool
|
||||||
|
var verbose bool
|
||||||
|
|
||||||
var rootCmd = &cobra.Command{
|
var rootCmd = &cobra.Command{
|
||||||
Use: "lab-ca",
|
Use: "lab-ca",
|
||||||
@@ -34,19 +43,112 @@ func main() {
|
|||||||
|
|
||||||
var issueCmd = &cobra.Command{
|
var issueCmd = &cobra.Command{
|
||||||
Use: "issue",
|
Use: "issue",
|
||||||
Short: "Issue a new client/server certificate",
|
Short: "Issue a new certificate (client, server, server-only, code-signing, email)",
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
IssueCertificate(configPath, subject, overwrite)
|
if fromFile != "" {
|
||||||
|
certDefs, defaults, err := LoadCertificatesFile(fromFile)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("Error loading certificates file: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
successes := 0
|
||||||
|
errors := 0
|
||||||
|
for i, def := range certDefs {
|
||||||
|
if defaults != nil {
|
||||||
|
if def.Type == "" {
|
||||||
|
def.Type = defaults.Type
|
||||||
|
}
|
||||||
|
if def.Validity == "" {
|
||||||
|
def.Validity = defaults.Validity
|
||||||
|
}
|
||||||
|
if len(def.SAN) == 0 && len(defaults.SAN) > 0 {
|
||||||
|
def.SAN = defaults.SAN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finalDef := renderCertificateDefTemplates(def, defaults)
|
||||||
|
|
||||||
|
fmt.Printf("[%d/%d] Issuing %s... ", i+1, len(certDefs), finalDef.Name)
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
fmt.Printf("(dry run)\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("\n Name: %s\n", finalDef.Name)
|
||||||
|
fmt.Printf(" Subject: %s\n", finalDef.Subject)
|
||||||
|
fmt.Printf(" Type: %s\n", finalDef.Type)
|
||||||
|
fmt.Printf(" Validity: %s\n", finalDef.Validity)
|
||||||
|
fmt.Printf(" SAN: %v\n\n", finalDef.SAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
basename := finalDef.Name + "." + finalDef.Type
|
||||||
|
|
||||||
|
if dryRun {
|
||||||
|
successes++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
err := IssueCertificateWithBasename(configPath, basename, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite, dryRun)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("ERROR: %v\n", err)
|
||||||
|
errors++
|
||||||
|
} else {
|
||||||
|
if !verbose {
|
||||||
|
fmt.Printf("done\n")
|
||||||
|
}
|
||||||
|
successes++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Printf("Batch complete: %d succeeded, %d failed.\n", successes, errors)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Simple mode
|
||||||
|
subjectName := subject
|
||||||
|
if subjectName == "" {
|
||||||
|
subjectName = name
|
||||||
|
}
|
||||||
|
finalDef := renderCertificateDefTemplates(CertificateDef{Name: name, Subject: subject, Type: certType, Validity: validity, SAN: san}, nil)
|
||||||
|
if verbose {
|
||||||
|
fmt.Printf("\nCertificate:\n")
|
||||||
|
fmt.Printf(" Name: %s\n", finalDef.Name)
|
||||||
|
fmt.Printf(" Subject: %s\n", finalDef.Subject)
|
||||||
|
fmt.Printf(" Type: %s\n", finalDef.Type)
|
||||||
|
fmt.Printf(" Validity: %s\n", finalDef.Validity)
|
||||||
|
fmt.Printf(" SAN: %v\n", finalDef.SAN)
|
||||||
|
}
|
||||||
|
IssueCertificate(configPath, finalDef.Name, finalDef.Subject, finalDef.Type, finalDef.Validity, finalDef.SAN, overwrite)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
issueCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file")
|
issueCmd.Flags().StringVar(&configPath, "config", "ca_config.hcl", "Path to CA configuration file")
|
||||||
issueCmd.Flags().StringVar(&subject, "subject", "", "Subject Common Name for the certificate (required)")
|
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().StringArrayVar(&san, "san", nil,
|
||||||
|
"Subject Alternative Name (SAN). Use multiple times for multiple values.\n"+
|
||||||
|
"Format: 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().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
|
issueCmd.Flags().BoolVar(&overwrite, "overwrite", false, "Allow overwriting existing files")
|
||||||
issueCmd.MarkFlagRequired("subject")
|
issueCmd.Flags().StringVar(&fromFile, "from-file", "", "Path to HCL file with multiple certificate definitions (batch mode)")
|
||||||
|
issueCmd.Flags().BoolVar(&dryRun, "dry-run", false, "Validate and show what would be created, but do not write files (batch mode)")
|
||||||
|
issueCmd.Flags().BoolVar(&verbose, "verbose", false, "Print detailed information about each processed certificate")
|
||||||
|
// Only require --name in simple mode
|
||||||
|
issueCmd.Flags().StringVar(&name, "name", "", "Name for the certificate and key files (used as subject if --subject is omitted)")
|
||||||
|
issueCmd.PreRun = func(cmd *cobra.Command, args []string) {
|
||||||
|
if fromFile == "" {
|
||||||
|
cmd.MarkFlagRequired("name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var versionCmd = &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Show version information",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Printf("lab-ca version: %s\n", Version)
|
||||||
|
},
|
||||||
|
}
|
||||||
rootCmd.AddCommand(initcaCmd)
|
rootCmd.AddCommand(initcaCmd)
|
||||||
rootCmd.AddCommand(issueCmd)
|
rootCmd.AddCommand(issueCmd)
|
||||||
|
rootCmd.AddCommand(versionCmd)
|
||||||
|
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -62,6 +164,9 @@ func printMainHelp() {
|
|||||||
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(" issue Issue a new client/server certificate")
|
fmt.Println(" issue Issue a new client/server certificate")
|
||||||
|
fmt.Println(" version Show version information")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
fmt.Println("Use 'lab-ca <command> --help' for more information about a command.")
|
fmt.Println("Use 'lab-ca <command> --help' for more information about a command.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove stray SAN parsing code below main()
|
||||||
|
Reference in New Issue
Block a user