Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion internal/procfs/procfs.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,14 @@ type MountEntry struct {
// Mounts parses /proc/self/mountinfo following the format described in
// Documentation/filesystems/proc.rst.
func (r Root) Mounts() ([]MountEntry, error) {
f, err := os.Open(r.file("self", "mountinfo"))
return r.MountsForPID("self")
}

// MountsForPID parses /proc/<pid>/mountinfo. Useful for reading the host's
// mount table from inside a strictly-confined snap by passing "1" (host
// systemd lives in the host's mount namespace).
func (r Root) MountsForPID(pid string) ([]MountEntry, error) {
f, err := os.Open(r.file(pid, "mountinfo"))
if err != nil {
return nil, err
}
Expand Down
79 changes: 79 additions & 0 deletions internal/tools/block.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,12 @@ import (
"encoding/json"
"fmt"
"regexp"
"strconv"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/gjolly/fleetmind/internal/procfs"
"github.com/gjolly/fleetmind/internal/snapconf"
)

type listBlockIn struct {
Expand Down Expand Up @@ -48,13 +52,88 @@ func registerBlock(s *mcp.Server, d Deps) {
if err := json.Unmarshal(stdout, &parsed); err != nil {
return nil, listBlockOut{}, fmt.Errorf("parse lsblk output: %w", err)
}
// Inside a strictly-confined snap, lsblk uses libmount which reads
// /proc/self/mountinfo — the snap's mount namespace. That makes the
// host's rootfs appear mounted at /var/lib/snapd/hostfs, shadows real
// /boot with the snap base squashfs, and adds a long tail of
// /etc/*-bind-mount entries. Rewrite mountpoint/mountpoints/fsroots
// from PID 1's view (the host's mount namespace) so callers see the
// host's actual mount table.
if snapconf.InSnap() {
if idx, err := buildHostMountIndex(d.ProcFS); err == nil {
rewriteHostMounts(parsed, idx)
}
// Soft-fail: keep the raw lsblk output if /proc/1/mountinfo
// is unreadable for any reason.
}
if nameRE != nil || len(in.Fields) > 0 {
parsed = filterBlockDevices(parsed, nameRE, in.Fields)
}
return textResult("lsblk: %d bytes of JSON", len(stdout)), listBlockOut{Devices: parsed}, nil
})
}

// buildHostMountIndex maps "major:minor" → all mount entries for that device
// in PID 1's mount namespace (the host's view). The kernel allocates
// major:minor namespace-independently, so values agree between the snap's
// mount NS and the host's, making this a safe key.
func buildHostMountIndex(pf procfs.Root) (map[string][]procfs.MountEntry, error) {
entries, err := pf.MountsForPID("1")
if err != nil {
return nil, err
}
idx := make(map[string][]procfs.MountEntry, len(entries))
for _, e := range entries {
key := strconv.Itoa(e.DevMajor) + ":" + strconv.Itoa(e.DevMinor)
idx[key] = append(idx[key], e)
}
return idx, nil
}

// rewriteHostMounts walks the parsed lsblk JSON and overwrites each device's
// mountpoint/mountpoints/fsroots with the host-namespace view from idx,
// recursing into "children".
func rewriteHostMounts(parsed map[string]any, idx map[string][]procfs.MountEntry) {
devs, ok := parsed["blockdevices"].([]any)
if !ok {
return
}
for _, raw := range devs {
if m, ok := raw.(map[string]any); ok {
rewriteDeviceMounts(m, idx)
}
}
}

func rewriteDeviceMounts(m map[string]any, idx map[string][]procfs.MountEntry) {
if mm, ok := m["maj:min"].(string); ok {
if entries := idx[mm]; len(entries) > 0 {
mps := make([]any, len(entries))
roots := make([]any, len(entries))
for i, e := range entries {
mps[i] = e.MountPoint
roots[i] = e.Root
}
m["mountpoint"] = entries[0].MountPoint
m["mountpoints"] = mps
m["fsroots"] = roots
} else if _, present := m["maj:min"]; present {
// Device exists but isn't mounted in the host NS — report as
// unmounted regardless of what the snap NS shows.
m["mountpoint"] = nil
m["mountpoints"] = []any{nil}
m["fsroots"] = []any{nil}
}
}
if kids, ok := m["children"].([]any); ok {
for _, kid := range kids {
if km, ok := kid.(map[string]any); ok {
rewriteDeviceMounts(km, idx)
}
}
}
}

// filterBlockDevices applies name_regex (top level only) and fields projection
// (applied recursively to every device dict) to the parsed lsblk output.
func filterBlockDevices(in map[string]any, nameRE *regexp.Regexp, fields []string) map[string]any {
Expand Down
18 changes: 17 additions & 1 deletion internal/tools/mount.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import (
"syscall"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/gjolly/fleetmind/internal/procfs"
"github.com/gjolly/fleetmind/internal/snapconf"
)

type listMountsIn struct {
Expand Down Expand Up @@ -47,7 +50,20 @@ func registerMount(s *mcp.Server, d Deps) {
}
mpRE = re
}
entries, err := d.ProcFS.Mounts()
// Inside a strictly-confined snap, /proc/self/mountinfo is the snap's
// confined mount namespace (snap base bind-mounts, tmpfs overlays,
// every other snap's squashfs). Read PID 1's mountinfo instead — host
// systemd lives in the host's mount namespace, and the mount-observe
// plug grants read access to /proc/[pid]/mountinfo.
var (
entries []procfs.MountEntry
err error
)
if snapconf.InSnap() {
entries, err = d.ProcFS.MountsForPID("1")
} else {
entries, err = d.ProcFS.Mounts()
}
if err != nil {
return nil, listMountsOut{}, err
}
Expand Down
21 changes: 20 additions & 1 deletion internal/tools/system.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"syscall"

"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/gjolly/fleetmind/internal/snapconf"
)

type systemInfoIn struct{}
Expand Down Expand Up @@ -59,8 +61,25 @@ func utsString(b []int8) string {
}

func readOSRelease() map[string]string {
// Inside a strictly-confined snap, /etc/os-release and /usr/lib/os-release
// are bind-mounted from the snap base (e.g. core24), so reading them
// reports "Ubuntu Core". snapd exposes the host's filesystem under
// /var/lib/snapd/hostfs, and the system-observe interface grants read
// access to os-release there — prefer those paths when in a snap.
var paths []string
if snapconf.InSnap() {
paths = []string{
"/var/lib/snapd/hostfs/etc/os-release",
"/var/lib/snapd/hostfs/usr/lib/os-release",
"/etc/os-release",
"/usr/lib/os-release",
}
} else {
paths = []string{"/etc/os-release", "/usr/lib/os-release"}
}

out := map[string]string{}
for _, p := range []string{"/etc/os-release", "/usr/lib/os-release"} {
for _, p := range paths {
f, err := os.Open(p) //nolint:gosec // fixed allowlist of os-release paths
if err != nil {
continue
Expand Down
Loading