Skip to content

Commit 64dde61

Browse files
committed
hostapp: add kernel version ABI extension filtering
Extensions can contain two labels (container labels or image labels, with container labels taking precedence): * io.balena.image.kernel-version: user space ABI in the form M.m.p revision * io.balena.image.kernel-abi-id: kernel space ABI as a sha256 of the Modules.symver file. Change-type: patch Signed-off-by: Alex Gonzalez <alexg@balena.io>
1 parent 5b25bc4 commit 64dde61

3 files changed

Lines changed: 730 additions & 1 deletion

File tree

cmd/mobynit/main.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,24 @@ func mountDataOverlays(newRootPath string) error {
152152
return nil
153153
}
154154

155+
// An empty release (e.g. uname failed) disables the version filter
156+
release, err := hostapp.GetKernelRelease()
157+
if err != nil {
158+
log.Printf("Warning: could not get kernel release: %v", err)
159+
}
160+
161+
cmdline, err := os.ReadFile("/proc/cmdline")
162+
if err != nil {
163+
log.Printf("Warning: could not read /proc/cmdline: %v", err)
164+
}
165+
hostABIID := hostapp.ParseHostKernelABIID(string(cmdline))
166+
167+
containers = hostapp.SelectMountable(containers, release, hostABIID)
168+
if len(containers) == 0 {
169+
log.Println("No extensions compatible with running kernel, skipping overlay")
170+
return nil
171+
}
172+
155173
var leftExtensions, rightExtensions []hostapp.Extension
156174

157175
for _, container := range containers {

hostapp.go

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package hostapp
22

33
import (
4+
"crypto/sha256"
5+
"encoding/hex"
46
"encoding/json"
57
"fmt"
8+
"io"
69
"log"
710
"os"
811
"path/filepath"
@@ -34,6 +37,7 @@ type Config struct {
3437
type Container struct {
3538
Config
3639
MountPath string
40+
HomePath string
3741
}
3842

3943
var (
@@ -115,6 +119,22 @@ func (container *Container) mount(layerRoot string) (string, error) {
115119
return container.MountPath, nil
116120
}
117121

122+
// unmount releases the container's overlay filesystem. It is a no-op for a
123+
// container that was never mounted (MountPath == "").
124+
func (container *Container) unmount() error {
125+
if container.MountPath == "" {
126+
return nil
127+
}
128+
if err := unix.Unmount(container.MountPath, 0); err != nil {
129+
return fmt.Errorf("unmounting %s: %w", container.MountPath, err)
130+
}
131+
if Debug {
132+
log.Printf("Unmounted ID %s from %s", container.ID, container.MountPath)
133+
}
134+
container.MountPath = ""
135+
return nil
136+
}
137+
118138
// initialize reads container config
119139
func (container *Container) initialize(homePath string) error {
120140
configPath := filepath.Join(homePath, "config.v2.json")
@@ -127,6 +147,7 @@ func (container *Container) initialize(homePath string) error {
127147
if err := json.NewDecoder(f).Decode(&container.Config); err != nil {
128148
return fmt.Errorf("decoding %s: %w", configPath, err)
129149
}
150+
container.HomePath = homePath
130151
if Verbose || Debug {
131152
log.Println("Initialized container:", container.Config.Name)
132153
}
@@ -192,7 +213,169 @@ func Mount(rootdir string, label string) ([]Container, error) {
192213
return initializeContainers(rootdir, label)
193214
}
194215

195-
const HOSTOS_BLOCKS_OVERRIDE = "io.balena.image.override"
216+
const (
217+
HOSTOS_BLOCKS_OVERRIDE = "io.balena.image.override"
218+
HOSTOS_BLOCKS_KERNEL_VERSION = "io.balena.image.kernel-version"
219+
HOSTOS_BLOCKS_KERNEL_ABI_ID = "io.balena.image.kernel-abi-id"
220+
CMDLINE_KERNEL_ABI = "balena_kernel_abi"
221+
)
222+
223+
// ParseHostKernelABIID extracts the balena_kernel_abi=<value> token from a
224+
// kernel cmdline string and returns its value. Returns "" when the token is
225+
// absent or carries an empty value, i.e. when the boot path ran a stock
226+
// kernel whose ABI is not knowable.
227+
func ParseHostKernelABIID(cmdline string) string {
228+
prefix := CMDLINE_KERNEL_ABI + "="
229+
for _, tok := range strings.Fields(cmdline) {
230+
if v, ok := strings.CutPrefix(tok, prefix); ok {
231+
return v
232+
}
233+
}
234+
return ""
235+
}
236+
237+
// GetKernelRelease returns the running kernel's full release string
238+
// (e.g. "6.8.0-100-generic"), as reported by uname(2).
239+
func GetKernelRelease() (string, error) {
240+
var utsname unix.Utsname
241+
if err := unix.Uname(&utsname); err != nil {
242+
return "", fmt.Errorf("uname syscall failed: %w", err)
243+
}
244+
return unix.ByteSliceToString(utsname.Release[:]), nil
245+
}
246+
247+
// kernelVersionFromRelease strips the local-version suffix (e.g. "-100-generic",
248+
// "-v8+") from a uname release, leaving the M.m.p version that kernel-version
249+
// compatibility tracks. An empty release yields an empty version.
250+
func kernelVersionFromRelease(release string) string {
251+
if idx := strings.IndexByte(release, '-'); idx > 0 {
252+
return release[:idx]
253+
}
254+
return release
255+
}
256+
257+
// FilterByKernelVersion removes containers whose kernel-version label
258+
// doesn't match the running kernel. Containers without the label always pass.
259+
// An empty kernelVersion disables filtering.
260+
func FilterByKernelVersion(containers []Container, kernelVersion string) []Container {
261+
if kernelVersion == "" {
262+
return containers
263+
}
264+
var filtered []Container
265+
for _, c := range containers {
266+
if labelVal, ok := c.Labels[HOSTOS_BLOCKS_KERNEL_VERSION]; ok && labelVal != kernelVersion {
267+
log.Printf("Skipping container %s: kernel version %q != running %q", c.Name, labelVal, kernelVersion)
268+
continue
269+
}
270+
filtered = append(filtered, c)
271+
}
272+
return filtered
273+
}
274+
275+
// ComputeABIID returns the hex-encoded sha256 of the file at path.
276+
// Used to derive io.balena.image.kernel-abi-id from Module.symvers.
277+
func ComputeABIID(path string) (string, error) {
278+
f, err := os.Open(path)
279+
if err != nil {
280+
return "", fmt.Errorf("opening %s: %w", path, err)
281+
}
282+
defer f.Close()
283+
h := sha256.New()
284+
if _, err := io.Copy(h, f); err != nil {
285+
return "", fmt.Errorf("hashing %s: %w", path, err)
286+
}
287+
return hex.EncodeToString(h.Sum(nil)), nil
288+
}
289+
290+
// ResolveExtensionABIID computes the container's kernel ABI ID as
291+
// sha256(<mount>/lib/modules/<release>/Module.symvers), where release is the
292+
// running kernel's uname release.
293+
//
294+
// Returns "" with no error if the extension carries no kernel modules for the
295+
// running release. Returns an error if /lib/modules/<release> exists
296+
// but Module.symvers is missing (broken extension), if the label disagrees with
297+
// the computed value, or if release is empty (running kernel unknown) for a
298+
// mounted extension.
299+
func (c *Container) ResolveExtensionABIID(release string) (string, error) {
300+
if c.MountPath == "" {
301+
return "", nil
302+
}
303+
if release == "" {
304+
return "", fmt.Errorf("extension %s: running kernel release unknown", c.Name)
305+
}
306+
modDir := filepath.Join(c.MountPath, "lib", "modules", release)
307+
if _, err := os.Stat(modDir); err != nil {
308+
if os.IsNotExist(err) {
309+
return "", nil
310+
}
311+
return "", fmt.Errorf("stat %s: %w", modDir, err)
312+
}
313+
symversPath := filepath.Join(modDir, "Module.symvers")
314+
if _, err := os.Stat(symversPath); err != nil {
315+
if os.IsNotExist(err) {
316+
return "", fmt.Errorf("broken extension %s: %s missing", c.Name, symversPath)
317+
}
318+
return "", fmt.Errorf("stat %s: %w", symversPath, err)
319+
}
320+
id, err := ComputeABIID(symversPath)
321+
if err != nil {
322+
return "", err
323+
}
324+
if labelVal, ok := c.Labels[HOSTOS_BLOCKS_KERNEL_ABI_ID]; ok && labelVal != "" && labelVal != id {
325+
return "", fmt.Errorf("extension %s: %s label %q != computed %q",
326+
c.Name, HOSTOS_BLOCKS_KERNEL_ABI_ID, labelVal, id)
327+
}
328+
return id, nil
329+
}
330+
331+
// FilterByKernelABIID keeps only those containers safe to mount over the
332+
// running kernel.
333+
//
334+
// An ABI-agnostic extension makes no kernel-ABI claim and always passes.
335+
// A kernel-carrying extension is kept only when its computed ABI equals hostABIID.
336+
func FilterByKernelABIID(containers []Container, release, hostABIID string) []Container {
337+
var filtered []Container
338+
for i := range containers {
339+
c := &containers[i]
340+
id, err := c.ResolveExtensionABIID(release)
341+
if err != nil {
342+
log.Printf("Error: dropping container %s: %v", c.Name, err)
343+
continue
344+
}
345+
if id == "" {
346+
filtered = append(filtered, *c)
347+
continue
348+
}
349+
if id != hostABIID {
350+
log.Printf("Skipping container %s: kernel ABI ID %q != host %q", c.Name, id, hostABIID)
351+
continue
352+
}
353+
filtered = append(filtered, *c)
354+
}
355+
return filtered
356+
}
357+
358+
// SelectMountable filters the already-mounted extensions down to those
359+
// compatible with the running kernel, unmounting every extension it drops.
360+
// Survivors stay mounted for use as overlay lowerdirs.
361+
func SelectMountable(containers []Container, release, hostABIID string) []Container {
362+
selected := FilterByKernelVersion(containers, kernelVersionFromRelease(release))
363+
selected = FilterByKernelABIID(selected, release, hostABIID)
364+
365+
keep := make(map[string]bool, len(selected))
366+
for _, c := range selected {
367+
keep[c.MountPath] = true
368+
}
369+
for i := range containers {
370+
if keep[containers[i].MountPath] {
371+
continue
372+
}
373+
if err := containers[i].unmount(); err != nil {
374+
log.Printf("Warning: failed to unmount dropped extension %s: %v", containers[i].Name, err)
375+
}
376+
}
377+
return selected
378+
}
196379

197380
// Extension represents an OS-block overlay extension. Extensions passed in
198381
// the leftExtensions slice of BuildOverlayOptions mount left of the hostapp

0 commit comments

Comments
 (0)