Describe the bug
StartWorker bootstraps all enabled container engines synchronously, on the caller's thread, before returning. The context it uses is derived from context.Background() with cancel only, so it has no deadline. As a consequence, a runtime socket that accepts a connection but never answers blocks the call forever.
Since StartWorker is invoked from start_async_events, which libs calls from sinsp::open_common() on Falco's main thread, this hangs Falco's startup permanently, before the event loop is even reached.
Reported downstream at falcosecurity/falco#3953
The chain:
StartWorker runs generator(ctx) and then engine.List(ctx) for each engine, serially and inline, with no deadline 👇
|
for _, generator := range generators { |
|
engine, err := generator(ctx) |
|
if err != nil { |
|
continue |
|
} |
|
containerEngines = append(containerEngines, engine) |
|
if _, ok := enabledEngines[engine.Name()]; !ok { |
|
enabledEngines[engine.Name()] = make([]string, 0) |
|
} |
|
enabledEngines[engine.Name()] = append(enabledEngines[engine.Name()], engine.Sock()) |
|
// List all pre-existing containers and run `goCb` on all of them |
|
containers, err := engine.List(ctx) |
|
if err == nil { |
|
for _, ctr := range containers { |
|
goCb(ctr.String(), true, true) |
|
} |
|
} |
|
} |
newPodmanEngine calls bindings.NewConnection, which pings the service with a GET /_ping. For unix:// URIs, the podman bindings build an http.Client with no Timeout and a net.Dialer with no timeout 👇
|
func newPodmanEngine(ctx context.Context, _ *slog.Logger, socket string) (Engine, error) { |
|
conn, err := bindings.NewConnection(ctx, enforceUnixProtocolIfEmpty(socket)) |
|
if err != nil { |
|
return nil, err |
|
} |
|
return &podmanEngine{pCtx: conn, socket: socket}, nil |
|
} |
podmanEngine.List discards its ctx parameter and uses the connection context instead 👇
|
func (pc *podmanEngine) List(_ context.Context) ([]event.Event, error) { |
- Sockets are not reachability-probed.
Generators() creates an engine for any path whose os.Stat does not return NotExist 👇
|
|
|
func Generators() ([]EngineGenerator, error) { |
|
generators := make([]EngineGenerator, 0) |
|
|
|
c := config.Get() |
|
for engineName, engineGen := range engineGenerators { |
|
eCfg, ok := c.SocketsEngines[string(engineName)] |
|
if !ok || !eCfg.Enabled { |
|
continue |
|
} |
|
// For each specified socket, return a closure to generate its engine |
|
for _, socket := range eCfg.Sockets { |
|
// Properly account for HOST_ROOT env variable |
|
socket = filepath.Join(config.GetHostRoot(), socket) |
|
// Even if `stat` returns an err that is not NotExist, |
|
// try to generate an engine for the socket. |
|
if _, statErr := os.Stat(socket); !os.IsNotExist(statErr) { |
|
generators = append(generators, func(ctx context.Context) (Engine, error) { |
|
return engineGen(ctx, slog.With("engine", engineName), socket) |
|
}) |
|
} |
|
} |
|
} |
|
return generators, nil |
|
} |
Affected engines:
| Engine |
Connect timeout |
List bounded |
podman |
none |
no (ignores ctx) |
docker |
none |
no |
containerd |
none |
no |
cri |
5s |
yes |
So cri is the only one already protected 👇
|
func newCriEngine(ctx context.Context, logger *slog.Logger, socket string) (Engine, error) { |
Aggravating factor: rootless podman auto-discovery
When engines.podman.sockets is empty, the plugin adds /run/podman/podman.sock and scans /run/user/*/podman/podman.sock, enabling everything it finds 👇
|
if(cfg.engines.podman.sockets.empty()) |
|
{ |
|
cfg.engines.podman.sockets.emplace_back("/run/podman/podman.sock"); |
|
try |
|
{ |
|
for(const auto& entry : std::filesystem::directory_iterator( |
|
cfg.host_root + "/run/user")) |
|
{ |
|
if(entry.is_directory()) |
|
{ |
|
if(std::filesystem::exists(entry.path().string() + |
|
"/podman/podman.sock")) |
|
{ |
|
// Remove host root since it will be later added by |
|
// go-worker itself |
|
auto root = entry.path().string().substr( |
|
cfg.host_root.length()); |
|
cfg.engines.podman.sockets.emplace_back( |
|
root + "/podman/podman.sock"); |
|
} |
|
} |
|
} |
|
} |
|
catch(...) |
|
{ |
|
// No error; perhaps /run/user does not exist. |
|
} |
|
} |
That scan is active on host installs, since Falco ships engines unset ("We use default config values for engines key" in falco.yaml).
Those sockets are socket-activated by systemd, per user. Falco runs as root, so connect() succeeds because systemd owns the listener, but the response only arrives once that user's podman.service starts. If the user manager is not running, no response ever arrives.
N.B. The Helm chart pins podman.sockets: ["/run/podman/podman.sock"], which suppresses the scan. So Kubernetes deployments are largely shielded, and host RPM/DEB installs are the exposed configuration.
How to reproduce it
- Install Falco from the RPM/DEB packages, so that
engines is left at its defaults
- Make a unix socket available at
/run/user/<uid>/podman/podman.sock that accepts connections but never writes a response. A socket-activated rootless podman.service that cannot start reproduces this naturally (this is what the reporter hit). For a synthetic repro, something like socat UNIX-LISTEN:<path>,fork SYSTEM:'sleep infinity' should do, though I have not verified that exact invocation yet
- Start Falco
Falco logs up to Trying to open the right engine! and then hangs indefinitely. SIGINT and SIGTERM have no effect, since they only set a flag that the event loop polls, so only SIGKILL works.
Expected behaviour
An unresponsive or misbehaving runtime socket must never be able to block Falco's startup. The engine bootstrap should be bounded, and an engine that does not answer in time should be reported as unavailable instead of hanging.
Environment
- Container plugin version: 0.7.1, and also reproducible on
main (no timeout changes have landed on go-worker since 0.7.1)
- Falco version: 0.44.1
- libs version: 0.25.4
- OS: Rocky Linux 10.2, kernel
6.12.0-211.42.1.el10_2.x86_64
- Installation method: RPM, also reported with the Docker/Podman images
- Drivers: reproduced with both
kmod and modern_ebpf, since the plugin bootstrap runs before the driver matters
Proposed fix
In order of risk, from lowest:
- Bound the bootstrap. Wrap each
generator(ctx) and engine.List(ctx) in a context.WithTimeout inside StartWorker. This just normalizes the behaviour to what cri already does, and the timeout should be configurable.
- Honour the passed
ctx. podmanEngine.List currently discards it, so (1) alone would have no effect for podman. The other engines should be audited for the same problem.
- Set explicit client timeouts where the SDK allows it, i.e.
client.WithTimeout for docker and containerd.WithTimeout for containerd. The podman bindings own their http.Client, so the ctx deadline from (1) is the only lever there.
- Follow-up, larger change: do not block startup at all. Move the pre-existing container enumeration into the worker goroutine and let
StartWorker return immediately. N.B. this needs care, since capture_open enriches the initial thread table right afterwards, so it would race against the merge of s_preexisting_containers. I'd do 1-3 first and treat this one separately.
Additional context
There is also a design question worth discussing. Auto-enabling every /run/user/*/podman/podman.sock found on disk gives a root-running Falco a hard dependency on arbitrary users' socket-activated services. At the very least, those sockets should be reachability-probed with a short timeout before being enabled. Arguably, the scan should be opt-in. wdyt? 🤔
cc @deepskyblue86 @FedeDP
Describe the bug
StartWorkerbootstraps all enabled container engines synchronously, on the caller's thread, before returning. The context it uses is derived fromcontext.Background()with cancel only, so it has no deadline. As a consequence, a runtime socket that accepts a connection but never answers blocks the call forever.Since
StartWorkeris invoked fromstart_async_events, which libs calls fromsinsp::open_common()on Falco's main thread, this hangs Falco's startup permanently, before the event loop is even reached.Reported downstream at falcosecurity/falco#3953
The chain:
StartWorkerrunsgenerator(ctx)and thenengine.List(ctx)for each engine, serially and inline, with no deadline 👇plugins/plugins/container/go-worker/worker_api.go
Lines 66 to 83 in 21d3ee6
newPodmanEnginecallsbindings.NewConnection, which pings the service with aGET /_ping. Forunix://URIs, the podman bindings build anhttp.Clientwith noTimeoutand anet.Dialerwith no timeout 👇plugins/plugins/container/go-worker/pkg/container/podman.go
Lines 33 to 39 in 21d3ee6
podmanEngine.Listdiscards itsctxparameter and uses the connection context instead 👇plugins/plugins/container/go-worker/pkg/container/podman.go
Line 188 in 21d3ee6
Generators()creates an engine for any path whoseos.Statdoes not returnNotExist👇plugins/plugins/container/go-worker/pkg/container/engine.go
Lines 61 to 85 in 21d3ee6
Affected engines:
Listboundedpodmanctx)dockercontainerdcriSo
criis the only one already protected 👇plugins/plugins/container/go-worker/pkg/container/cri.go
Line 43 in 21d3ee6
Aggravating factor: rootless podman auto-discovery
When
engines.podman.socketsis empty, the plugin adds/run/podman/podman.sockand scans/run/user/*/podman/podman.sock, enabling everything it finds 👇plugins/plugins/container/src/plugin_config.cpp
Lines 66 to 93 in 21d3ee6
That scan is active on host installs, since Falco ships
enginesunset ("We use default config values forengineskey" infalco.yaml).Those sockets are socket-activated by
systemd, per user. Falco runs as root, soconnect()succeeds becausesystemdowns the listener, but the response only arrives once that user'spodman.servicestarts. If the user manager is not running, no response ever arrives.N.B. The Helm chart pins
podman.sockets: ["/run/podman/podman.sock"], which suppresses the scan. So Kubernetes deployments are largely shielded, and host RPM/DEB installs are the exposed configuration.How to reproduce it
enginesis left at its defaults/run/user/<uid>/podman/podman.sockthat accepts connections but never writes a response. A socket-activated rootlesspodman.servicethat cannot start reproduces this naturally (this is what the reporter hit). For a synthetic repro, something likesocat UNIX-LISTEN:<path>,fork SYSTEM:'sleep infinity'should do, though I have not verified that exact invocation yetFalco logs up to
Trying to open the right engine!and then hangs indefinitely.SIGINTandSIGTERMhave no effect, since they only set a flag that the event loop polls, so onlySIGKILLworks.Expected behaviour
An unresponsive or misbehaving runtime socket must never be able to block Falco's startup. The engine bootstrap should be bounded, and an engine that does not answer in time should be reported as unavailable instead of hanging.
Environment
main(no timeout changes have landed ongo-workersince 0.7.1)6.12.0-211.42.1.el10_2.x86_64kmodandmodern_ebpf, since the plugin bootstrap runs before the driver mattersProposed fix
In order of risk, from lowest:
generator(ctx)andengine.List(ctx)in acontext.WithTimeoutinsideStartWorker. This just normalizes the behaviour to whatcrialready does, and the timeout should be configurable.ctx.podmanEngine.Listcurrently discards it, so (1) alone would have no effect for podman. The other engines should be audited for the same problem.client.WithTimeoutfor docker andcontainerd.WithTimeoutfor containerd. The podman bindings own theirhttp.Client, so the ctx deadline from (1) is the only lever there.StartWorkerreturn immediately. N.B. this needs care, sincecapture_openenriches the initial thread table right afterwards, so it would race against the merge ofs_preexisting_containers. I'd do 1-3 first and treat this one separately.Additional context
There is also a design question worth discussing. Auto-enabling every
/run/user/*/podman/podman.sockfound on disk gives a root-running Falco a hard dependency on arbitrary users' socket-activated services. At the very least, those sockets should be reachability-probed with a short timeout before being enabled. Arguably, the scan should be opt-in. wdyt? 🤔cc @deepskyblue86 @FedeDP