diff --git a/pkg/main/config.go b/pkg/main/config.go index 4d2fb24..a16213d 100644 --- a/pkg/main/config.go +++ b/pkg/main/config.go @@ -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 @@ -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; @@ -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 { diff --git a/pkg/main/config_time_parse.go b/pkg/main/config_time_parse.go index 365f68c..def294f 100644 --- a/pkg/main/config_time_parse.go +++ b/pkg/main/config_time_parse.go @@ -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)") +} diff --git a/pkg/main/main.go b/pkg/main/main.go index 0ee401a..d19c017 100644 --- a/pkg/main/main.go +++ b/pkg/main/main.go @@ -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 diff --git a/pkg/main/update_schedule.go b/pkg/main/update_schedule.go index a69f635..c045d7f 100644 --- a/pkg/main/update_schedule.go +++ b/pkg/main/update_schedule.go @@ -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) + } + } + } + } + }() +}