Documentation
¶
Overview ¶
Package updater implements Ether's signed self-update feed.
A release publishes one JSON manifest per channel ("stable" / "beta"). The manifest lists per-platform artifacts (a bare ether binary and/or a platform installer) with SHA-256 checksums, and carries an ed25519 signature — embedded in the "signature" field, or detached in a sibling "<manifest>.sig" file. The embedded signature covers the canonical JSON encoding of the manifest with the signature field emptied; a detached signature covers the exact published manifest bytes. The release public key is compiled in (PublicKeyB64, set via -ldflags); cmd/ether-release-sign generates keys and signs manifests in CI.
Nothing unsigned or mis-checksummed is ever applied: Check verifies the signature before comparing versions, and Download verifies size and SHA-256 before Apply touches the installed binary.
Index ¶
- Constants
- Variables
- func ApplyBinary(newPath, target string) error
- func CleanupOld(target string) error
- func CompareVersions(a, b string) (int, error)
- func DefaultPublicKey() (ed25519.PublicKey, error)
- func DirWritable(dir string) bool
- func EncodeKey(k []byte) string
- func FailedPath(dir string) string
- func GenerateKeyPair() (pub ed25519.PublicKey, priv ed25519.PrivateKey, err error)
- func InContainer() bool
- func IsDev() bool
- func Newer(candidate, current string) bool
- func PendingPath(dir string) string
- func PrivateKeyFromB64(s string) (ed25519.PrivateKey, error)
- func PublicKeyFromB64(s string) (ed25519.PublicKey, error)
- func Rollback(target string) error
- func SelfApplyCapability(env Environment) (bool, string)
- func ShouldAutoApply(upd config.Updates) bool
- func SignDetached(priv ed25519.PrivateKey, manifestBytes []byte) (string, error)
- func VerifyDetached(pub ed25519.PublicKey, manifestBytes []byte, sigB64 string) error
- func VerifyFile(path string, art *Artifact) error
- type ApplyOptions
- type Artifact
- type AvailableUpdate
- type Checker
- type Client
- func (c *Client) Apply(ctx context.Context, upd *Update, opts ApplyOptions) (string, error)
- func (c *Client) Check(ctx context.Context) (*Update, error)
- func (c *Client) CurrentVersion() string
- func (c *Client) Download(ctx context.Context, upd *Update, dir string, progress ProgressFunc) (string, error)
- func (c *Client) DownloadArtifact(ctx context.Context, art *Artifact, dir string, progress ProgressFunc) (string, error)
- func (c *Client) ManifestURL() string
- func (c *Client) Poll(ctx context.Context, interval, firstDelay time.Duration, ...)
- type ClusterUpdateStatus
- type Coordinator
- type Environment
- type FailedUpdate
- type Manager
- type ManagerOptions
- type ManagerStatus
- type Manifest
- func (m *Manifest) ArtifactFor(goos, goarch string, prefKinds ...string) (*Artifact, error)
- func (m *Manifest) RenewFreshness(now time.Time)
- func (m *Manifest) Sign(priv ed25519.PrivateKey) error
- func (m *Manifest) SigningBytes() ([]byte, error)
- func (m *Manifest) Validate() error
- func (m *Manifest) ValidateFreshness(now time.Time) error
- func (m *Manifest) Verify(pub ed25519.PublicKey) error
- type Options
- type Pending
- type ProgressFunc
- type Semver
- type StartupOptions
- type StartupResult
- type Update
- type Window
Constants ¶
const ( StateIdle = "idle" StateDownloading = "downloading" StateStaged = "staged" StateWaitingWindow = "waiting-for-window" StateWaitingIdle = "waiting-for-idle" StateApplying = "applying" StateRolledBack = "rolled-back" // Clustered nodes only (see Coordinator). StateWaitingTurn = "waiting-for-turn" // another node holds the update lease StateDraining = "draining" // lease held; waiting for this node's sessions to end )
Manager states reported by Status.
const ( CapabilitySelfApply = "self-apply" CapabilityNotifyOnly = "notify-only" CapabilityDisabled = "disabled" )
Capabilities reported by Status.
const ( DefaultPollInterval = 24 * time.Hour DefaultFirstCheckDelay = 5 * time.Minute DefaultRetryInterval = time.Hour DefaultQuietPeriod = 10 * time.Minute DefaultHealthPeriod = 10 * time.Minute DefaultTick = 30 * time.Second DefaultWindowStart = "03:00" DefaultWindowEnd = "06:00" // DefaultMaxDrain bounds a clustered node's drain before an install. DefaultMaxDrain = 30 * time.Minute )
Manager defaults.
const ( ChannelStable = "stable" ChannelBeta = "beta" )
Channels.
const ( KindBinary = "binary" // bare ether executable; atomic self-replace KindInstaller = "installer" // .dmg/.pkg/.exe/.msi/.deb/.rpm; downloaded + launched KindArchive = "archive" // .tar.gz/.zip bundle; downloaded, not applied automatically )
Artifact kinds.
const DefaultFeedBase = "https://github.com/braedonsaunders/ether/releases/download/channels"
DefaultFeedBase is where release manifests live when config has no override: <base>/<channel>.json (+ optional <channel>.json.sig). The release workflow keeps every channel's current manifest on the rolling "channels" release: GitHub's releases/latest never points at a beta, so a beta manifest cannot be found there.
const ( // DefaultMaxStartAttempts is how many starts of a freshly installed // version may happen without it confirming health before the previous // binary is restored. DefaultMaxStartAttempts = 3 )
Marker files inside the updates directory (<dataDir>/updates).
const ManifestLifetime = 30 * 24 * time.Hour
ManifestLifetime bounds replay to 30 days. The publisher refreshes the signature weekly without changing the release, artifacts, or publication date.
Variables ¶
var ( // ErrUpdatesDisabled: this build cannot verify updates (no release key) // or the feed settings are unusable. ErrUpdatesDisabled = errors.New("updater: updates are disabled in this build") // ErrNotSelfApplicable: the install is notify-only (package manager, // container, macOS app bundle, read-only install dir, ...). ErrNotSelfApplicable = errors.New("updater: this installation cannot update itself") // ErrClusterRolledBack is returned by Coordinator.AcquireUpdate for a // release that rolled back on another node of the cluster. ErrClusterRolledBack = errors.New("updater: this release was rolled back on another cluster node") )
var ( ErrBadSignature = errors.New("updater: manifest signature verification failed") ErrNoPublicKey = errors.New("updater: no release public key configured") ErrNoArtifact = errors.New("updater: no artifact for this platform") ErrChecksum = errors.New("updater: artifact checksum mismatch") ErrTooOldToUpdate = errors.New("updater: installed version is too old to update directly; reinstall") ErrStaleManifest = errors.New("updater: update feed is expired or lacks signed freshness; check the server clock and contact the feed publisher") )
var ( Version = "dev" Commit = "" Date = "" // PublicKeyB64 is the base64 (std encoding) ed25519 public key that // release manifests are signed with. Injected at release build time; // empty in dev builds, in which case Check requires an explicit key. PublicKeyB64 = "" )
Build metadata, set by goreleaser via -ldflags -X. Version is a semver string ("1.2.3", optionally "v1.2.3-beta.1"); "dev" in development builds.
Functions ¶
func ApplyBinary ¶
ApplyBinary atomically replaces target with the verified new binary at newPath, keeping the previous binary at "<target>.old".
Unix: the new file is staged next to the target and rename(2)d over it — running processes keep their inode, so this is safe mid-run. Windows cannot overwrite a running executable, so the current binary is first renamed aside to .old (allowed while running), then the new one moved in; on failure the .old is moved back.
func CleanupOld ¶
CleanupOld removes a stale "<target>.old" left by a previous update. The server calls this at startup once the new binary is confirmed running (on Windows the .old of a *running* binary can only be deleted by the next process, which is exactly this).
func CompareVersions ¶
CompareVersions returns -1, 0 or 1 (a <, ==, > b) per semver precedence: a release outranks its prereleases; numeric prerelease identifiers compare numerically and rank below alphanumeric ones.
func DefaultPublicKey ¶
DefaultPublicKey returns the compiled-in release public key, if any.
func DirWritable ¶
DirWritable reports whether this process can create files in dir.
func FailedPath ¶
func GenerateKeyPair ¶
func GenerateKeyPair() (pub ed25519.PublicKey, priv ed25519.PrivateKey, err error)
GenerateKeyPair creates a fresh ed25519 release-signing key pair.
func InContainer ¶
func InContainer() bool
InContainer reports whether we are running inside a container image, which must be updated by pulling a new image, never in place.
func Newer ¶
Newer reports whether candidate is strictly newer than current. An unparseable/dev current version makes every valid candidate newer.
func PendingPath ¶
PendingPath / FailedPath locate the marker files inside dir.
func PrivateKeyFromB64 ¶
func PrivateKeyFromB64(s string) (ed25519.PrivateKey, error)
PrivateKeyFromB64 parses a base64 private key (64-byte ed25519 form, or a 32-byte seed which is expanded).
func PublicKeyFromB64 ¶
PublicKeyFromB64 parses a base64 public key (as baked in via -ldflags).
func Rollback ¶
Rollback restores "<target>.old" over target. On Windows the caller must not be running from target (the service should be stopped first).
func SelfApplyCapability ¶
func SelfApplyCapability(env Environment) (bool, string)
SelfApplyCapability decides whether this install may replace its own binary. Anything owned by something else — a package manager, a container image, a signed macOS app bundle — is notify-only.
func ShouldAutoApply ¶
ShouldAutoApply interprets the owner's update policy: auto-apply only when config says so; the tray/server otherwise prompts.
func SignDetached ¶
func SignDetached(priv ed25519.PrivateKey, manifestBytes []byte) (string, error)
SignDetached returns a base64 signature over the exact manifest file bytes, for publishing next to (not inside) the manifest.
func VerifyDetached ¶
VerifyDetached checks a detached base64 (or raw) signature over the exact bytes the manifest was served as.
func VerifyFile ¶
VerifyFile checks an already-downloaded file against an artifact.
Types ¶
type ApplyOptions ¶
type ApplyOptions struct {
// Target is the installed binary replaced for KindBinary updates.
// Empty: os.Executable().
Target string
// DownloadDir for the artifact. Empty: temp dir.
DownloadDir string
// LaunchInstaller starts a downloaded installer/.dmg. Nil: the
// platform default (exec the installer / `open` on macOS).
LaunchInstaller func(ctx context.Context, path string) error
Progress ProgressFunc
}
ApplyOptions tunes Apply.
type Artifact ¶
type Artifact struct {
OS string `json:"os"` // runtime.GOOS
Arch string `json:"arch"` // runtime.GOARCH, or "universal" (macOS)
Kind string `json:"kind"` // KindBinary | KindInstaller | KindArchive
URL string `json:"url"`
SHA256 string `json:"sha256"` // hex, lowercase
Size int64 `json:"size"`
Filename string `json:"filename,omitempty"`
}
Artifact is one downloadable file for one platform.
type AvailableUpdate ¶
type AvailableUpdate struct {
Version string `json:"version"`
Kind string `json:"kind"`
NotesURL string `json:"notesUrl,omitempty"`
Notes string `json:"notes,omitempty"`
PublishedAt *time.Time `json:"publishedAt,omitempty"`
Staged bool `json:"staged"`
}
AvailableUpdate describes the newest release on the feed.
type Checker ¶
type Checker interface {
Check(ctx context.Context) (*Update, error)
Download(ctx context.Context, upd *Update, dir string, progress ProgressFunc) (string, error)
}
Checker is the slice of *Client the Manager needs.
type Client ¶
type Client struct {
// contains filtered or unexported fields
}
Client checks, downloads and applies updates from the signed feed.
func NewClient ¶
NewClient builds a Client. It fails fast when no usable public key is configured: an updater that cannot verify must never update.
func NewClientFromConfig ¶
NewClientFromConfig maps the owner's Updates settings onto a Client.
func (*Client) Apply ¶
Apply downloads (and verifies) the update, then:
- KindBinary: atomically replaces the target binary, keeping "<target>.old" for rollback. The caller must restart the process.
- KindInstaller: launches the installer and returns; the installer owns stopping/restarting the service.
- KindArchive: returns the downloaded path without applying.
It returns the artifact file path.
func (*Client) Check ¶
Check fetches and verifies the channel manifest. It returns (nil, nil) when the feed's version is not newer than the running one.
func (*Client) CurrentVersion ¶
CurrentVersion is the version Check compares against.
func (*Client) Download ¶
func (c *Client) Download(ctx context.Context, upd *Update, dir string, progress ProgressFunc) (string, error)
Download fetches the update's artifact into dir (empty: tempDir()), verifying size and SHA-256. The returned file is complete and verified; partial files are removed on error. Downloads are capped at max(Size, Size*1.1) when Size is known, else 2 GiB.
func (*Client) DownloadArtifact ¶
func (c *Client) DownloadArtifact(ctx context.Context, art *Artifact, dir string, progress ProgressFunc) (string, error)
DownloadArtifact is Download for a bare Artifact.
func (*Client) ManifestURL ¶
ManifestURL resolves the feed URL for the configured channel:
- a URL containing "{channel}" has it substituted;
- a URL ending in ".json" is used as-is;
- anything else is a base: "<base>/<channel>.json".
func (*Client) Poll ¶
func (c *Client) Poll(ctx context.Context, interval, firstDelay time.Duration, onUpdate func(context.Context, *Update))
Poll periodically checks the feed until ctx ends, invoking onUpdate for each newly-seen update version. Check errors are logged and retried on the next tick; the first check runs immediately unless firstDelay > 0.
type ClusterUpdateStatus ¶
type ClusterUpdateStatus struct {
// LeaseHeld is true while this node holds the cluster update lease
// for an install.
LeaseHeld bool `json:"leaseHeld"`
// LeaseHolder is the node last seen holding the lease (this node's
// name when LeaseHeld).
LeaseHolder string `json:"leaseHolder,omitempty"`
Draining bool `json:"draining"`
DrainStarted *time.Time `json:"drainStarted,omitempty"`
MaxDrainSeconds int `json:"maxDrainSeconds"`
}
ClusterUpdateStatus is a clustered node's part in a rolling update.
type Coordinator ¶
type Coordinator interface {
// AcquireUpdate takes, or renews, the update lease for version. ok is
// false while another node (holder) holds it; err wraps
// ErrClusterRolledBack for a release that rolled back elsewhere.
AcquireUpdate(ctx context.Context, version string) (ok bool, holder string, err error)
// ReleaseUpdate gives the lease back if this node holds it.
ReleaseUpdate(ctx context.Context) error
// ReportFailed records that version rolled back on this node.
ReportFailed(ctx context.Context, version string) error
// SetDraining takes this node out of (true) or back into (false) the
// rotation for new sessions.
SetDraining(draining bool)
}
Coordinator serialises self-updates across a cluster (rolling updates). With a Coordinator the manager installs a release only while holding the cluster-wide update lease, so nodes restart one at a time; before installing it drains this node (no new sessions, out of the published connections) and waits for this node's own sessions to end, up to Updates.MaxDrain. The lease stays held through the restart: the new process renews it while confirming its health and releases it once confirmed. A process that finds no update to confirm releases a lease this node still holds; one that rolled back reports the failed release so the other nodes skip it.
type Environment ¶
type Environment struct {
GOOS string
Target string // running executable
Dev bool // IsDev()
InContainer bool
// Supervised: a service manager restarts the process after it exits.
Supervised bool
// DirWritable probes the executable's directory (default: DirWritable).
DirWritable func(dir string) bool
}
Environment describes the install for SelfApplyCapability.
type FailedUpdate ¶
type FailedUpdate struct {
Version string `json:"version"`
From string `json:"from"`
At time.Time `json:"at"`
Reason string `json:"reason"`
// ReportPending is set by a rollback and cleared once a clustered node
// has told the other nodes to skip Version. A successful rollback
// restarts straight into the restored binary, so it is that process
// (with no update to confirm) that reports.
ReportPending bool `json:"reportPending,omitempty"`
}
FailedUpdate records a version that was rolled back; the manager skips exactly this version until a newer one is published.
type Manager ¶
type Manager struct {
// contains filtered or unexported fields
}
Manager polls the signed feed and, when allowed, downloads a release early and installs it at a safe moment: inside the maintenance window (unless the owner pressed "Update now") and after the server has been idle for the quiet period (unless forced).
func NewManager ¶
func NewManager(opts ManagerOptions) *Manager
NewManager builds a Manager; it does not start polling (see Run).
type ManagerOptions ¶
type ManagerOptions struct {
// NewClient builds the feed client for the current settings; it is
// rebuilt when channel/feed change. Nil or failing at construction:
// update checks are disabled (startup confirmation still runs).
NewClient func(config.Updates) (Checker, error)
// Config returns the live Updates settings.
Config func() config.Updates
// CurrentVersion is the running version (default: Version).
CurrentVersion string
// Dir is <dataDir>/updates: downloads and marker files.
Dir string
// Target is the running server executable that a binary update
// replaces (default: os.Executable()).
Target string
// SelfApply reports whether this install may replace its own binary;
// NotifyReason explains why not. See SelfApplyCapability.
SelfApply bool
NotifyReason string
// ActiveSessions returns how many playback / transcode / live TV
// sessions are in progress. Nil: always idle.
ActiveSessions func() int
// RequestRestart asks the app to shut down gracefully and exit so the
// service manager starts the new binary. Called at most once.
RequestRestart func(reason string)
// Boot is the result of ConfirmStartup for this process.
Boot StartupResult
// Coordinator, when set (a clustered node that can self-apply),
// coordinates installs with the other nodes; nil keeps the standalone
// behaviour (install after the server has been idle for QuietPeriod).
Coordinator Coordinator
// NodeName labels this node in status (the lease holder is compared
// with it); optional.
NodeName string
Now func() time.Time
Location *time.Location // maintenance window zone (default time.Local)
PollInterval time.Duration
FirstCheckDelay time.Duration
RetryInterval time.Duration
QuietPeriod time.Duration
HealthPeriod time.Duration
Tick time.Duration
// ApplyBinary / CleanupOld default to the package functions.
ApplyBinary func(newPath, target string) error
CleanupOld func(target string) error
Logger *slog.Logger
}
ManagerOptions configures a Manager. Only Dir and Config are required.
type ManagerStatus ¶
type ManagerStatus struct {
Enabled bool `json:"enabled"`
DisabledReason string `json:"disabledReason,omitempty"`
Current string `json:"current"`
Available *AvailableUpdate `json:"available,omitempty"`
Capability string `json:"capability"`
CapabilityReason string `json:"capabilityReason,omitempty"`
State string `json:"state"`
AutoUpdate bool `json:"autoUpdate"`
Requested bool `json:"requested"`
Force bool `json:"force"`
LastCheck *time.Time `json:"lastCheck,omitempty"`
NextCheck *time.Time `json:"nextCheck,omitempty"`
LastError string `json:"lastError,omitempty"`
Window Window `json:"window"`
ActiveSessions int `json:"activeSessions"`
IdleSince *time.Time `json:"idleSince,omitempty"`
QuietPeriodSeconds int `json:"quietPeriodSeconds"`
Confirming bool `json:"confirming"`
FailedVersion string `json:"failedVersion,omitempty"`
FailedReason string `json:"failedReason,omitempty"`
Note string `json:"note,omitempty"`
// Cluster is the rolling-update state of a clustered node.
Cluster *ClusterUpdateStatus `json:"cluster,omitempty"`
}
ManagerStatus is the admin-facing snapshot.
type Manifest ¶
type Manifest struct {
Version string `json:"version"`
Channel string `json:"channel"`
PublishedAt time.Time `json:"publishedAt,omitempty"`
// IssuedAt and ExpiresAt bound replay of a signed feed. Pointers preserve
// the canonical encoding of legacy manifests during signature checks.
IssuedAt *time.Time `json:"issuedAt,omitempty"`
ExpiresAt *time.Time `json:"expiresAt,omitempty"`
NotesURL string `json:"notesUrl,omitempty"`
Notes string `json:"notes,omitempty"`
// MinVersion, when set, is the oldest installed version that may
// update directly to this release (older installs must reinstall).
MinVersion string `json:"minVersion,omitempty"`
Artifacts []Artifact `json:"artifacts"`
// Signature is base64 ed25519 over SigningBytes(). Empty when the
// signature is detached (served from "<manifest URL>.sig").
Signature string `json:"signature,omitempty"`
}
Manifest is the signed release document for one channel.
func (*Manifest) ArtifactFor ¶
ArtifactFor picks the artifact for goos/goarch, preferring a bare binary, then an installer, then an archive. "universal" arch matches any arch on the same OS (macOS universal binaries). prefKinds overrides the default preference order when non-empty.
func (*Manifest) RenewFreshness ¶ added in v0.2.4
RenewFreshness updates the timestamps before signing. Never call this on unverified downloaded content: renewal is a release-publisher operation.
func (*Manifest) Sign ¶
func (m *Manifest) Sign(priv ed25519.PrivateKey) error
Sign sets m.Signature using priv.
func (*Manifest) SigningBytes ¶
SigningBytes is the canonical payload an embedded signature covers: the manifest's JSON encoding (struct field order, no indentation) with the Signature field emptied.
func (*Manifest) Validate ¶
Validate checks the manifest is well-formed (not authentic — see Verify).
func (*Manifest) ValidateFreshness ¶ added in v0.2.4
ValidateFreshness must run only after authenticating the manifest.
type Options ¶
type Options struct {
// FeedURL is the manifest URL, feed base, or a template containing
// "{channel}". See ManifestURL for resolution. Empty: DefaultFeedBase.
FeedURL string
// Channel: "stable" (default) or "beta".
Channel string
// CurrentVersion is the running version. Empty: the build Version.
CurrentVersion string
// PublicKey verifies manifests. Nil: the compiled-in key (required).
PublicKey ed25519.PublicKey
// HTTPClient for feed + artifact fetches. Nil: a 30s-timeout client
// for manifests; downloads set their own per-request deadline via ctx.
HTTPClient *http.Client
// GOOS/GOARCH override artifact selection (tests, cross-tooling).
GOOS, GOARCH string
// PreferKinds overrides artifact kind preference (e.g. installer-only
// on macOS where the .dmg is the product).
PreferKinds []string
Logger *slog.Logger
}
Options configures a Client. Zero values fall back to build defaults.
type Pending ¶
type Pending struct {
From string `json:"from"`
To string `json:"to"`
Target string `json:"target"`
AppliedAt time.Time `json:"appliedAt"`
Attempts int `json:"attempts"`
}
Pending is written immediately before a binary swap and removed once the new version has served healthily for the health period. Attempts counts starts of the new version; a crash loop pushes it past the limit and triggers a rollback.
type ProgressFunc ¶
type ProgressFunc func(downloaded, total int64)
ProgressFunc receives cumulative downloaded bytes (total may be 0 when the manifest omits Size).
type Semver ¶
type Semver struct {
Major, Minor, Patch uint64
Pre []string // prerelease identifiers, empty for a release
// contains filtered or unexported fields
}
Semver is a parsed semantic version.
func ParseVersion ¶
ParseVersion parses "v1.2.3-beta.1+build". Missing minor/patch default to 0 ("1.2" == 1.2.0). Build metadata is accepted and ignored.
type StartupOptions ¶
type StartupOptions struct {
// Dir is the updates directory holding pending.json / failed.json.
Dir string
// Target is the running executable; used when the marker has none and
// for leftover cleanup. Empty: os.Executable().
Target string
// CurrentVersion is the running build's version.
CurrentVersion string
// MaxAttempts before rollback. <= 0: DefaultMaxStartAttempts.
MaxAttempts int
Now func() time.Time
// Rollback / CleanupOld default to the package functions.
Rollback func(target string) error
CleanupOld func(target string) error
Logger *slog.Logger
}
StartupOptions configures ConfirmStartup.
type StartupResult ¶
type StartupResult struct {
// Confirming is the marker of a just-installed update that this
// process (the new version) must confirm by running healthily.
Confirming *Pending
// RolledBack is set when the previous binary was restored. When
// RestartRequired is also set the process must exit so the service
// manager starts the restored binary.
RolledBack *FailedUpdate
RestartRequired bool
// Note is a human-readable summary for logs and the admin status.
Note string
}
StartupResult is what ConfirmStartup found and did.
func ConfirmStartup ¶
func ConfirmStartup(opts StartupOptions) (StartupResult, error)
ConfirmStartup runs once at process start, before anything that a bad release could break (config parse, database migrations), and implements confirm-or-rollback for a self-applied update:
- no marker: remove leftover "<target>.old"/".bad" from a confirmed update;
- marker and Version == To: count this start; past MaxAttempts the update is considered crash-looping, the previous binary is restored, the version is recorded as failed and a restart is requested;
- marker and Version == From: the swap never took effect; clear it.