Skip to content

Commit 2542d1b

Browse files
committed
feat(cert): add support for multiple domains in a single certificate
This change enables SAN certificates, allowing users to specify multiple domains for a single certificate. It modifies the CLI interface, updates the README, and refactors the internal logic to handle multiple domains throughout the certificate management process.
1 parent 8271b64 commit 2542d1b

File tree

8 files changed

+113
-47
lines changed

8 files changed

+113
-47
lines changed

README.md

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ Zero is a lightweight service that manages SSL/TLS certificates using ZeroSSL. I
1212

1313
Core Features:
1414
- Automatic SSL/TLS certificate management via ZeroSSL
15+
- Support for multiple domains in a single certificate (SAN certificates)
1516
- Daily certificate monitoring and renewal (30 days before expiration)
1617
- Built-in HTTP server for ACME challenges
1718
- HTTP to HTTPS traffic redirection
@@ -119,12 +120,24 @@ zero --help
119120

120121
## Usage
121122

122-
Basic usage:
123+
Basic usage for a single domain:
123124

124125
```bash
125126
zero -d example.com -e user@example.com
126127
```
127128

129+
With multiple domains:
130+
131+
```bash
132+
zero -d example.com,www.example.com -e user@example.com
133+
```
134+
135+
Or by specifying the domain flag multiple times:
136+
137+
```bash
138+
zero -d example.com -d www.example.com -e user@example.com
139+
```
140+
128141
With all options:
129142

130143
```bash
@@ -133,7 +146,7 @@ zero -d example.com -e user@example.com [-c /path/to/certs] [-p port] [-t HH:mm]
133146

134147
Options:
135148

136-
- `-d, --domain`: Domain name for the certificate (required)
149+
- `-d, --domain`: Domain name(s) for the certificate (comma-separated or repeated, at least one required)
137150
- `-e, --email`: Email address for credential retrieval and account registration (required)
138151
- `-c, --cert-dir`: Directory to store certificates (default: "./certs")
139152
- `-p, --port`: HTTP port for ACME challenges (default: 80)
@@ -188,7 +201,6 @@ This is particularly useful for reloading Nginx configuration after certificate
188201
## Limitations
189202

190203
- Only supports HTTP-01 challenge
191-
- Designed for single-domain certificates
192204
- No support for wildcard certificates
193205

194206
## Contributing

cmd/zero/main.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const (
2727
)
2828

2929
type Config struct {
30-
Domain string
30+
Domains []string
3131
Email string
3232
CertDir string
3333
Time string
@@ -39,7 +39,7 @@ type Config struct {
3939
func parseFlags() (*Config, error) {
4040
cfg := &Config{}
4141

42-
pflag.StringVarP(&cfg.Domain, "domain", "d", "", "Domain name for the certificate")
42+
pflag.StringSliceVarP(&cfg.Domains, "domain", "d", []string{}, "Domain name(s) for the certificate (comma-separated or repeated)")
4343
pflag.StringVarP(&cfg.Email, "email", "e", "", "Email address for account registration")
4444
pflag.StringVarP(&cfg.CertDir, "cert-dir", "c", defaultCertDir, "Directory to store certificates")
4545
pflag.StringVarP(&cfg.Time, "time", "t", defaultTime, "Time for daily renewal in HH:mm format")
@@ -49,15 +49,15 @@ func parseFlags() (*Config, error) {
4949

5050
pflag.Usage = func() {
5151
_, _ = fmt.Fprintf(os.Stderr, "Usage of %s:\n", os.Args[0])
52-
_, _ = fmt.Fprintf(os.Stderr, " %s -d example.com -e user@example.com [-c /path/to/certs] [--time HH:mm] [-p port]\n\n", os.Args[0])
52+
_, _ = fmt.Fprintf(os.Stderr, " %s -d example.com,www.example.com -e user@example.com [-c /path/to/certs] [--time HH:mm] [-p port]\n\n", os.Args[0])
5353
_, _ = fmt.Fprintf(os.Stderr, "Options:\n")
5454
pflag.PrintDefaults()
5555
}
5656

5757
pflag.Parse()
5858

59-
if cfg.Domain == "" || cfg.Email == "" {
60-
return nil, errors.New("domain and email are required")
59+
if len(cfg.Domains) == 0 || cfg.Email == "" {
60+
return nil, errors.New("at least one domain and email are required")
6161
}
6262

6363
if _, err := task.ParseTime(cfg.Time); err != nil {
@@ -98,7 +98,7 @@ func main() {
9898

9999
// Start certificate checker
100100
checkCert := func(ctx context.Context) error {
101-
return zeroManager.CheckCertificate(ctx, cfg.Domain, cfg.Email, cfg.CertDir)
101+
return zeroManager.CheckCertificate(ctx, cfg.Domains, cfg.Email, cfg.CertDir)
102102
}
103103

104104
scheduler := task.NewScheduler(checkCert, cfg.Time)

internal/acme/zerossl.go

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,11 @@ func (s *ZeroSSL) FetchCredentials(ctx context.Context, email string) (kid, hmac
6363
if err != nil {
6464
return "", "", fmt.Errorf("fetch EAB credentials: %w", err)
6565
}
66-
defer resp.Body.Close()
66+
defer func() {
67+
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
68+
err = fmt.Errorf("close response body: %w", closeErr)
69+
}
70+
}()
6771

6872
body, err := io.ReadAll(resp.Body)
6973
if err != nil {
@@ -89,7 +93,7 @@ func (s *ZeroSSL) FetchCredentials(ctx context.Context, email string) (kid, hmac
8993
return result.EABKID, result.EABHMACKey, nil
9094
}
9195

92-
func (s *ZeroSSL) ObtainCertificate(ctx context.Context, domain, email string, challengeHandler func(token, response string)) ([][]byte, crypto.PrivateKey, error) {
96+
func (s *ZeroSSL) ObtainCertificate(ctx context.Context, domains []string, email string, challengeHandler func(token, response string)) ([][]byte, crypto.PrivateKey, error) {
9397
eabKID, eabHMACKey, err := s.FetchCredentials(ctx, email)
9498
if err != nil {
9599
return nil, nil, fmt.Errorf("fetch ZeroSSL credentials: %w", err)
@@ -127,44 +131,45 @@ func (s *ZeroSSL) ObtainCertificate(ctx context.Context, domain, email string, c
127131
return nil, nil, fmt.Errorf("generate certificate private key: %w", err)
128132
}
129133

130-
order, err := client.AuthorizeOrder(ctx, []acme.AuthzID{{Type: "dns", Value: domain}})
134+
var authzIDs []acme.AuthzID
135+
for _, d := range domains {
136+
authzIDs = append(authzIDs, acme.AuthzID{Type: "dns", Value: d})
137+
}
138+
139+
order, err := client.AuthorizeOrder(ctx, authzIDs)
131140
if err != nil {
132141
return nil, nil, fmt.Errorf("create order: %w", err)
133142
}
134143

135-
var challenge *acme.Challenge
136144
for _, authzURL := range order.AuthzURLs {
137145
auth, err := client.GetAuthorization(ctx, authzURL)
138146
if err != nil {
139147
return nil, nil, fmt.Errorf("get authorization: %w", err)
140148
}
149+
var challenge *acme.Challenge
141150
for _, c := range auth.Challenges {
142151
if c.Type == "http-01" {
143152
challenge = c
144153
break
145154
}
146155
}
147-
if challenge != nil {
148-
break
156+
if challenge == nil {
157+
return nil, nil, fmt.Errorf("no HTTP-01 challenge found")
149158
}
150-
}
151-
if challenge == nil {
152-
return nil, nil, fmt.Errorf("no HTTP-01 challenge found")
153-
}
154159

155-
token := challenge.Token
156-
keyAuth, err := client.HTTP01ChallengeResponse(challenge.Token)
157-
if err != nil {
158-
return nil, nil, fmt.Errorf("get key authorization: %w", err)
159-
}
160+
token := challenge.Token
161+
keyAuth, err := client.HTTP01ChallengeResponse(challenge.Token)
162+
if err != nil {
163+
return nil, nil, fmt.Errorf("get key authorization: %w", err)
164+
}
160165

161-
challengeHandler(token, keyAuth)
166+
challengeHandler(token, keyAuth)
162167

163-
log.Printf("Starting HTTP-01 challenge verification...")
164-
if _, err := client.Accept(ctx, challenge); err != nil {
165-
return nil, nil, fmt.Errorf("accept challenge: %w", err)
168+
log.Printf("Starting HTTP-01 challenge verification for domain authorization")
169+
if _, err := client.Accept(ctx, challenge); err != nil {
170+
return nil, nil, fmt.Errorf("accept challenge: %w", err)
171+
}
166172
}
167-
log.Printf("Challenge accepted, waiting for verification (timeout: 10 minutes)...")
168173

169174
log.Printf("Waiting for order verification (timeout: 10 minutes)...")
170175
ctxWithTimeout, cancel := context.WithTimeout(ctx, 10*time.Minute)
@@ -177,8 +182,8 @@ func (s *ZeroSSL) ObtainCertificate(ctx context.Context, domain, email string, c
177182
log.Printf("Order verified successfully")
178183

179184
csrTemplate := &x509.CertificateRequest{
180-
Subject: pkix.Name{CommonName: domain},
181-
DNSNames: []string{domain},
185+
Subject: pkix.Name{CommonName: domains[0]},
186+
DNSNames: domains,
182187
}
183188
csrDER, err := x509.CreateCertificateRequest(rand.Reader, csrTemplate, certPrivateKey)
184189
if err != nil {

internal/cert/store.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,11 @@ func (s *Store) SaveCertificate(filename string, certBytes [][]byte) error {
2525
if err != nil {
2626
return fmt.Errorf("create certificate file: %w", err)
2727
}
28-
defer file.Close()
28+
defer func() {
29+
if closeErr := file.Close(); closeErr != nil && err == nil {
30+
err = fmt.Errorf("close certificate file: %w", closeErr)
31+
}
32+
}()
2933

3034
for _, cert := range certBytes {
3135
if err := pem.Encode(file, &pem.Block{Type: "CERTIFICATE", Bytes: cert}); err != nil {
@@ -40,7 +44,11 @@ func (s *Store) SavePrivateKey(filename string, privateKey crypto.PrivateKey) er
4044
if err != nil {
4145
return fmt.Errorf("create private key file: %w", err)
4246
}
43-
defer file.Close()
47+
defer func() {
48+
if closeErr := file.Close(); closeErr != nil && err == nil {
49+
err = fmt.Errorf("close private key file: %w", closeErr)
50+
}
51+
}()
4452

4553
privateKeyBytes, err := x509.MarshalPKCS8PrivateKey(privateKey)
4654
if err != nil {

internal/cert/store_test.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ func TestStore(t *testing.T) {
1919
t.Run("certificate operations", func(t *testing.T) {
2020
store := NewStore()
2121
tmpFile := "test_cert.pem"
22-
defer os.Remove(tmpFile)
22+
defer func() {
23+
err := os.Remove(tmpFile)
24+
if err != nil && !os.IsNotExist(err) {
25+
t.Logf("Failed to remove test certificate file: %v", err)
26+
}
27+
}()
2328

2429
// Create a self-signed test certificate
2530
template := &x509.Certificate{
@@ -49,7 +54,12 @@ func TestStore(t *testing.T) {
4954
t.Run("private key operations", func(t *testing.T) {
5055
store := NewStore()
5156
tmpFile := "test_key.pem"
52-
defer os.Remove(tmpFile)
57+
defer func() {
58+
err := os.Remove(tmpFile)
59+
if err != nil && !os.IsNotExist(err) {
60+
t.Logf("Failed to remove test key file: %v", err)
61+
}
62+
}()
5363

5464
// Generate and save private key
5565
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)

internal/hook/hook.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"encoding/json"
77
"fmt"
8+
"log"
89
"net"
910
"net/http"
1011
"os/exec"
@@ -83,7 +84,11 @@ func (h *Hook) findContainer() (string, error) {
8384
if err != nil {
8485
return "", err
8586
}
86-
defer resp.Body.Close()
87+
defer func() {
88+
if closeErr := resp.Body.Close(); closeErr != nil {
89+
log.Printf("Failed to close response body: %v", closeErr)
90+
}
91+
}()
8792

8893
var containers []struct {
8994
ID string `json:"Id"`
@@ -153,7 +158,11 @@ func (h *Hook) createExec(containerID string) (string, error) {
153158
if err != nil {
154159
return "", err
155160
}
156-
defer resp.Body.Close()
161+
defer func() {
162+
if closeErr := resp.Body.Close(); closeErr != nil {
163+
log.Printf("Failed to close response body: %v", closeErr)
164+
}
165+
}()
157166

158167
var result struct {
159168
ID string `json:"Id"`
@@ -194,7 +203,11 @@ func (h *Hook) startExec(execID string) error {
194203
if err != nil {
195204
return err
196205
}
197-
defer resp.Body.Close()
206+
defer func() {
207+
if closeErr := resp.Body.Close(); closeErr != nil {
208+
log.Printf("Failed to close response body: %v", closeErr)
209+
}
210+
}()
198211

199212
return nil
200213
}

internal/server/server_test.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,11 @@ func TestServer(t *testing.T) {
7272

7373
resp, err := http.DefaultClient.Do(req)
7474
require.NoError(t, err)
75-
defer resp.Body.Close()
75+
defer func() {
76+
if closeErr := resp.Body.Close(); closeErr != nil {
77+
t.Logf("Failed to close response body: %v", closeErr)
78+
}
79+
}()
7680

7781
assert.Equal(t, tc.expectedCode, resp.StatusCode)
7882

@@ -125,7 +129,11 @@ func TestServer(t *testing.T) {
125129

126130
resp, err := client.Get(tc.requestURL)
127131
require.NoError(t, err)
128-
defer resp.Body.Close()
132+
defer func() {
133+
if closeErr := resp.Body.Close(); closeErr != nil {
134+
t.Logf("Failed to close response body: %v", closeErr)
135+
}
136+
}()
129137

130138
assert.Equal(t, http.StatusMovedPermanently, resp.StatusCode)
131139
location := resp.Header.Get("Location")

internal/zero/manager.go

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"fmt"
88
"log"
99
"path/filepath"
10+
"strings"
1011
"time"
1112

1213
"github.com/yarlson/zero/internal/acme"
@@ -32,8 +33,8 @@ func NewManager(zeroSSL *acme.ZeroSSL, store *cert.Store, hook *hook.Hook) *Mana
3233
}
3334
}
3435

35-
func (s *Manager) ObtainOrRenewCertificate(ctx context.Context, domain, email, certFile, keyFile string) error {
36-
certs, privateKey, err := s.zeroSSL.ObtainCertificate(ctx, domain, email, s.store.StoreChallenge)
36+
func (s *Manager) ObtainOrRenewCertificate(ctx context.Context, domains []string, email, certFile, keyFile string) error {
37+
certs, privateKey, err := s.zeroSSL.ObtainCertificate(ctx, domains, email, s.store.StoreChallenge)
3738
if err != nil {
3839
return fmt.Errorf("obtain certificate: %w", err)
3940
}
@@ -62,18 +63,27 @@ func (s *Manager) CertificateNeedsRenewal(cert *x509.Certificate) bool {
6263
return time.Now().Add(renewBeforeDays * 24 * time.Hour).After(cert.NotAfter)
6364
}
6465

65-
func (s *Manager) CheckCertificate(ctx context.Context, domain, email, certDir string) error {
66-
certFile := filepath.Join(certDir, domain+".crt")
67-
keyFile := filepath.Join(certDir, domain+".key")
66+
func (s *Manager) CheckCertificate(ctx context.Context, domains []string, email, certDir string) error {
67+
var certFile, keyFile string
68+
if len(domains) == 1 {
69+
// Single domain case (backward compatibility)
70+
certFile = filepath.Join(certDir, domains[0]+".crt")
71+
keyFile = filepath.Join(certDir, domains[0]+".key")
72+
} else {
73+
// Multiple domains case - join domain names with underscore
74+
joinedName := strings.Join(domains, "_")
75+
certFile = filepath.Join(certDir, joinedName+".crt")
76+
keyFile = filepath.Join(certDir, joinedName+".key")
77+
}
6878

6979
certificate, err := s.store.LoadCertificate(certFile)
7080
if err != nil {
7181
log.Printf("Load existing certificate: %v", err)
7282
}
7383

7484
if certificate == nil || s.CertificateNeedsRenewal(certificate) {
75-
log.Printf("Obtaining certificate for %s", domain)
76-
if err := s.ObtainOrRenewCertificate(ctx, domain, email, certFile, keyFile); err != nil {
85+
log.Printf("Obtaining certificate for domains: %v", domains)
86+
if err := s.ObtainOrRenewCertificate(ctx, domains, email, certFile, keyFile); err != nil {
7787
if errors.Is(err, context.Canceled) {
7888
return errors.New("operation canceled")
7989
}

0 commit comments

Comments
 (0)