All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
A maintenance release: Go 1.27, the whole dependency graph brought current, and the codebase modernized to the Go 1.26 idioms.
- Go toolchain updated to go1.27.0; the supported minimum stays Go 1.26 (#800).
- Scheduler dependency netresearch/go-cron updated to v0.16.0, its first release with the Go 1.26 floor (#802).
- All Go dependencies updated across the module graph — docker/cli 29.7.2, emersion/go-smtp 0.25.0, moby/moby/client 0.5.1, testify 1.12.1, golang.org/x/crypto 0.55.0, golang.org/x/text 0.41.0 and indirect moves (#801).
- Codebase modernized via
go fixand manual passes:strings.Cut,slices.Backward,maps.Copy,errors.AsType[T],atomic.Int32in tests, and six stale//nolint:goconstdirectives removed; no behavior change (#800).
A security release. Jobs defined through Docker container labels could carry privilege-bearing keys the label-security policy did not cover, letting an untrusted self-labeling container escalate against the host or read another container's secrets — in the default configuration. See GHSA-h7m7-v83x-vfp3.
- Label-sourced jobs can no longer smuggle
privileged,env-fileorenv-frompast the host-escalation policy.allow-host-jobs-from-labelsstripped host bind mounts fromjob-run/job-service-run(#462) but never covered these three keys, andjob-execwas not routed through the policy at all. So a container labelling itself could run aprivilegeddocker exec(a container-escape primitive), read a file from ofelia's own filesystem view into the job environment (env-file), or copy another container's entire environment (env-from) — all with the policy in its default, off state. These keys are now stripped from every label-sourced job, matched by normalized key so casing and separator variants are caught, unlessallow-host-jobs-from-labels=true; the strip runs on both the initial-load and the live container-reconcile paths and logs aSECURITY POLICY VIOLATIONper stripped key. INI configuration is trusted and unaffected (GHSA-h7m7-v83x-vfp3, #791).
A release about ofelia telling the truth about itself. A job the scheduler
refused was reported as scheduled, /health claimed health it had never
established, validate returned success on a config it had not checked, and
shutdown ended the process before the hooks that stop the web server had run.
Each of those looked fine from the outside, which is what made them worth
fixing.
Three changes alter behaviour you may be relying on. ofelia validate now
exits non-zero when validation fails, so a pipeline step that silently passed
will start failing — that is the point, but check yours before upgrading. That
same command now also validates the jobs, so a config that passed before may
not. And /health can now answer degraded; /ready is unchanged and still
answers 200 for it.
ofelia validatenow validates the jobs, not just the[global]section. It checked global keys and stopped there, so a[job-run]without animageor a[job-exec]without acontainerpassed the gate and then failed on every tick at runtime. Each job type is now checked for the fields its runtime actually requires, and the schedule and command are required everywhere (#778).
/healthreportsdegradedwhile a configured job is not scheduled. The scheduler check was a stub that returnedhealthyunconditionally, with a comment saying a real implementation would check the scheduler. So a daemon whose job had a mistyped schedule — a job that never fires — served a green/health, which is the probe the integration docs tell operators to point a container healthcheck at. The check now names every job the scheduler refused./readystill answers 200 fordegraded: one job with a typo should not take a daemon out of rotation while its other jobs keep running. A corrected config reloaded at runtime clears the complaint without a restart (#780).- Shutdown no longer ends the process before its hooks have run. The daemon watched the channel that closes when shutdown starts, not when it finishes, so everything after the first priority group was killed mid-flight — the web server was never stopped gracefully despite having a hook registered to do it, cutting any request in progress. Measured on a release build,
SIGTERMreached the end of shutdown in 0 of 5 runs before the fix and 5 of 5 after (#781). /healthreported a version of1.0.0for every build ever shipped. It was hardcoded at the call site, so the endpoint could not be used to tell which ofelia was answering. It now reports the running build, ordevwhen built without ldflags (#781).- A job the scheduler rejects is now reported as such instead of being counted as running. Five registration errors were discarded, and the startup line reported the number of jobs in the config rather than the number actually scheduled — so an operator saw a healthy daemon and a job that silently did nothing. The rejection is now logged at error level, naming the job, and the count describes what is really scheduled (#777).
- A failing command now leaves a non-zero exit status.
ofelia validateand every other subcommand returned 0 after logging the error, soofelia validate … || exit 1in a pipeline never fired and a broken config passed the gate that exists to stop it (#771). ofelia --config=x daemonis accepted, not onlyofelia daemon --config=x.--configand--log-levelwere pre-parsed out of argv, which made them look global while the parser rejected them in the position a user would naturally write them (#776).- Web credentials are only required when web authentication is enabled. Validation demanded them unconditionally, so a config with the web UI open and unauthenticated — the default — failed a check it should have passed (#775).
- A container without a
Configno longer crashes the daemon. Two places dereferenced that pointer unguarded while converting Docker's response, which panics for any container the daemon reports without one (#768). - An expanded job output no longer collapses on its own. The web UI refreshes every five seconds, and with a job's history panel open that refresh rebuilt the whole table from scratch. Every
<details>element was re-created without itsopenattribute, so any output a user had expanded snapped shut within five seconds of opening it — long enough to start reading, not long enough to finish. The history table now records which outputs are expanded before it re-renders and restores them afterwards, keyed by the execution's timestamp rather than its row position, so an expanded output also stays open when a new run appears above it. Only the user collapses an output now (#764).
- The documented health endpoints did not exist.
docs/API.mdanddocs/PROJECT_INDEX.mddescribedGET /health/livenessandGET /health/readiness; the daemon serves/health,/healthz,/readyand/live. Both response shapes were wrong too. The OpenAPI description of/healthdid not match the served body either — it nameduptimeinstead ofuptimeSeconds, describedchecksas booleans where each is an object, omittedtimestampandsystem, and documented a 503 that/healthnever returns. All four endpoints are now specified, with sharedHealthResponse,HealthCheckandSystemInfoschemas. durationMsin the health report carries nanoseconds. It serialises a Gotime.Duration, which marshals as nanoseconds, so a local Docker ping reads8332586rather than8. The unit is now stated wherever the field is documented. The field name remains as-is; renaming it would break every existing consumer.
- The e2e shutdown test asserted on the substring
"graceful shutdown", which also matches"Starting graceful shutdown"— the line logged before the first hook. It stayed green while shutdown was broken, and now requires the completion line (#781). - Test results are reported to Codecov Test Analytics (#769).
- The zizmor policy that exempts first-party reusable workflows from the pin rule now comes from the organisation-level reusable rather than a copy in this repository. It was added here in #766 and removed again in #783 once the reusable supplied it — a local file takes precedence over the fetched one, so leaving the copy behind would have pinned this repository to an ageing policy.
A documentation and tooling release. The one change that reaches a running deployment is the mail template; the rest corrects what the documentation promised and makes the checks that should have caught it able to fail.
- Secret scanning matched nothing.
.gitleaks.tomldeclared an allowlist and no rules, and a gitleaks config file replaces the built-in ruleset unless it extends it — so every scan reported "no leaks found" regardless of input. The config now extends the defaults. Verified by planting a token: it is reported with the fix and not without it. Turning the scan on surfaced four documentation examples, now unmistakable placeholders. One of them, an ntfy token indocs/webhooks.mdandmiddlewares/presets/ntfy-token.yaml, has been in public history since 2025-12; it is not a live credential.
- Notification mails no longer contain invisible characters. The HTML mail body carried five U+200B zero-width spaces around the job name, duration and command. They shipped in every notification and are a known spam-filter signal.
- The documentation described 37 configuration keys that do not exist. Ofelia ignores an unrecognized key without warning, so an operator who pasted these snippets got none of the promised behavior. Git history shows none of them was ever implemented. The dangerous ones were in
SECURITY.mdunder container hardening —memory,memory-swap,cpu-shares,cpu-quota,capabilities-add,capabilities-drop,dns,tmpfs— presented as the way to constrain a job, while every line was discarded. If you set any of them, your jobs were never constrained. That section now states ofelia has no such keys and shows where the limits belong: on the Compose service for exec jobs, on the daemon for run jobs, per ADR-002. Also corrected:max-runtimeis not available onjob-execand a[global] max-runtimedoes not reach it;timeout,delayandmax-concurrent-jobsexist nowhere;useris not ajob-localkey;job-composetakes onlyfile,serviceandexec. - The release-verification instructions could not work. Every command in
SECURITY.mdwas wrong: the wrong signature file extensions, a signer workflow that does not exist, and a verifier pointed at assets no release ships. They are corrected and were executed against the published v0.28.0. The container image tag also drops thevthat the release tag keeps —v0.28.0publishesghcr.io/netresearch/ofelia:0.28.0— which the previous<TAG>placeholder hid behind a manifest-not-found error. - Nine packages, including the module root, rendered "There is no documentation for this package" on pkg.go.dev. Every package and every exported symbol is now documented.
- Error messages now start lowercase, matching Go convention and the rest of the codebase. Anything matching on the leading capital of a message such as
Docker image cannot be emptyneeds adjusting.
- The linters could not fail on much. Blanket staticcheck exclusions hid the mail template's invisible characters; golangci-lint capped output at 50 issues per linter, so a regression could hide behind the cap; and findings on a line another linter had already flagged were dropped. All three are off, and the checks run against the integration and e2e build tags as well.
- New gates, each verified by breaking it: documented INI snippets are parsed with the real parser, so a renamed key cannot leave the docs behind; every HTTP route is held to a declared authentication expectation, closing the gap where a route registered outside
/api/shipped reachable without a token; and each published release is re-verified with the commandsSECURITY.mdhands to users.
- The Docker SDK moved from the frozen
github.com/docker/docker v28.5.2+incompatibleto the maintained split modulesgithub.com/moby/moby/clientandgithub.com/moby/moby/api.govulnchecknow reports zero findings for this codebase, down from four: GO-2026-5668 and GO-2026-5617 (docker cprace conditions), GO-2026-4887 (AuthZ plugin bypass) and GO-2026-4883 (plugin-privilege off-by-one). None of the four had a fix on the v1 import path — upstream ended releases there, so leaving it was the only remedy. All four were reachable only throughinit()chains and were previously assessed as not exploitable in Ofelia's deployment shape, which is why this was deferred untilgithub.com/docker/clicompleted its own move tomoby/moby/client(v29.6.2 imports it in 361 files against 7 still on the old path, and thecli/configsubtree Ofelia depends on is clean of it). Closes #667. The new SDK reshaped every client call, so the migration was audited for behavior drift rather than assumed equivalent: two places where it would have altered runtime behavior —job-execjobs combiningconsole-height/console-widthwithtty = false, and startup pinging a daemon whose API version is already pinned — were caught and corrected before release, so both behave exactly as they did in 0.27.1.
- Docker Engine 19.03 or newer (API v1.40+) is now required. The new client enforces a minimum API version and refuses to negotiate below it, where the previous client had no floor and simply clamped down to whatever the daemon reported. Ofelia has no documented minimum until now; in practice several features already required v1.42 (Engine 20.10, released 2020-12), so only daemons older than 2019 are affected.
- An invalid API version now fails at startup instead of later.
DOCKER_API_VERSIONand[docker] versionare validated when the client is built, so a typo reportsinvalid API version (…)immediately; the previous client accepted any string and let the failure surface at request time. A leadingv(v1.44) is now also accepted, where it used to be passed through unusable. Network.Containersis no longer populated when listing networks — the Docker list endpoint does not return per-network endpoints, and the new API types reflect that. Inspecting a network still returns them. No Ofelia feature reads the field from list results.
type=runjobs created through the web API or restored from the state file now remove their container after each execution, matching the behaviorconfig.iniusers already got.RunJob.Deleteonly received its"true"default via the config decoder'sdefaultstruct tag, butnewRunJobFromRequest(web/server.go) andbuildPersistedRunJob(cli/daemon.go) construct the job directly and bypass that decoder, leavingDeleteat its zero value""— whichdeleteContainerreads asfalsethroughstrconv.ParseBool. Every API-created run job therefore left its container behind, and the next scheduled execution failed withjob run: creating container: create container "<name>": resource conflict. SincejobRequestexposes nodeletefield, there was no way to work around it at the call site. Both construction paths now set the default explicitly, each covered by a regression test. (#745)
- Go toolchain bumped 1.26.4 → 1.26.5 (
go.modplus themake lint/make lint-fixGOTOOLCHAINpins). This clears GO-2026-5856, an Encrypted Client Hello privacy leak incrypto/tlsthatgovulncheckreported as reachable from the Docker client's TLS dialer. Direct and indirect modules were refreshed viago get -u all:docker/cli29.5.3→29.6.2,docker/go-connections0.7.0→0.8.1,golang.org/x/crypto0.53.0→0.54.0,golang.org/x/text0.38.0→0.40.0,golang.org/x/term0.44.0→0.45.0,golang.org/x/sys0.46.0→0.47.0, plusdocker-credential-helpers0.9.8,felixge/httpsnoop1.1.0,gabriel-vasile/mimetype1.4.15,go-logr/logr1.4.4 andleodido/go-urn1.5.0. Every module linked into the binary is on its latest release; the modulesgo list -m -u allstill reports as outdated are test-dependencies-of-dependencies that MVS resolves but the binary never links. The remaininggovulncheckfindings are the unfixable upstream moby advisories ondocker/dockerv28.5.2 — GO-2026-5668 and GO-2026-5617 (docker cprace conditions), GO-2026-4887 (AuthZ plugin bypass) and GO-2026-4883 (plugin-privilege off-by-one) — all reachable only viainit()chains, with no upstream patch on the v28 line. (#744, #746)
- New
[global] job-exec-label-scopeoption makes label-definedjob-execjob naming collision-safe when one central Ofelia daemon watches several independent Compose projects. Since #300/#597 switched the prefix from the container name to the Compose service name (to power cross-containerdepends-onreferences likedatabase.backup), two stacks deployed from the same template — e.g.acme-webandglobex-web, both Compose servicewebwith an identicalofelia.job-exec.sync-newslabel — silently collapsed to the single keyweb.sync-news: only the first container (running → newest → name order) won and the other stack's job never ran, with no error. This unnoticed regression reopened the exact collision that #86/#114 had closed. The new option selects the prefix scheme:service(default —{service}.{job}, unchanged, required for cross-container references),container({container}.{job}— collision-safe per Docker daemon), orcontainer-service({container}.{service}.{job}— descriptive and collision-safe, falling back to{container}.{job}for non-Compose containers). It is a daemon-wide INI-only setting (not exposed via container labels, so a single container cannot redefine how every other container's jobs are named) and defaults toservice, preserving the pre-fix behavior. When switching away fromservice, update cross-containerdepends-on/on-success/on-failurereferences to the new scoped names. Independently of the chosen scope, Ofelia now also logs a loud warning whenever a job-exec name collision is detected — naming both containers, the dropped job, and thejob-exec-label-scoperemedy — so a collapsed job is never dropped in silence again (the worst part of this bug was the absence of any signal). An unrecognizedjob-exec-label-scopevalue is likewise reported with a warning before falling back toservice, rather than silently degrading. Closes #734.
-
New bundled webhook presets
healthchecksandhealthchecks-selfhostedfor Healthchecks.io and self-hosted instances. The hosted preset pingshttps://hc-ping.com/{id}(whereidis a UUID orpingkey/slug); the self-hosted preset takes the full ping URL viaurl = .... Both POST a plain-text job/execution summary as the ping body. Because Healthchecks derives up/down state from the URL suffix rather than the body, the docs show the two-webhook pattern (trigger = success→ base URL,trigger = error→<id>/fail) for explicit failure signaling. (#692) -
New
--state-file/OFELIA_STATE_FILEdaemon flag persists API-mutated state (jobs created/updated viaPOST /api/jobs/createor/updateand disable flags from/disable) to a JSON file so they survive daemon restarts. Pre-fix, every API-created job lived only in scheduler memory and was lost ondocker compose restartor pod recycle (#593); operators had no way to make UI/API changes durable short of editing the INI by hand. The new flag is opt-in (empty path = disabled, preserving the pre-fix behavior). On startup the file loads after INI/labels and shadows same-named INI/label jobs (API state is authoritative for its own entries). Disable flags apply regardless of origin so an INI-defined job paused from the UI stays paused after restart. Writes are atomic via tmp+rename, file mode is explicitly0o600, the on-disk schema carries aVersionfield so future-incompatible changes can be migrated explicitly, and Load enforces a 16 MiB size cap +DisallowUnknownFieldsso a malicious or typo'd file fails closed rather than silently dropping config. Closes #593. -
job-execjobs can now set the initial pseudo-TTY console size via two new fields:console-height(rows) andconsole-width(columns). Useful for jobs that render TUIs, tables, or formatted text — applications that expect a specific terminal geometry (htop,vim, formatted-output reports) now render correctly. Both default to 0, meaning "use Docker's default size" (preserving pre-fix behavior). Only honored whentty = true; otherwise the Docker daemon silently ignores the size. Requires Docker API v1.42+ (Docker Engine 20.10+, released 2020-12) — Ofelia's auto-negotiation handles older daemons gracefully. Closes #235. -
job-runjobs can now customize the signal Ofelia sends to the container's main process at stop time via a newstop-signalfield, paired with a newstop-timeoutfield that controls the grace period before the Docker daemon escalates toSIGKILL.stop-signalaccepts the canonical name (SIGINT,SIGUSR1) or the bare suffix (INT,USR1); empty (the default) preserves the pre-fix behavior of falling back to whatever the container image declared viaSTOPSIGNAL(which itself defaults toSIGTERM).stop-timeoutaccepts a Go duration (e.g.30s,2m); zero (the default) preserves the pre-fix hardcoded 10s grace period and is read only by Ofelia's deadline-cleanup path (cleanupOnDeadline) — other shutdown paths inherit the daemon's default. Useful for apps with signal handlers — Node.js workers that handleSIGINTcleanly, Java apps that dump threads onSIGQUIT, or custom cleanup paths onSIGUSR1/SIGUSR2, all of which often need more than 10s beforeSIGKILL. Requires Docker API v1.42+ (Docker Engine 20.10+, released 2020-12). Closes #234. -
New
[docker] startup-retry-countand[docker] startup-retry-intervalconfig (also exposed as--docker-startup-retry-count/OFELIA_DOCKER_STARTUP_RETRY_COUNTand--docker-startup-retry-interval/OFELIA_DOCKER_STARTUP_RETRY_INTERVAL) retry the initial Docker connection with exponential backoff before the daemon exits. Default isstartup-retry-count=0, which preserves the pre-fix "exit on first failure" behavior; settingstartup-retry-count=5 startup-retry-interval=1syields a 1s → 2s → 4s → 8s → 16s budget (~31s total). Each per-attempt ping is still bounded bydockerStartupPingTimeout(10s) so a wedged daemon cannot inflate startup beyond(count+1)·10s + Σbackoffs. The backoff window observes ctx cancellation so SIGTERM during startup drains promptly instead of blocking the full budget (same shape as #685 / #687). Useful for TCP-based Docker hosts where the daemon may briefly be unreachable on startup before health checks settle (socket proxies, remote Docker hosts, Docker-in-Docker). Closes #523. -
The
savemiddleware output permissions are now configurable via two optional octal keys:save-modefor the per-execution log/JSON files (default0600) andsave-folder-modefor the save folder (default0750). Both accept0644,0o644or644, are capped at0777(setuid/setgid/sticky bits rejected), and inherit global→job exactly likesave-folder. The hardened defaults are unchanged — set a wider mode (e.g.save-mode = 0644) to let a non-root operator or shared group read the captured logs on the host. The0600/0750defaults date to the gosec G301/G306 hardening in5211e54b, which made logs readable only by the daemon uid and was a behavioral regression vs.mcuadros/ofelia(which wrote0644). Likesave-folder, neither key is in the global Docker-label allow-list, so a scheduled container cannot change the daemon-wide default;save-folder-modeapplies only when Ofelia creates the folder (mkdir -psemantics — an existing bind-mounted folder keeps its current permissions). Closes #729. -
The bundled
pushoverwebhook preset gains an optionaldevicefield to target one or more named Pushover devices instead of all of a user's devices. Settable via INI (device = ...) or Docker label; leaving it unset preserves the prior behavior (delivery to all of the user's devices). Follows the precedent of the generic optionallink/link-textfields rather than a preset-specific hack. (#715)
-
BREAKING (API behavior):
POST /api/jobs/deletenow returns403 Forbiddenfor jobs that came from INI config or Docker labels, with a message naming the source so operators know which file to edit. Pre-fix this handler silently removed the job from memory until the next reload — a sharp edge that masked surprises (#593). Scripts that relied on the pre-fix behavior must switch toPOST /api/jobs/disable(which now persists across restart) or edit the source config. Jobs created via the web UI / API remain deletable as before. -
WebhookManagernow caches*http.Clientper webhookTimeoutso the underlying transport's keep-alive connection pool survivescli.Config.rebuildAllMiddlewaresreconciles (which fire after every Docker label change or INI reload). Pre-fix,NewWebhookbuilt a fresh*http.Clientand*http.Transportper call insideGetMiddlewares, so every reconcile dropped any held keep-alive connections and started fresh TCP/TLS handshakes for every job's webhooks. The cache is keyed byTimeoutbecause that's the only per-webhook input that varies (the sharedTransportFactory()handles TLS/proxy posture and the SSRF allow-list lives in the package-global security config). Webhooks with the sameTimeoutshare a client; different timeouts get distinct clients. StandaloneNewWebhook(config, loader)callers (tests, direct construction) keep building a fresh client per call — the legacy behavior is preserved for non-manager paths. Closes #674.
- Extended the
[global] allow-host-jobs-from-labels=falsepolicy to cover bothjob-runANDjob-service-runentries that mount host filesystem paths via Docker labels (e.g.ofelia.job-run.X.volume=/:/host:rw) or inherit a donor container's bind mounts viavolumes-from(e.g.ofelia.job-run.X.volumes-from=ofeliato pull in the daemon's/var/run/docker.sockmount). Pre-fix, onlyjob-localandjob-composewere filtered, leaving every container-spawning job type (job-runand Swarmjob-service-run) with open container-to-host privilege-escalation vectors viavolume=andvolumes-from=. An attacker controlling labels on any container Ofelia watched could mount/into the spawned container directly, or chain viavolumes-from=ofeliato inherit the Docker socket and gain full daemon access. The new per-job filter:- Detects host mounts in
volume(specs whose source starts with/,., or~, after whitespace normalization) and drops the entire offendingjob-run. - Treats any non-empty
volumes-fromas a violation, because the donor's mounts cannot be inspected at filter time — conservative drop is the only safe call. - Fails closed on unexpected param shapes (e.g. a future refactor delivering
[]anyinstead of[]stringcannot silently bypass the policy). - Logs a
SECURITY POLICY VIOLATIONper dropped job, naming the job, the vector class (volume=vsvolumes-from=), and the specific specs so operators can triage. Named volumes (my-vol:/data) and anonymous volumes (/datatarget-only) with novolumes-fromare unaffected. Closes #462.
- Detects host mounts in
-
PresetLoader.AddLocalPresetDirnow scans the registered directory for*.yamlfiles whose stem collides with a bundled preset name (slack,discord,teams,matrix,ntfy,ntfy-token,pushover,pagerduty,gotify,json-post) and emits a startupslog.Warnper collision. Pre-fix,PresetLoader.Loadresolved bundled presets first and never fell through, so a file at$LOCAL_DIR/json-post.yamlplaced hoping to override the bundledjson-postwas silently ignored at attach time. The warning matchesLoad's.yaml-only resolution path so a.ymlrename suggestion never misleads operators. The lookup order is documented indocs/webhooks.mdunder "Preset Lookup Order"; inverting the order to prefer local files is deliberately rejected (a local typo shadowingslack.yamlwould silently break Slack delivery host-wide). Closes #679. -
core.middlewareContainer.Use()now dedups per-instance via an optionalKey() stringinterface instead of byreflect.TypeOf(m).String(). Pre-fix, two*middlewares.Webhookinstances handed to the same job collapsed into the first and silently dropped the rest — the failure mode tracked in #670 and worked around at the webhook layer by PR #671 with theWebhookMiddlewarecomposite. The composite stays in place (no behavior change there), but the structural bug is now closed at the core layer so any future N-instance middleware (e.g. multiple Slack channels, multiple Mail recipient sets) gets correct semantics by construction. Existing 1-per-type middlewares (Slack,Mail,Save,Overlap,WebhookMiddleware) do not implementKey(), so they fall back to the legacy type-string dedup —j.Use(s.Middlewares()...)scheduler-to-job propagation still de-duplicates them as before.*middlewares.WebhookreturnsConfig.NamefromKey(), so the same-name propagation case (scheduler-level webhook re-propagated to a job that already has it) still de-duplicates correctly. Opt-in via type assertion means downstream consumers with custom middleware implementations need no changes. Closes #672. -
[global] default-user = defaultnow resolves to the container's default user instead of being forwarded verbatim todocker exec --user default(which failed withunable to find user default: no matching entries in passwd file). TheUserContainerDefault("default") sentinel was documented to select the container's default user butapplyDefaultUseronly honored it for a per-jobuser, not when inherited from the globaldefault-user— a label-defined job with no explicitusertook the global value literally. Closes #716. -
Unknown-key warnings for the
[global]and[docker]sections now suggest the nearest valid key (e.g.did you mean 'webhook-default-preset'?), bringing them into parity with the per-job sections. Pre-fix, only job sections offered suggestions;[global]/[docker]typos just reported(typo?). Closes #678. -
RetryExecutor.ExecuteWithRetrynow honors context cancellation during the inter-retry backoff (aselectontime.After(delay)vs.runCtx.Done()) instead of a baretime.Sleep. Pre-fix, SIGTERM mid-retry blocked daemon shutdown for up toRetryDelay × MaxRetries(compounded by exponential backoff); cancellation now drains promptly and returns an error wrappingcontext.Canceled. Sibling to the webhook-backoff fix in #685 (different code path: job-level retry executor vs. webhook middleware). Closes #687.
- BREAKING (source-only, pre-1.0): Removed unused
core/adapters/docker.ClientConfig.HTTPClientfield that was declared in #681 but never read — a caller settingcfg.HTTPClient = someClientsilently got the auto-constructed transport instead of theirs. Downstream Go consumers that referenced the field in named struct literals or assignments will see a compile-time error after upgrade (semantically a no-op since the field was already ignored at runtime); permitted under SemVer for the current 0.y.z line (cf. SemVer §4). Removing the field turns the silent footgun into a loud compile-time error rather than preserving it as a deprecated no-op. If you need a transport-level injection seam, file a feature request with the use case so the suppression ofdisableHTTP2AutoConfigon caller-supplied transports (the #668 invariant) can be wired in correctly. (#693, closes #684)
- Go toolchain bumped 1.26.3 → 1.26.4 (
go.modand themake lint/make lint-fixGOTOOLCHAINpins). Routine patch release;govulncheckfindings for this codebase are unchanged from 1.26.3. Direct deps refreshed in lockstep:docker/cli29.5.2→29.5.3,go-playground/validator/v1010.30.2→10.30.3, andnetresearch/go-cron0.14.0→0.15.0. The OpenTelemetry stack was aligned to a single release train (otel/otel/metric/otel/trace/otel/exporters/otlp/otlptrace/otlptracehttp1.43→1.44,contrib/instrumentation/net/http/otelhttp0.68→0.69), plus a full indirect-graph refresh viago get -u all. Aligning the otel exporters letgo mod tidyprune four now-unneeded indirect requires (google.golang.org/grpc,google.golang.org/genproto/googleapis/api,…/rpc,grpc-ecosystem/grpc-gateway/v2) that only existed to satisfy the older otel pins. Post-bump, the only twogovulncheckfindings remain the unfixable upstream moby advisories GO-2026-4887 (AuthZ plugin bypass) and GO-2026-4883 (plugin-privilege off-by-one) ondocker/dockerv28.5.2, both reachable only viainit()chains and with no upstream patch yet. (#720)
-
Upgrade impact: webhooks configured with
url = ...but nopresetwere previously rejected at startup (the documented "Custom Webhooks" section promised they would work but the code returnedpreset specification cannot be empty). They now attach and fire using the new bundledjson-postJSON-POST preset. Audit existing[webhook "..."]sections andwebhooks:job references before upgrading — a stale URL-only config that previously sat inert will now actually send a JSON POST to whatever's inurl. Pinwebhook-allowed-hoststo a specific list if you want to restrict egress; setwebhook-default-preset =(empty) to preserve pre-upgrade behavior and require every webhook to declarepresetexplicitly.New bundled
json-postwebhook preset and a[global] webhook-default-presetselector that ships withjson-postas the default fallback. A webhook configured with just aurl = ...(or Docker labelofelia.webhook.<name>.url: ...) now works out of the box — Ofelia POSTs a JSON payload describing the job and execution to that URL, no custom preset YAML required. The fallback preset is selected viaEffectiveDefaultPreset()at attach time, so live INI / label changes towebhook-default-presettake effect on the next attach without restart. Three-state semantics are unambiguous: the field is*string, so nil = "operator did not set" (use bundled fallback), non-nil empty = "explicit opt-out", non-nil non-empty = "operator's chosen fallback name". When opt-out is in effect, the attachment-failed error message nameswebhook-default-presetexplicitly so operators can grep their way from logs to the docs. Fixes #676.
-
Webhook retry backoff and in-flight HTTP requests now honor ctx cancellation, so
SIGTERMon a daemon mid-retry drains promptly instead of pinning a goroutine for up toretry-delay × retry-count(plus onetimeoutfor the in-flight request). On defaults this caps shutdown contribution at5s × 3 + 30s ≈ 45s; operators withretry-delay = 60s/retry-count = 5previously saw multi-minute shutdown stalls.(*Webhook).sendWithRetryreplaces the baretime.Sleepwith aselectover(time.After, ctx.Done);(*Webhook).sendderivesreqCtxfromctx.RunContext()rather thancontext.Background()so an in-flight HTTP write is also cut on cancellation. On cancel, callers see an error chain wrappingcontext.Canceled/context.DeadlineExceeded(was previously"all N attempts failed, last error: ..."after the full timeout). (#685, fixes #673) -
DOCKER_HOST=http://...now works for hijack-using APIs (ContainerExecAttach/run_exec,ContainerAttach,ContainerLogs --follow). The Docker SDK's hijack-path dialer falls through tonet.Dial(cli.proto, cli.addr); forproto == "http"that wasnet.Dial("http", addr), which Go'snetpackage rejects withunknown network http(only"tcp","unix", etc. are valid network names). Container discovery was unaffected because the regular HTTP path useshttp.Client.Do, not the SDK's hijack dialer. Fix installs an explicit TCPDialContexton thehttp://transport (newapplyHTTPTransport) so the SDK picks our dialer viadialerFromTransportand never reaches the broken fallback. Sibling fix to #681 (same SDK dialer, different failure mode). (#682) -
DOCKER_HOST=tcp://...(plain HTTP, no TLS) — typically routed through a socket proxy such astecnativa/docker-socket-proxy— now works for hijack-using APIs:ContainerExecAttach(therun_execjob type),ContainerAttach, andContainerLogs --follow. In v0.25.0 the first plain-HTTP request through the transport triggered Go's lazy HTTP/2 auto-config, which allocated*http.Transport.TLSClientConfigin place (to seedNextProtos=[h2 http/1.1]for ALPN). The Docker SDK's hijack dialer readsbaseTransport.TLSClientConfigas its "TLS is required" signal and then dialed TLS against a plaintext daemon, surfacing ascannot connect to the Docker daemon. Is 'docker daemon' running on this host?: tls: first record does not look like a TLS handshake. Container discovery (the non-hijack HTTP path) was unaffected. Fix sets*http.Transport.TLSNextPrototo a non-nil empty map on the non-TLS apply paths (applyUnixTransport,applyTCPTransport,applyPlainTransport) — the documented stdlib opt-out for HTTP/2 auto-config. TLS Docker hosts (https://,tcp+tls://) leaveTLSNextProtonil so ALPN h2 negotiation remains intact. (#681, fixes #668) -
Jobs that reference more than one webhook now fire every listed webhook, including any globally-configured ones.
core.middlewareContainer.Use()deduplicates by reflect type, so handing it two*middlewares.Webhookinstances kept only the first one and silently dropped the rest — leaving the second webhook (typically the error-trigger one) attached to nothing and never invoked. The same dedup also shadowed the scheduler-level*middlewares.WebhookMiddlewareagainst per-job webhooks during scheduler→job middleware propagation, so any job that declaredwebhooks:silently lost the global notifications too. The fix attaches a single per-job composite (middlewares.NewWebhookMiddleware) that carries the union of[global] webhook-webhooksand the job's ownwebhooksselector, deduplicated by name. The previously-silentwm.GetMiddlewareserror path (unknown webhook name, preset-load failure, missing required variable) now emits aslog.Errorkeyed by job name and webhook list, so misconfigured webhooks are visible in the log. To verify after upgrade: register two webhooks with differenttriggervalues on a job that fails (e.g.wh-successwithtrigger: success,wh-errorwithtrigger: error); only the error webhook should receive a payload on failure, but both must be wired and the success one must fire on success. (#670)
tcp+tls://is back on theDOCKER_HOSTallow-list now that the TLS plumbing from #613 wiresDOCKER_CERT_PATH/DOCKER_TLS_VERIFY(and the equivalentClientConfig.TLSCertPath/TLSVerifyoverrides) into the custom HTTP transport. PR #612 had withheld it to avoid a silent plain-TCP downgrade; that risk is now closed by the existingTestCreateHTTPClient_TCPPlusTLSEnablesTLSregression test plus the newTestNewClientWithConfig_TCPPlusTLSSchemeallow-list assertion. (#625, fixes #616)
- BREAKING:
DOCKER_HOSTscheme is now validated against an allow-list (unix://,tcp://,tcp+tls://,http://,https://,npipe://) and normalized to lowercase. Unsupported schemes (ssh://,fd://, bogus values) now fail at startup with a clear error instead of silently falling through to a plain-TCP transport. Fixes case-sensitivity bug forTCP:///UNIX://. Configurations that previously relied on the silent fallthrough will now fail loudly — operators usingssh://should switch to an SSH-forwarded socket and pointDOCKER_HOSTat the forwardedunix://path. (#612, fixes #609) - Webhook global config now lives at a single source of truth:
c.WebhookConfigs.Globalaliases&c.Global.WebhookGlobalConfig, eliminating the dual-store antipattern that PR #618 papered over with a hand-rolledsyncGlobalWebhookConfigcopy. Every entry point that mutates the embedded struct from the INI side (initial INI parse, INI live-reload) is automatically visible toWebhookManagerwithout an explicit sync call. INI live-reload ofwebhook-allowed-hostsnow also re-runsWebhookManager.InitManager()so the URL validator picks up the new whitelist at runtime — previously the data store was refreshed but the security validator stayed snapshotted at startup, so tightening the whitelist via live-reload had no enforcement effect until restart. The Docker label sync path still parses into a scratch Config and merges back viamergeWebhookConfigs/syncWebhookConfigs, which only forward thewebhook-webhooksselector and per-webhook definitions — see follow-up. (#620) PresetLoadernow caches a single*http.Client(constructed inNewPresetLoaderfromTransportFactory()) instead of building a fresh client and*http.Transporton everyloadFromURLcall. Bursty preset fetches now share the underlying connection pool / idle-conn reuse. Test ordering constraint: tests overriding the transport factory viaSetTransportFactoryForTestMUST install the override BEFORE callingNewPresetLoader— replacing the factory afterwards has no effect on the cached client. The deprecated Slack middleware (middlewares/slack.go) also routes its fallback*http.ClientthroughTransportFactory()so notifications inherit the webhook stack's TLS / proxy posture instead ofhttp.DefaultTransport; defense-in-depth for the deprecation window. (#630)
-
Docker label key
ofelia.webhooksis renamed toofelia.webhook-webhooksto match the documented INI[global]key name. A user copying their INIwebhook-webhooksvalue verbatim into Docker labels previously hit an "Unknown global label keys" warning and silently lost the value. The legacyofelia.webhooksform still works for backward compatibility but logs a one-shot deprecation warning per process — migrate toofelia.webhook-webhooksbefore the next major release. The other unprefixed legacy forms (ofelia.allow-remote-presets,ofelia.trusted-preset-sources,ofelia.preset-cache-ttl,ofelia.preset-cache-dir) were never accepted from labels because their canonical forms remain INI-only for SSRF reasons (see #486). (#620)Before:
labels: ofelia.webhooks: "slack-alerts" # legacy — emits one-shot deprecation warning
After:
labels: ofelia.webhook-webhooks: "slack-alerts" # canonical — matches INI [global]
- Go toolchain bumped from 1.26.2 to 1.26.3, clearing six standard-library advisories that
govulncheckreaches from this codebase: GO-2026-4986 and GO-2026-4977 (net/mail), GO-2026-4982 and GO-2026-4980 (html/template), GO-2026-4971 (net), GO-2026-4918 (net/http). Direct deps refreshed in lockstep:docker/cli29.4.2→29.4.3,golang.org/x/crypto0.50→0.51,golang.org/x/term0.42→0.43,golang.org/x/text0.36→0.37, plus a full indirect-graph refresh viago get -u all. Post-bump, only twogovulncheckfindings remain — the unfixable upstream moby advisories GO-2026-4887 (AuthZ plugin bypass) and GO-2026-4883 (plugin-privilege off-by-one) ondocker/dockerv28.5.2, both reachable only viainit()chains and with no upstream patch yet. Includes a small test-only fix toTestSDKDockerProviderWaitContainerContextCanceledto de-race the mock so go1.26.3's scheduler timing doesn't surface the preexistingselectrace between<-ctx.Done()and<-respCh(closed). (#662) - Docker SDK adapter now fails closed when
DOCKER_HOST=https://...is set with TLS material configured (DOCKER_CERT_PATHenv orClientConfig.TLSCertPath) but the cert material at the configured path is unreadable or invalid (typo, missing files, broken volume mount, secrets not yet populated). PreviouslyapplyDockerTLSemitted aslog.Warnand leftTLSClientConfignil; the SDK then dialed with Go's default TLS — system CA pool, no client cert — silently downgrading the operator's declared mTLS into an unauthenticated TLS handshake. The new typed sentinelErrHTTPSRequiresUsableCertMaterialmakes the misconfiguration loud at startup. Asymmetry vstcp+tls://:https://without any TLS material configured remains fail-open (operator legitimately uses the system CA bundle); only the misconfigured-material case fails. The warn-and-continue inapplyDockerTLSremains as defense-in-depth for directcreateHTTPClientcallers (tests). (#653, follow-up to #646) - SMTP middleware (
middlewares/mail.go) now defaults toMandatoryStartTLSinstead of inheriting go-mail'sOpportunisticStartTLS. The previous default silently sent SMTP credentials and message body in cleartext when the server did not advertise STARTTLS — even whensmtp-tls-skip-verify = false— violating the operator's intent. Newsmtp-tls-policyINI key acceptsmandatory(default),opportunistic, ornone; unknown values are normalized tomandatoryand aWARN-level log line is emitted (defensive enum handling: a typo cannot weaken transport security). BREAKING: operators whose SMTP servers do not advertise STARTTLS (legacy local relays, MailHog dev fixtures) will see send failures after upgrade — setsmtp-tls-policy = opportunistic(trusted-loopback paths) orsmtp-tls-policy = none(test fixtures only) to restore the previous behavior. Seedocs/TROUBLESHOOTING.mdfor migration recipes. (#653) - Webhook URL allow-list now emits a single startup-time
WARNwhen the resolvedwebhook-allowed-hostsadmits all hosts (empty / unset / contains*). Previously a typo in the INI key collapsed silently into the["*"]default, yielding wide-open egress with no operator-visible signal that the allow-list they thought they had configured was actually empty. The warning fires once fromSetGlobalSecurityConfig(the documented startup seam called fromNewWebhookManager) — not per request — and includes a hint at the corrective INI key plus a link to the issue. No behavior change for operators who intentionally run wide-open egress; the warning is recoverable noise that documents the security posture in the log. (#653) - Remote preset fetches (
webhook-allow-remote-presets = true) now route through the sameTransportFactory()used by the webhook stack instead ofhttp.DefaultClient. The previous code relied implicitly on Go stdlib defaults for TLS verification — safe today, but untested and easy to regress if a future change mutateshttp.DefaultTransport. The TLS posture is now explicit, centrally configurable alongside webhook delivery, and pinned by regression tests (self-signed cert rejection +InsecureSkipVerifyposture check). No behavior change for operators on default config. (#615) - Docker SDK adapter now honors
DOCKER_TLS_VERIFYandDOCKER_CERT_PATHfor HTTPS /tcp+tlshosts. The custom HTTP client previously replaced the SDK'sFromEnv-configured TLS transport wholesale, silently discarding the client cert and pinned CA. Connections to mTLS-protected Docker daemons proceeded without a client cert and against the system CA pool — operators believing they had mTLS were getting unauthenticated connections. NewClientConfig.TLSCertPath/TLSVerifyfields allow explicit override with config > env precedence. Upgrade impact: if yourhttps://Docker daemon previously accepted Ofelia connections without verifying client certs, upgrading will cause the dial to fail until validca.pem/cert.pem/key.pemexist atDOCKER_CERT_PATH. (#613, fixes #607) - Docker SDK adapter now fails closed when
DOCKER_HOST=tcp+tls://...is set without TLS material (DOCKER_CERT_PATH/DOCKER_TLS_VERIFYenv vars orClientConfig.TLSCertPath/TLSVerifyoverrides). PreviouslyresolveTLSConfigreturned(nil, nil)and the SDK dialed TLS using Go's stdlib defaults — system CA bundle, no client cert — silently downgrading the operator's declared mTLS into an unauthenticated TLS handshake against any daemon that did not strictly require client auth.tcp+tls://is an explicit TLS opt-in (unlike the ambiguoustcp://), so the new typed sentinelErrTCPTLSRequiresCertMaterialmakes the misconfiguration loud at startup rather than silent at runtime.tcp://andhttps://remain fail-open. Upgrade impact: if you setDOCKER_HOST=tcp+tls://...without configuring TLS material, Ofelia will now refuse to start — setDOCKER_CERT_PATH(and optionallyDOCKER_TLS_VERIFY) to a directory containing readableca.pem/cert.pem/key.pem, or switch tohttps://if you genuinely want fail-open-with-warning. (#627, surfaced during review of #625)
MaxRuntimecancellation now stops and removes the container or swarm service that was running, instead of leaving an orphaned process behind. #651 wired a wrapper-level deadline so the inner SDK calls returned when the deadline fired, but the deferreddeleteContainerreused the already-cancelled parent context — so the stop/remove API calls were rejected before they reached the daemon. The cleanup path now uses a freshcontext.WithTimeout(context.Background(), jobCleanupTimeout)(jobCleanupTimeout = 30s) so stop/remove still runs after the parent deadline fires, and the same fix applies toRunServiceJob's service teardown. Operators previously seeingExitedcontainers piling up after every MaxRuntime-bounded job should see them properly removed after this release. (#659, fixes #655, follow-up to #651 / #638)- Docker label
[global]keys outside the webhook subsystem now actually reach the livec.Globalinstead of being silently dropped. Setting e.g.ofelia.smtp-host=mail.example.comon a service container previously decoded into a scratchConfig.GlobalthatmergeJobsFromDockerContainers(boot) anddockerContainersUpdate(reconcile) discarded after the per-job / webhook merge —mergeMailDefaultsthen inherited the unchanged INI defaults so jobs saw empty SMTP with no warning. New per-subsystem helpers (mergeSlackGlobals,mergeMailGlobals,mergeSaveGlobals,mergeSchedulingGlobals) plus theapplyAllowListedGlobalsaggregator wire every allow-listed non-webhook global (Slack, Mail, Save,log-level,max-runtime,notification-cooldown,enable-strict-validation) through both call sites with the same "INI value wins when set; label only fills empty/default" precedence asmergeWebhookGlobalsfrom #650. Plain-bool fields (SMTPTLSSkipVerify,EnableStrictValidation) keepmergeMailDefaults' documented asymmetric policy: a label may UPGRADE false→true but cannot downgrade. Runtime label changes tolog-levelandnotification-cooldownnow also re-apply the process-wide knobs (ApplyLogLevel,initNotificationDedup) without a daemon restart. Documented limitation (matches existing INI live-reload behavior, out of scope for this fix): jobs whose own labels did not change in a reconcile pass keep their previously-inherited per-job middleware values until the next per-job change or restart —mergeNotificationDefaultsonly fills empty fields, so once inherited from the previous global, the per-job copy will not re-inherit. (#652, sibling fix to #650) DOCKER_HOST=tcp://...combined withDOCKER_CERT_PATH(or the equivalentClientConfig.TLSCertPathoverride) now actually negotiates TLS end-to-end. Previously the custom HTTP transport was wired with TLS material via #613, but the SDK kept thetcp://URL and Go'shttp.Transportonly triggers TLS forhttps://URLs — so the cert material was silently unused and connections went out plaintext.NewClientWithConfignow mirrors the docker CLI's silenttcp://->https://upgrade when TLS material is present, so the SDK and transport agree on the scheme and the configured certificate / pinned CA actually applies on the wire. Operators previously relying onDOCKER_HOST=tcp://...plus TLS env vars to reach an mTLS-protected daemon will now succeed instead of silently downgrading; operators who want plain TCP must omitDOCKER_CERT_PATH(or use explicittcp+tls://for TLS). (#634, follow-up to #613)- Docker API version negotiation at startup is now bounded by a configurable
NegotiateTimeout(default 30s). PreviouslyNewClientWithConfigcalledNegotiateAPIVersionwithcontext.Background(), so a reachable-but-wedged Docker daemon (e.g. a socket proxy with a hung upstream) could hang Ofelia at startup with no diagnostic output. The deadline-exceeded path now logs a warning so operators can correlate startup slowness with daemon health (#611, fixes #608) - Remaining unbounded Docker SDK calls are now wrapped in
context.WithTimeout, so a reachable-but-wedged daemon can no longer stall the periodic/healthand/readychecker (5s per call), the daemon startup sanity Pings inNewDockerHandlerandbuildSDKProvider(10s each, derived from the handler's own context so SIGINT during startup also cancels), or theofelia doctordiagnostic (5s per Ping and perHasImageLocallycall — per-call rather than overall to avoid falsely failing slow daemons with many images).web/health.gowas the most visible regression because monitoring agents would never observe a non-2xx response when the daemon wedged. The three timeout values are unexported constants — see code comments for rationale; file an issue if your environment needs different bounds. Also addsSDKDockerProviderConfig.NegotiateTimeoutto plumb the test-friendly negotiation bound from #611 one layer up. (#636, fixes #614) [global]section now recognizes the documentedwebhook-*keys (webhook-allow-remote-presets,webhook-preset-cache-ttl,webhook-trusted-preset-sources,webhook-preset-cache-dir,webhook-allowed-hosts,webhook-webhooks) without emitting "Unknown configuration key" warnings, and the values are now applied to the webhook subsystem. Live-reload also re-syncs intoWebhookConfigs.Globalso runtime edits towebhook-allowed-hoststake effect without a restart. Upgrade note: if you previously used the unprefixed forms (allow-remote-presets,webhooks,preset-cache-ttl, etc.) under[global]— they were never documented but were tolerated by the old hand-rolled parser — rename them to the documentedwebhook-*form. The old keys now produce "Unknown configuration key" warnings and the values silently fall back to defaults. (#618, fixes #604)DOCKER_HOST=tcp://...now correctly drives the HTTP transport's dialer whenClientConfig.Hostis empty. Previously the dialer was hard-pinned tounix:///var/run/docker.sockwhile the SDK was directed at the env-supplied TCP host, so every request silently routed to a non-existent unix socket and surfaced as a misleading "Cannot connect to the Docker daemon at tcp://..." error. Most commonly hit with Docker socket proxies (e.g. tecnativa/docker-socket-proxy). The actual code change cascaded in via #613; this entry documents the original report and adds the troubleshooting recipe. (#606, fixes #605)ExecServiceAdapter.Createand.Runno longer panic on a nilExecConfigor on nilstdout/stderrwriters in non-TTY mode. Both paths now return typed sentinel errors (ErrNilExecConfig,ErrNoExecOutputWriter) that callers can branch on viaerrors.Is. Previously the SDK would dereference the nil config (config.User, etc.) orstdcopy.StdCopywould panic on(nil, nil)writers when there was output to demultiplex. (#619, refs #610)- Defense-in-depth: every public method on every
*ServiceAdapterincore/adapters/docker/(Container,Exec,Image,Event,Network,Swarm,System) now returns the new sentinelErrNilDockerClientinstead of panicking with a nil-pointer dereference if the embedded SDK client is nil. ThenewClientFromSDKconstructor always wires a non-nil client, so this is only reachable through hand-rolled adapter values (test fixtures or wiring bugs) — but the guards convert what would otherwise be a panic in a hot goroutine into a branchable, actionable failure.SubscribeandWait(channel-returning) push the sentinel toerrChand close both channels synchronously without launching a goroutine. (#639, fixes #623) - The Docker label sync path now mirrors the
WebhookConfigs.Global → &Global.WebhookGlobalConfigpointer alias thatNewConfigset up for the live config in #637 / #620. The scratchConfigbuilt bydockerContainersUpdateandmergeJobsFromDockerContainerspreviously used a struct literal withWebhookConfigs: NewWebhookConfigs(), which leftparsed.WebhookConfigs.Globalpointing at a fresh*WebhookGlobalConfigdisjoint fromparsed.Global.WebhookGlobalConfig. Any futuremergeWebhookConfigsfield that reads from the parsedWebhookConfigs.Global(notably thePresetCacheTTLforwarding planned in #640) would silently observe the 24h default instead of the just-decoded label value. Both call sites now go through anewScratchConfig(c)helper that re-establishes the alias. (#641) - Symmetric nil-guards on the From helpers in the Docker adapter:
convertFromSwarmService(nil)returns nil,convertTaskTemplateFromSwarmis a no-op when either side is nil,convertFromSwarmTask(nil)returns the zerodomain.Task, andconvertFromSDKEvent(nil)returns the zerodomain.Event.ContainerServiceAdapter.Createnow returns the new typed sentinelErrNilContainerConfig(errors.Is-branchable, mirrorsErrNilExecConfigfrom #619) instead of dereferencingconfig.HostConfig/config.NetworkConfigon a nil config.convertFromSwarmTask,convertFromSDKEvent, andContainer.Create'sHostConfigderef are reachable through public API paths (the event-consumer goroutine,WaitForServiceTasks, the executor);convertFromSwarmServiceandconvertTaskTemplateFromSwarmwere latent (test-callable only) but guarded for symmetry with PR #626. Same bug class as #619 and #626. The issue's mention of "nile.Actor" was technically inaccurate —events.Actoris a value type, not a pointer, so only the outer*events.Messagecan be nil. (#632, refs #626 / #622) - Docker adapter convert helpers (
convertToSwarmSpec,convertTaskTemplateToSwarm,convertToMount) now nil-guard their pointer arguments and return a zero value instead of panicking. Same bug class as #619 — the helpers dereferencedspec,src, andmwithout a nil-check. Production callers always pass a non-nil pointer today (so this is defense-in-depth, not a wild-fire fix), but the helper signatures invited unsafe direct calls from tests and refactors. (#626, fixes #622) - Sibling-hunt completion of the
convert.gofamily of nil-guard gaps:convertFromAPIContainer(nil)andconvertFromNetworkResource(nil)return the zerodomain.Container/domain.Network,convertFromNetworkInspect(nil)returns nil (mirroringconvertFromSwarmServicefrom #648), andconvertToMount(nil)returns the zeromount.Mount— closing the asymmetry where every otherconvertTo*helper incontainer.go(convertToHostConfig,convertToNetworkingConfig,convertToEndpointSettings,convertToContainerConfig) already nil-guarded its argument and onlyconvertToMountdid not. All four are reached via&loopVarfrom arangeover a slice in production, so no live panic exists today; this is defense-in-depth for unsafe signature contracts. Same bug class as #619 / #626 / #632, completing what #648 started. (#654, refs #648 / #626)
- Stabilize
TestHealthStatusrace against theNewHealthCheckerbackground goroutine — build theHealthCheckerdirectly in the test so the auto-injecteddocker=Unhealthycheck cannot leak into the aggregated status beforeGetHealth()runs. (#606) - New
TestConfigGlobalKeysAreDocumentedwalks the embedded middleware structs inConfig.Globalvia reflection and asserts eachmapstructurekey is mentioned in at least one operator-facing docs file (docs/CONFIGURATION.md,docs/webhooks.md,docs/QUICK_REFERENCE.md,docs/TROUBLESHOOTING.md,README.md). Catches the same drift class as #604 / #621 mechanically. (#621) - Per-handler unit tests for the Docker scheme dispatch table (
TestSchemeHandlers_ApplyDirect) invoke eachapply*function directly with a fresh*http.Transportand assert the per-schemeForceAttemptHTTP2/DialContextshape. Catches a refactor that breaks one scheme without breaking the others — previously theapply*functions were only covered transitively. NewTestCreateHTTPClient_UnknownSchemeFallbackpins the defensive plain-HTTP/1.1 fallback for unrecognized schemes (production rejects upstream viaNewClientWithConfig; this exercises the seam, not the production gate). TightenedTestNewClientWithConfig_ReadsDOCKERHOSTOnceenv_onlybranch from<= 1to== 1so a regression that drops the env read entirely is caught. (#633, follow-up to #629)
- Reconcile the
tcp://Docker host scheme docs with reality: the godoc incore/adapters/docker/client.go(theschemeHandlerstable) and the scheme table indocs/TROUBLESHOOTING.mdpreviously claimedtcp://"auto-upgrades to TLS whenDOCKER_TLS_VERIFY/DOCKER_CERT_PATHare set", mirroring the docker CLI. The transport-layer half of that upgrade was wired in #613, but Go'shttp.Transportonly performs TLS onhttps://URLs — so theTLSClientConfigwas loaded with cert material that the SDK never offered on the wire. Operators following the docker CLI mental model believed they had mTLS while their connections went out as plain TCP. The fix removes theapplyDockerTLScall fromapplyTCPTransport(silent ineffective wiring is worse than failing loud), updates the godoc and the troubleshooting table to point operators attcp+tls://(#616) orhttps://for TLS over TCP, and replaces the misleadingTestCreateHTTPClient_TCPWithTLSEnvUpgradesregression test withTestCreateHTTPClient_TCPDoesNotWireTLSEvenWithEnvto pin the contract going forward. The deeper docker-CLI parity story — automatictcp://tohttps://URL rewriting at the SDK layer — is tracked separately in #634 and intentionally not addressed here. (#628) - Reconcile Slack middleware key documentation with the actual struct fields in
middlewares.SlackConfig. Removed the documented-but-rejectedslack-url(typo forslack-webhook),slack-channel,slack-mentions,slack-icon-emoji, andslack-usernamekeys fromdocs/CONFIGURATION.mdanddocs/QUICK_REFERENCE.md. The legacy Slack middleware (deprecated, scheduled for removal in v1.0.0) only acceptsslack-webhookandslack-only-on-error; for channel routing, mentions, custom username/avatar, etc., migrate to a[webhook "name"]section withpreset = slack(webhook docs). (#621) - Reconcile Save middleware key documentation with the actual struct fields in
middlewares.SaveConfig. Documented the existingrestore-historyandrestore-history-max-ageglobal keys (supported by the parser but undocumented) and removed the unimplementedsave-formatandsave-retentionkeys fromdocs/CONFIGURATION.md. (#621) - Document the previously-undocumented (or only partially-documented)
[global]keys surfaced by the docs-vs-code drift sweep:notification-cooldown(notification deduplication window) andsmtp-tls-skip-verify(with a dedicated security trade-off section indocs/TROUBLESHOOTING.mdcovering when it's acceptable, when it's not, and recommended alternatives) indocs/CONFIGURATION.md; and the webhook globalswebhook-webhooks,webhook-trusted-preset-sources,webhook-preset-cache-dir, plus an explicit INI-vs-Docker-labels callout (onlywebhook-webhooksis exposed via labels; the SSRF-sensitive globals are INI-only) indocs/webhooks.md. (#635, refs #621, #604) - Reconcile the README "Features" bullet and
OFELIA_POLL_INTERVALenv-var row with the post-#586 split-poll-interval contract: container detection now defaults to Docker events (--docker-events=trueis the only CLI flag in this group). The other three knobs are INI-only —docker-poll-interval(opt-in container polling),polling-fallback(default10s, auto-engages polling if the event stream fails), andconfig-poll-interval(default10s, drives INI file reloads). The legacy--docker-poll-intervalCLI flag stays for backward compatibility but now sets the deprecated unifiedConfig.Docker.PollInterval, whichApplyDeprecationMigrationssplits into the new INI keys at parse time. Thedocs/ARCHITECTURE_DIAGRAMS.mdpolling-defaults diagram is swapped to match — Events are default-on, Poll is opt-in, polling-fallback is shown as the third edge. Also documents the previously-undocumentedweb-trusted-proxiesglobal key indocs/CONFIGURATION.mdwith a security note covering the X-Forwarded-For spoofing risk if the CIDR list is too permissive (sibling-hunt finding from PR #645). TheTestConfigGlobalKeysAreDocumenteddrift detector is extended to walk direct (non-anonymous)Config.Globalfields too, closing theTODO(#635)so future undocumented direct keys (not just embedded middleware config keys) are caught mechanically. (#656, refs #644, #645, #586, #635)
- Unify Docker host / scheme resolution in
core/adapters/docker/client.gointo a singleresolveDockerHostseam.NewClientWithConfigandcreateHTTPClientnow agree on the resolved host without re-readingDOCKER_HOST, eliminating the dual-reader anti-pattern that produced #605 / #607 / #609. The dispatchswitchand the separatesupportedDockerHostSchemesslice collapsed into a singleschemeHandlersmap (allow-list + dispatch derived from the same data); scheme spelling lives in named constants;formatSupportedSchemesis cached in a package var.client.FromEnvis dropped from the SDK options chain (host + TLS are mirrored explicitly;DOCKER_API_VERSIONis preserved viaclient.WithVersionFromEnv()). New contract test assertsDOCKER_HOSTis read at most once perNewClientWithConfigcall; new parity test asserts the public allow-list cannot drift from the dispatch table. Pure refactor with one minor operator-visible side effect: theunsupported DOCKER_HOST schemeerror now lists the supported schemes in alphabetical order (http://, https://, npipe://, tcp://, unix://) rather than the previous curatedunix, tcp, http, https, npipeorder — the new map-derived list sorts deterministically. (#617)
- BREAKING: Docker Compose service-name based job naming now works as documented. The
com.docker.compose.servicelabel is no longer filtered out, so theCross-Container Job References (Docker Compose)feature fromdocs/CONFIGURATION.mdis functional. Users who relied on the previous (incorrect) job names may see different names. (#597)
- End-to-end test harness running the compiled binary as a subprocess — covers scheduling, the
validatecommand, SIGTERM/SIGINT graceful shutdown, and real Alpine container runs (#581)
log-levelinvalid-value error now lists all accepted levels (#599)make lintworks again —golangci-lintis now installed via the v2 module path (#600).envrchooks detection inside git worktrees (#598).gitignore/ofeliapattern is anchored so it cannot shadow source files (#574)- Stabilize flaky tests for scheduler shutdown, retry backoff, and rate limiter (#582, #601)
- Bump Go to 1.26.2 for stdlib security fixes (#557)
- Bump
github.com/netresearch/go-cron0.13.1 → 0.14.0 (#553, #563) - Bump
github.com/docker/cli29.3.0 → 29.4.0 (#548, #559) - Bump
github.com/docker/go-connections0.6.0 → 0.7.0 (#564) - Bump
github.com/go-playground/validator/v1010.30.1 → 10.30.2 (#552) - Bump
github.com/go-viper/mapstructure/v22.4.0 → 2.5.0 (#549) - Bump
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp1.42.0 → 1.43.0 (#556) - Bump
golang.org/x/crypto,golang.org/x/term,golang.org/x/text(#558, #560, #561) - Bump go-dependencies group (#596)
- Bump
alpineDocker base image (#569) - Bump GitHub Actions groups (#550, #554, #562)
- Adopt unified single-build release pipeline via
netresearch/.githubreusable workflows (#566, #587) - Migrate auto-merge to org-level reusable workflow (#567)
- Drop
integration.yml— superseded bygo-check(#579) - Stop Trivy FS scan from blocking PRs on pre-existing CVEs (#555)
- Fix auto-merge for Dependabot/Renovate PRs (#551)
- Use cosign
--bundlefor checksums signing (#547) - Grant
security-events: writeto satisfy reusable workflow (#585)
- Extract repeated string literals flagged by
goconst(#599)
- Migrate release pipeline from
slsa-github-generatortoactions/attest-build-provenancevia org-wide reusable workflow — fixes release builds blocked by SHA-pinning ruleset (#542)
- Migrate
go-viper/mapstructurev1 to v2.4.0 — fixes GO-2025-3787 and GO-2025-3900 (sensitive information leak in logs) (#544)
env-filesupport: load environment variables from files for all job types, like Docker's--env-file(#540, closes #314)env-fromsupport: copy environment variables from a running Docker container at job execution time (#540, closes #336, #351)
- Environment variable substitutions containing
#or;were parsed as INI inline comments, truncating values like SMTP passwords (#539, fixes #538) - Environment variable expansion now works in webhook config values (
secret,url, etc.) and section names (#539) log-levelconfig value now supports${VAR}expansion in the pre-parse path (#539)
- SHA-pin all GitHub Actions and add Dependabot for actions updates (#536)
- Bump the github-actions group with 20 updates (#537)
- Environment variable substitution in INI config files with
${VAR}and${VAR:-default}syntax (#532, closes #362)
- Bump
aquasecurity/trivy-actionfrom 0.28.0 to v0.35.0 (#532) - Bump
step-security/harden-runnerfrom v2.12.0 to v2.16.0 (#533) - Bump
codecov/codecov-actionfrom v5.5.2 to v5.5.3 (#533) - Bump
go.opentelemetry.io/otelfrom v1.40.0 to v1.42.0 (#533) - Bump
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttpfrom v1.38.0 to v1.42.0 (#533) - Bump
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttpfrom v0.65.0 to v0.67.0 (#533) - Bump
go.opentelemetry.io/proto/otlpfrom v1.9.0 to v1.10.0 (#533) - Bump
google.golang.org/protobuffrom v1.36.10 to v1.36.11 (#533) - Bump
google.golang.org/grpcfrom v1.77.0 to v1.79.3 (#531)
ofelia versioncommand and--versionflag (#528)job-service-runnow supportsvolumefor mounting host directories and named volumes (#529, closes #527)
- Fix
job-service-runnetwork not attached to service (#525, closes #524)convertToSwarmSpecnow reads networks from bothServiceSpec.NetworksandTaskTemplate.Networks
- Complete
convertFromSwarmServicewith missing field conversions: Mounts, RestartPolicy, Resources, Networks, Mode, Placement, LogDriver, EndpointSpec (#525)
- Swarm service adapter now converts Placement, LogDriver, and EndpointSpec in both directions (#525)
- 13 round-trip tests for the service adapter conversion layer (#525)
- Wire missing container spec fields across job types (#520, closes #519)
job-service-run: addenvironment,hostname,dirsupportjob-run: addworking-dirsupport, wirevolumes-from(was in struct but unused)job-exec: addprivilegedsupport- Fix misleading documentation claiming
job-service-runinherits fromRunJob
- Hide
WebPasswordHashandWebSecretKeyfrom/api/configendpoint (#511) - Remove CSRF bypass via
X-Requested-Withheader (#511) - Implement rate limiter cleanup to prevent memory exhaustion DoS (#511)
- Only trust
X-Forwarded-ForandX-Real-IPfrom trusted proxies to prevent IP spoofing (#511) - Make trusted proxies configurable via
web-trusted-proxies(#511)
- Propagate context to Docker API calls so cancellation and shutdown reach containers (#511)
- Prevent double-close panic on daemon done channel (#511)
- Add mutex to Config to prevent concurrent map access crash (#511)
- Execute shutdown hooks in priority groups, not all concurrently (#511)
- Enforce shutdown timeout even when hooks ignore context (#511)
- Return
NonZeroExitErrorfor non-zero Swarm service exit codes (#511)
- Bump
golang.org/x/cryptofrom 0.48.0 to 0.49.0 (#512) - Bump
github.com/netresearch/go-cronfrom 0.13.0 to 0.13.1 (#514) - Bump
golang.org/x/timefrom 0.14.0 to 0.15.0 (#515)
-
Secure Web Authentication (#408)
- Complete bcrypt password hashing with HMAC session tokens
- Secure cookie handling with HttpOnly, Secure, and SameSite flags
- Support for reverse proxy HTTPS detection (X-Forwarded-Proto)
- Password hashing utility:
ofelia hashpw
-
Doctor Command Enhancements (#408)
- Web authentication configuration checks in
ofelia doctor - Validates password hash format and token secret strength
- Web authentication configuration checks in
-
ntfy-token Preset (#409)
- Bearer token authentication for self-hosted ntfy instances
- Supports both ntfy.sh and self-hosted deployments with access tokens
-
Webhook Host Whitelist (#410)
- New
webhook-allowed-hostsconfiguration option - Default:
*(allow all hosts) - consistent with local command trust model - Whitelist mode when specific hosts are configured
- Supports domain wildcards (e.g.,
*.slack.com)
- New
-
CronClock Interface (#412)
- Testable time abstraction for scheduler testing
- FakeClock implementation for instant, deterministic tests
- go-cron compatible Timer interface
-
Cookie Security Hardening (#411)
- Secure, HttpOnly, and SameSite=Lax flags on all cookies
- HTTPS detection for reverse proxy deployments
- Security boundaries ADR documenting responsibility model
-
GitHub Actions Pinning (#411)
- All workflow actions pinned to SHA for supply chain security
- CodeQL updated to v3.31.9
-
Test Infrastructure (#412)
- Complete gocheck to stdlib+testify migration
- Eventually pattern replacing time.Sleep-based synchronization
- Parallel test execution with t.Parallel()
- Race condition fixes detected by -race flag
-
Performance (#412)
- Sub-second scheduling for faster test execution
- Optimized pre-commit and pre-push hooks
- Test suite runtime reduced by ~80%
-
Linting (#413)
- Comprehensive golangci-lint configuration audit
- All linting issues resolved
-
Security Boundaries ADR (#411)
- ADR-002 documenting security responsibility model
- Clear separation between Ofelia and infrastructure responsibilities
-
Webhook Documentation (#410)
- Host whitelist configuration guide
- Security model explanation
- Docker Socket HTTP/2 Compatibility
- Fixed Docker client connection failures on non-TLS connections introduced in v0.11.0
- OptimizedDockerClient now only enables HTTP/2 for HTTPS (TLS) connections
- HTTP/2 is disabled for Unix sockets, tcp://, and http:// (Docker daemon only supports HTTP/2 over TLS with ALPN)
- Resolves "protocol error" issues when connecting to
/var/run/docker.sockortcp://localhost:2375 - HTTP/2 enabled only for
https://connections where Docker daemon supports ALPN negotiation - Added comprehensive unit tests covering all connection types (9 scenarios)
- Technical details: Docker daemon does not implement h2c (HTTP/2 cleartext) - HTTP/2 requires TLS
-
Command Parsing in Swarm Services (#254)
- Fixed critical bug where
strings.Splitbroke quoted arguments in Docker Swarm service commands - Now uses
args.GetArgs()to properly handle commands likesh -c "echo hello world" - Prevents command execution failures in complex shell commands
- Fixed critical bug where
-
LocalJob Empty Command Panic (#254)
- Fixed documented bug where empty commands caused runtime panic
- Now returns proper error instead of crashing
- Prevents service crashes from malformed job configurations
-
API Security Validation (#254)
- Added validation for LocalJob and ComposeJob API endpoints
- Prevents command injection attacks via API
- Validates file paths, service names, and command arguments
-
Privilege Escalation Logging (#244)
- Enhanced logging for security monitoring
- Better detection of privilege escalation attempts
-
Dependency Updates
- Updated golang.org/x/crypto to v0.45.0 for CVE fixes
-
Enhanced Buffer Pool (#245)
- Multi-tier adaptive pooling system
- 99.97% memory usage reduction (2000 MB → 0.5 MB for 100 executions)
- Automatic size adjustment and pool warmup
-
Optimized Docker Client (#245)
- Connection pooling for reduced overhead
- Thread-safe concurrent operations
- Health monitoring and automatic recovery
-
Reduced Polling (#254)
- Increased legacy polling interval from 500ms to 2s
- 75% reduction in Docker API calls (200/min → 50/min per job)
- Significant CPU and network usage improvement
-
Performance Metrics Framework (#245)
- Comprehensive metrics for Docker operations
- Memory, latency, and throughput tracking
- Real-time performance monitoring
-
Container Annotations
- Support for custom annotations on RunJob and RunServiceJob
- Default Ofelia annotations for job tracking
- User-defined metadata for containers and services
-
WorkingDir for ExecJob
- Support for setting working directory in exec jobs
- Backward compatible with existing configurations
-
Opt-in Validation
- New
enable-strict-validationflag - Allows gradual migration to strict validation
- Prevents breaking changes for existing users
- New
-
Git Hooks with Lefthook
- Go-native git hooks for better portability
- Pre-commit, commit-msg, pre-push, post-checkout, post-merge hooks
- Automated code quality checks and security scans
-
Architecture Diagrams (#252)
- System architecture overview
- Component interaction diagrams
- Data flow visualization
-
Complete Package Documentation (#247)
- Comprehensive package-level documentation
- Security guides and best practices
- Practical usage guides
-
Docker Requirements
- Documented minimum Docker version requirements
- API compatibility notes
-
Exit Code Documentation (#254)
- Clear documentation of Ofelia-specific exit codes
- Swarm service error codes (-999, -998)
- Go Version Check (#251)
- Corrected inverted logic in .envrc Go version check
- Ensures correct Go version enforcement
- Updated go-dockerclient to v1.12.2
- Migrated from Husky to Lefthook for git hooks
- Improved CI/CD pipeline with comprehensive security scanning
- Removed AI assistant artifacts and outdated documentation (#246, #253)
- Enhanced test suite with comprehensive integration tests
- Improved code organization and maintainability
Previous release.
None - This release is backward compatible with v0.10.x
- Review API Usage: If you create jobs via API, ensure commands are properly validated
- Check Swarm Commands: Verify complex shell commands in service jobs work correctly
- Monitor Performance: Observe improved memory usage and reduced API calls
- Enable Metrics: Consider enabling the new metrics framework for monitoring
# Optional: Enable strict validation (default: false)
[global]
enable-strict-validation = true
# New: Container annotations
[job-run "example"]
annotations = com.example.key=value, app.version=1.0None in this release.
For more information, see: