Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 23 additions & 4 deletions pkg/main/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import (
// CW_CLIENT_LOGLEVEL - zap log level for the app
// CW_CLIENT_BIND_ADDRESS - address to bind the https server to
// CW_CLIENT_BIND_PORT - https server port
// CW_CLIENT_POLLING_INTERVAL - interval for polling server for new certificates (e.g., 1d, 5h, 30m)
// if not specified, polling is disabled (uses server push notifications)

// Certificate Specific:
// CW_CLIENT_0_FILE_UPDATE_TIME_START - 24-hour time when window opens to write key/cert updates to filesystem
Expand Down Expand Up @@ -148,10 +150,11 @@ type certConfig struct {

// config holds all of the client configuration
type config struct {
BindAddress string
BindPort int
ServerAddress string
Certs []certConfig
BindAddress string
BindPort int
ServerAddress string
PollingInterval time.Duration // 0 means polling is disabled
Certs []certConfig
}

// configureApp creates the application from environment variables and/or defaults;
Expand Down Expand Up @@ -205,6 +208,22 @@ func configureApp() (*app, error) {
app.cfg.BindPort = defaultBindPort
}

// CW_CLIENT_POLLING_INTERVAL
pollingIntervalStr := os.Getenv("CW_CLIENT_POLLING_INTERVAL")
if pollingIntervalStr != "" {
app.cfg.PollingInterval, err = parseDurationString(pollingIntervalStr)
if err != nil {
return app, fmt.Errorf("CW_CLIENT_POLLING_INTERVAL is invalid (%s)", err)
}
if app.cfg.PollingInterval < time.Minute {
return app, errors.New("CW_CLIENT_POLLING_INTERVAL must be at least 1 minute")
}
app.logger.Infof("polling interval set to %v", app.cfg.PollingInterval)
} else {
app.cfg.PollingInterval = 0
app.logger.Debug("CW_CLIENT_POLLING_INTERVAL not specified, polling disabled (using server push notifications)")
}

// Configure each cert
certIndex := 0
for {
Expand Down
26 changes: 26 additions & 0 deletions pkg/main/config_time_parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,29 @@ func timeAIsAfterOrEqualB(aHr, aMin, bHr, bMin int) bool {

return false
}

// parseDurationString parses a duration string like "1d", "5h", "30m" and returns
// a time.Duration. Supported units: d (days), h (hours), m (minutes), s (seconds)
func parseDurationString(durationStr string) (time.Duration, error) {
if durationStr == "" {
return 0, errors.New("duration string is empty")
}

// check for standard Go duration format first (supports combinations like "1h30m")
duration, err := time.ParseDuration(durationStr)
if err == nil {
return duration, nil
}

// if standard parsing fails, try custom day format
if strings.HasSuffix(durationStr, "d") {
daysStr := strings.TrimSuffix(durationStr, "d")
days, err := strconv.Atoi(daysStr)
if err != nil || days <= 0 {
return 0, errors.New("invalid duration format (use formats like: 1d, 5h, 30m, 1h30m)")
}
return time.Duration(days) * 24 * time.Hour, nil
}

return 0, errors.New("invalid duration format (use formats like: 1d, 5h, 30m, 1h30m)")
}
5 changes: 5 additions & 0 deletions pkg/main/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ func main() {
// failed to get newest cert, so schedule future fetch and write
app.scheduleJobFetchCertsAndWriteToDisk(certIndex)
}

// if polling is enabled, start polling job
if app.cfg.PollingInterval > 0 {
app.scheduleJobPollingFetch(certIndex)
}
}

// start https server
Expand Down
49 changes: 49 additions & 0 deletions pkg/main/update_schedule.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,3 +197,52 @@ func (app *app) scheduleJobFetchCertsAndWriteToDisk(certIndex int) {
app.logger.Infof("fetch cert %d job scheduled for %s complete", certIndex, runTimeString)
}()
}

// scheduleJobPollingFetch creates a background job that polls the server at regular
// intervals to check for new certificates. This is an alternative to server push
// notifications when the client cannot accept incoming connections.
func (app *app) scheduleJobPollingFetch(certIndex int) {
go func() {
// cancel any old job
if app.pendingJobCancels[certIndex] != nil {
app.pendingJobCancels[certIndex]()
}

// make new cancel context for this job
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app.pendingJobCancels[certIndex] = cancel

app.logger.Infof("starting polling for cert %d with interval %v", certIndex, app.cfg.PollingInterval)

ticker := time.NewTicker(app.cfg.PollingInterval)
defer ticker.Stop()

for {
select {
case <-ctx.Done():
app.logger.Infof("polling job for cert %d canceled", certIndex)
return

case <-ticker.C:
app.logger.Debugf("polling server for cert %d updates", certIndex)

// try to fetch newer key/cert from server
err := app.updateClientKeyAndCertchain(certIndex)
if err != nil {
app.logger.Errorf("failed to poll and fetch key/cert %d from server (%s)", certIndex, err)
// continue polling despite error
} else {
// success - check if disk needs update
diskNeedsUpdate := app.updateCertFilesAndRestartContainers(certIndex, false)

// if disk needs update but we're outside the update window,
// schedule a write job for the next window
if diskNeedsUpdate {
app.scheduleJobWriteCertsMemoryToDisk(certIndex)
}
}
}
}
}()
}