Documentation
¶
Overview ¶
Package sensorstate keeps an API-key sensor's state: the state directory, the credentials file in it (the renewed API key, written atomically and 0600), the choice between the configured and the renewed key at start, and the key renewal loop with its protocol v2 client (POST /api/v2/sensor/keys).
It holds what sensorkit used from the former pkg/platform, whose registration, lease and poll routes no OpenCTEM API served; that package is removed.
Index ¶
- Constants
- Variables
- func DecideKeyAutoRenew(setting string, p StatePersistence) (bool, string)
- func DefaultCredentialsFile() (string, error)
- func KeyFingerprint(key string) string
- func KeyFingerprintMatches(fp, key string) (match, ok bool)
- func LegacyCredentialsFile() (string, error)
- func MigrateCredentialsFile(from, to string) (moved bool, err error)
- func ResolveCredentialsFile(explicit string) (string, error)
- func ResolveStateCredentialsFile(explicit, stateDir string) (string, error)
- func ResolveStateDir(explicit string) string
- func RotatedKeySaver(store *FileCredentialStore, configuredKey, sensorID string) func(newKey string, expiresAt *time.Time) error
- func StateCredentialsFile(stateDir string) string
- type FileCredentialStore
- type KeyChoice
- type KeyRenewConfig
- type KeyRenewManager
- type KeyRenewer
- type RenewClient
- type RenewError
- type RenewKeyResponse
- type SensorCredentials
- type StatePersistence
Constants ¶
const ( // DefaultStateDir is where a sensor keeps its state when nothing else is // configured and the directory is usable. The images create it for the // sensor user; the install snippets mount one data volume on its parent, // /var/lib/openctem (state/, outbox/, content/), and a mount on the // state directory itself still works. CheckStatePersistence judges a // directory by its nearest mount, so either counts. DefaultStateDir = "/var/lib/openctem/state" // EnvStateDir overrides the state directory. EnvStateDir = "SENSOR_STATE_DIR" )
State directory defaults.
Variables ¶
var ErrCredentialsFileNotMoved = errors.New("credentials file not moved")
ErrCredentialsFileNotMoved is returned by MigrateCredentialsFile when the old file is valid but could not be moved (a read-only volume, a Kubernetes Secret, a single bind-mounted file). Nothing was changed.
var ErrRenewUnsupported = errors.New("the platform does not serve sensor protocol v2 key renewal " +
"(upgrade the OpenCTEM API: protocol v1 is retired)")
ErrRenewUnsupported: the platform does not serve sensor protocol v2 key renewal (an OpenCTEM API from before 2026-10); upgrade the platform.
Functions ¶
func DecideKeyAutoRenew ¶
func DecideKeyAutoRenew(setting string, p StatePersistence) (bool, string)
DecideKeyAutoRenew turns a renewal setting into a decision: "true" / "on" / "1" renews, "false" / "off" / "0" does not, and "" or "auto" renews only when the state directory persists (a renewed key that is lost on restart locks the sensor out).
func DefaultCredentialsFile ¶
DefaultCredentialsFile is where a sensor keeps its credentials when no path is configured: ~/.openctem/sensor-credentials.json.
func KeyFingerprint ¶
KeyFingerprint returns a salted PBKDF2-SHA256 fingerprint of an API key, "pbkdf2-sha256$<iterations>$<salt>$<hash>" (base64url, no padding). The credentials file keeps the fingerprint of the configured key its renewed key descends from, never the configured key itself; a fresh salt makes each fingerprint different, so compare with KeyFingerprintMatches.
func KeyFingerprintMatches ¶
KeyFingerprintMatches reports whether key has the fingerprint fp (KeyFingerprint). ok is false when fp is not a fingerprint this SDK can check (empty, malformed, another scheme), so the caller can treat it as unknown rather than as a different key.
func LegacyCredentialsFile ¶
LegacyCredentialsFile is the default credentials path of sensors from before the agent -> sensor rename: ~/.openctem/agent-credentials.json.
func MigrateCredentialsFile ¶
MigrateCredentialsFile moves the credentials file at from to to, without ever leaving the sensor with no readable credentials:
- from is read and parsed (an unreadable or invalid file is an error, not a reason to register a second sensor);
- the credentials are written to to atomically — temp file with mode 0600, fsync, rename, directory fsync;
- to is read back and compared with what was read from from;
- only then is from removed.
An interruption at any step leaves from in place, and the next start migrates again. If writing to, verifying it or removing from fails, to is removed again and the error wraps ErrCredentialsFileNotMoved: the state is exactly as before. When to already exists it wins: from is left untouched and a warning names both files. When from does not exist there is nothing to do. It reports whether a file was moved.
func ResolveCredentialsFile ¶
ResolveCredentialsFile returns the credentials file a sensor should use. An explicit path is returned as is. With an empty path it returns DefaultCredentialsFile, after moving a pre-rename credentials file there (MigrateCredentialsFile), so an upgraded sensor keeps its identity and API key without registering again. When the old file cannot be moved (see ErrCredentialsFileNotMoved) it is used where it is, with a warning, so a deployment that mounts it read-only keeps starting exactly as before.
func ResolveStateCredentialsFile ¶
ResolveStateCredentialsFile returns the credentials file a sensor should use. An explicit path is returned as is. Otherwise it is the file in stateDir; a credentials file an earlier version kept in the home directory (~/.openctem/sensor-credentials.json, or the pre-rename agent-credentials.json) is moved there first (MigrateCredentialsFile), so an upgraded sensor keeps the key it renewed. When that file cannot be moved it is used where it is, with a warning.
func ResolveStateDir ¶
ResolveStateDir returns the sensor's state directory: explicit, else $SENSOR_STATE_DIR, else DefaultStateDir when it exists or can be created and is writable, else ~/.openctem (a sensor binary run by a user without /var/lib/openctem). The returned directory may not exist yet only in the last case.
func RotatedKeySaver ¶
func RotatedKeySaver(store *FileCredentialStore, configuredKey, sensorID string) func(newKey string, expiresAt *time.Time) error
RotatedKeySaver returns a KeyRenewConfig.OnRotated that saves each renewed key, its expiry (or that it has none) and the fingerprint of configuredKey (the key the sensor was installed with) to the credentials file, atomically and 0600 (FileCredentialStore.Save).
func StateCredentialsFile ¶
StateCredentialsFile is the credentials file in a state directory.
Types ¶
type FileCredentialStore ¶
type FileCredentialStore struct {
Path string
}
FileCredentialStore stores credentials in a file.
func NewFileCredentialStore ¶
func NewFileCredentialStore(path string) *FileCredentialStore
NewFileCredentialStore creates a new file-based credential store.
func (*FileCredentialStore) Exists ¶
func (s *FileCredentialStore) Exists() bool
Exists checks if the credentials file exists.
func (*FileCredentialStore) Load ¶
func (s *FileCredentialStore) Load() (*SensorCredentials, error)
Load loads credentials from the file.
func (*FileCredentialStore) Save ¶
func (s *FileCredentialStore) Save(creds *SensorCredentials) error
Save saves credentials to the file.
The write is atomic and owner-only: the JSON goes to a 0600 temp file in the same directory, is fsynced, then renamed over Path. A crash or full disk mid-write therefore never leaves a truncated credentials file (which would lose the only copy of a rotated key), and a pre-existing file with looser permissions is replaced by a 0600 one rather than rewritten in place with its old mode.
type KeyChoice ¶
type KeyChoice struct {
// APIKey is the key to use.
APIKey string
// ExpiresAt is its expiry when known (pass it to
// KeyRenewConfig.CurrentKeyExpiresAt).
ExpiresAt *time.Time
// NeverExpires is true when the server said the key has no expiry (pass
// it to KeyRenewConfig.CurrentKeyNeverExpires). False when unknown.
NeverExpires bool
// FromStateFile is true when the key is a renewed key read from the
// credentials file rather than the configured one.
FromStateFile bool
// Reason explains the choice, for the sensor's log.
Reason string
}
KeyChoice is the API key a sensor starts with.
func ChooseAPIKey ¶
func ChooseAPIKey(store *FileCredentialStore, configuredKey, sensorID string) KeyChoice
ChooseAPIKey picks the key a sensor starts with, from its configured key (SENSOR_API_KEY, a flag, a config file) and the credentials file. The renewed key in the file wins, because a renewal retires the configured key, except:
- the file is missing, unreadable or empty, or belongs to another sensor (sensorID set and different): the configured key;
- the configured key is not the one the file's key descends from (an administrator regenerated the key and the operator configured the new one): the configured key. The file is replaced on the next renewal.
A file written before fingerprints existed has no fingerprint; its key wins, as it did before. With no configured key the file's key is used.
type KeyRenewConfig ¶
type KeyRenewConfig struct {
// RenewFraction is the fraction of the key lifetime at which to renew
// (0 < f < 1). Default 0.5.
RenewFraction float64
// MinInterval floors the computed schedule. Default 1m.
MinInterval time.Duration
// RetryInterval is the wait before retrying a failed renewal. Default 30s.
RetryInterval time.Duration
// OnRotated is called after a successful key swap so the caller can persist
// the new key (e.g. FileCredentialStore.Save) for the next restart.
//
// A non-nil error is treated as a failure, not a warning: the server has
// already invalidated the previous key, so an unpersisted new key means
// the next restart comes up with a dead credential. The manager reports
// the error (stderr + OnPersistError), keeps the running client on the new
// key, and retries the persist every RetryInterval. It does NOT rotate
// again until the persist succeeds — rotating on top of an unsaved key
// would only widen the gap.
OnRotated func(newKey string, expiresAt *time.Time) error
// OnPersistError, if set, is called each time OnRotated fails. Use it to
// surface the condition (metrics, health check, alert).
OnPersistError func(err error)
// CurrentKeyExpiresAt is the expiry of the key the client starts with,
// when the caller knows it (e.g. stored next to the key). When set, the
// first renewal is scheduled from it instead of rotating immediately.
CurrentKeyExpiresAt *time.Time
// CurrentKeyNeverExpires tells the manager the server issued the current
// key without a TTL. The manager then does nothing: there is nothing to
// renew, and the discovery rotation would replace a working key for no
// benefit. Leave false when unknown — the manager then performs one
// discovery renewal to learn the server's TTL posture.
CurrentKeyNeverExpires bool
// Verbose enables debug logging.
Verbose bool
}
KeyRenewConfig configures the KeyRenewManager.
type KeyRenewManager ¶
type KeyRenewManager struct {
// contains filtered or unexported fields
}
KeyRenewManager auto-renews the sensor's API key before it expires, then swaps it into the live client and persists it. It is a no-op when the server has no key TTL (renewal returns a nil expiry) — after the discovery renewal it stops.
Rotation model: this pairs with a server that issues short-lived keys. On the current server the previous key is invalidated the instant renewal succeeds (no overlap window yet), so a request already in flight at the swap instant can see a 401; the poll and lease loops self-heal on their next tick. Zero- window overlap is a planned server-side change.
func NewKeyRenewManager ¶
func NewKeyRenewManager(client KeyRenewer, config *KeyRenewConfig) *KeyRenewManager
NewKeyRenewManager creates a KeyRenewManager. A nil config uses defaults.
func (*KeyRenewManager) RenewNow ¶
func (m *KeyRenewManager) RenewNow()
RenewNow asks the running manager to renew the key now instead of at its schedule, e.g. because the platform's heartbeat said the key is inside its renewal window (the rotate_key doorbell action). It never blocks; requests made while one is pending collapse into it, and a request within MinInterval of the last rotation is ignored (the platform judged the key that was just replaced).
func (*KeyRenewManager) Start ¶
func (m *KeyRenewManager) Start(ctx context.Context) error
Start launches the renewal loop in the background. Without a known expiry it performs one discovery renewal immediately: if the server returns no expiry (TTL disabled), nothing more is scheduled and the sensor keeps its non-expiring key. Otherwise it schedules the next renewal at RenewFraction of the remaining lifetime and repeats. In every case RenewNow still renews on request while the loop runs.
type KeyRenewer ¶
type KeyRenewer interface {
RenewKey(ctx context.Context) (*RenewKeyResponse, error)
SetAPIKey(key string)
}
KeyRenewer is the subset of RenewClient the renew manager needs: fetch a fresh key and swap it in. *RenewClient satisfies this.
type RenewClient ¶
type RenewClient struct {
// contains filtered or unexported fields
}
RenewClient renews an API-key sensor's key (POST /api/v2/sensor/keys, api RFC-029 §4.7). It satisfies KeyRenewer.
func NewRenewClient ¶
func NewRenewClient(baseURL, apiKey string) *RenewClient
NewRenewClient creates a RenewClient for the platform at baseURL that presents apiKey. Its HTTP client is SSRF-guarded, follows no redirects (following one would forward the key) and sends the SDK User-Agent.
func (*RenewClient) RenewKey ¶
func (c *RenewClient) RenewKey(ctx context.Context) (*RenewKeyResponse, error)
RenewKey rotates the key by presenting the current one. A platform that does not serve the route (404/405 without a problem document) answers ErrRenewUnsupported. It does NOT swap the key in: the caller decides when to call SetAPIKey (and persist), so a failed persist never leaves the running client on a key the sensor cannot recover after a restart. It never retries: without a key TTL the platform replaces the presented key, so a repeated renewal after a lost answer would be refused.
func (*RenewClient) SetAPIKey ¶
func (c *RenewClient) SetAPIKey(key string)
SetAPIKey swaps the key presented by later renewals. Safe for concurrent use.
type RenewError ¶
type RenewError struct{ StatusCode int }
RenewError is a refused key renewal. It satisfies the HTTPStatusCode interface pkg/core uses to tell a rejected key from other failures.
func (*RenewError) Error ¶
func (e *RenewError) Error() string
func (*RenewError) HTTPStatusCode ¶
func (e *RenewError) HTTPStatusCode() int
HTTPStatusCode returns the response status.
type RenewKeyResponse ¶
type RenewKeyResponse struct {
APIKey string `json:"api_key"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
}
RenewKeyResponse is the result of a renewal. ExpiresAt is nil when the server has no key TTL configured (the key never expires).
type SensorCredentials ¶
type SensorCredentials struct {
SensorID string `json:"sensor_id"`
APIKey string `json:"api_key"`
APIPrefix string `json:"api_prefix"`
// ExpiresAt is the key's expiry when known (from a key renewal). Pass it
// to KeyRenewConfig.CurrentKeyExpiresAt on startup so the renewer does
// not rotate a still-valid key immediately.
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// ConfiguredKeyFingerprint is the salted PBKDF2 fingerprint
// (KeyFingerprint) of the configured key APIKey was renewed from. When
// the configured key no longer matches it, an administrator issued a new
// key and the operator configured it: ChooseAPIKey then uses the
// configured key. Empty in files written before it existed.
ConfiguredKeyFingerprint string `json:"configured_key_fingerprint,omitempty"`
// NeverExpires records that the server issued APIKey without an expiry
// (its key TTL is off), as opposed to an expiry that is not known. Pass it
// to KeyRenewConfig.CurrentKeyNeverExpires so a restart does not rotate
// the key again just to learn that.
NeverExpires bool `json:"never_expires,omitempty"`
}
SensorCredentials is the format of the credentials file (FileCredentialStore). The sensor id is written as "sensor_id"; files written before the agent -> sensor rename carry "agent_id" and are still read (see UnmarshalJSON).
func (*SensorCredentials) UnmarshalJSON ¶
func (c *SensorCredentials) UnmarshalJSON(data []byte) error
UnmarshalJSON reads both the current "sensor_id" key and the "agent_id" key credentials files written before the agent -> sensor rename carry. Both present with different values is an error: the file has been edited by hand and the sensor's identity is ambiguous.
type StatePersistence ¶
type StatePersistence struct {
Persistent bool
// Reason explains the verdict, for the sensor's log.
Reason string
}
StatePersistence says whether a state directory survives the sensor being recreated.
func CheckStatePersistence ¶
func CheckStatePersistence(dir string) StatePersistence
CheckStatePersistence reports whether dir survives the sensor being recreated. Outside a container the host filesystem persists. Inside one, dir must be on a mounted volume (the nearest mount at or above dir: a volume at /var/lib/openctem covers /var/lib/openctem/state) (a Docker volume, a bind mount, a Kubernetes volume) that is not a tmpfs: the container's writable layer is lost when the container is recreated. A Kubernetes emptyDir looks like a volume and is lost with the pod; the Helm chart therefore decides key renewal explicitly instead of relying on this check.