imagepull.Flatten now captures per-file uid/gid/mode/type from the tar headers as it walks layers, returning a Metadata map alongside the extracted tree. Whiteouts correctly drop the victim's metadata. The returned Metadata feeds the new imagepull.ApplyOwnership, which pipes a batched `set_inode_field` script to `debugfs -w -f -`. Why: mkfs.ext4 -d copies the runner's on-disk uids verbatim, so without this pass setuid binaries become setuid-nonroot and sshd refuses to start on the resulting image. With the pass, a pulled debian:bookworm has /usr/bin/sudo with uid=0 + setuid bit surviving intact. imagepull.BuildExt4 signature unchanged; ownership is applied as a separate step by the daemon orchestrator between BuildExt4 and StageBootArtifacts, keeping each helper focused. The seam (d.pullAndFlatten) now returns (Metadata, error) for test stubs to feed synthetic metadata. StdinRunner is a new duck-typed extension next to CommandRunner; the real system.Runner implements RunStdin, test mocks don't need to unless they exercise stdin. Prevents every existing mock from growing a new method. Tests: - TestFlattenCapturesHeaderMetadata: setuid bit + mode survive the tar-header walk - TestApplyOwnershipRewritesUidGidMode: real debugfs round-trip — create ext4 with runner's uid, apply synthetic metadata setting uid=0 + setuid mode, verify via `debugfs -R stat` that the inode now has uid=0 and mode 04755 - TestBuildOwnershipScriptDeterministic: sorted, well-formed sif script output Debugfs and mkfs.ext4 tests skip if the binaries aren't on PATH. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
563 lines
14 KiB
Go
563 lines
14 KiB
Go
package system
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"banger/internal/model"
|
|
)
|
|
|
|
type Runner struct{}
|
|
|
|
type CommandRunner interface {
|
|
Run(ctx context.Context, name string, args ...string) ([]byte, error)
|
|
RunSudo(ctx context.Context, args ...string) ([]byte, error)
|
|
}
|
|
|
|
// StdinRunner is a duck-typed extension to CommandRunner for callers
|
|
// that need to pipe stdin into a command (e.g. `debugfs -w -f -`). The
|
|
// real system.Runner implements it; test doubles don't need to unless
|
|
// they exercise this path.
|
|
type StdinRunner interface {
|
|
RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error)
|
|
}
|
|
|
|
func NewRunner() Runner {
|
|
return Runner{}
|
|
}
|
|
|
|
func (Runner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if stderr.Len() > 0 {
|
|
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return stdout.Bytes(), err
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func (r Runner) RunSudo(ctx context.Context, args ...string) ([]byte, error) {
|
|
all := append([]string{"-n"}, args...)
|
|
return r.Run(ctx, "sudo", all...)
|
|
}
|
|
|
|
// RunStdin executes name with args and pipes stdin in from the provided
|
|
// reader. Used for commands like debugfs -w that accept a scripted
|
|
// command stream on stdin.
|
|
func (Runner) RunStdin(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
cmd.Stdin = stdin
|
|
if err := cmd.Run(); err != nil {
|
|
if stderr.Len() > 0 {
|
|
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return stdout.Bytes(), err
|
|
}
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
func EnsureSudo(ctx context.Context) error {
|
|
cmd := exec.CommandContext(ctx, "sudo", "-v")
|
|
cmd.Stdout = os.Stdout
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
return cmd.Run()
|
|
}
|
|
|
|
func CheckSudo(ctx context.Context) error {
|
|
if _, err := exec.LookPath("sudo"); err != nil {
|
|
return err
|
|
}
|
|
cmd := exec.CommandContext(ctx, "sudo", "-n", "-v")
|
|
var stderr bytes.Buffer
|
|
cmd.Stderr = &stderr
|
|
if err := cmd.Run(); err != nil {
|
|
if stderr.Len() > 0 {
|
|
return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func RequireCommands(ctx context.Context, commands ...string) error {
|
|
for _, command := range commands {
|
|
if _, err := exec.LookPath(command); err != nil {
|
|
return fmt.Errorf("required command %q not found", command)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func LookupExecutable(name string) (string, error) {
|
|
return exec.LookPath(name)
|
|
}
|
|
|
|
func WriteJSON(path string, value any) error {
|
|
data, err := json.MarshalIndent(value, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, data, 0o644)
|
|
}
|
|
|
|
func AllocatedBytes(path string) int64 {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
stat, ok := info.Sys().(*syscall.Stat_t)
|
|
if !ok {
|
|
return info.Size()
|
|
}
|
|
return stat.Blocks * 512
|
|
}
|
|
|
|
func ProcessRunning(pid int, apiSock string) bool {
|
|
if pid <= 0 || apiSock == "" {
|
|
return false
|
|
}
|
|
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "cmdline"))
|
|
if err != nil {
|
|
return false
|
|
}
|
|
cmdline := strings.ReplaceAll(string(data), "\x00", " ")
|
|
return strings.Contains(cmdline, "firecracker") && strings.Contains(cmdline, apiSock)
|
|
}
|
|
|
|
type ProcessStats struct {
|
|
CPUPercent float64
|
|
RSSBytes int64
|
|
VSZBytes int64
|
|
}
|
|
|
|
type HostResources struct {
|
|
CPUCount int
|
|
TotalMemoryBytes int64
|
|
}
|
|
|
|
type FilesystemUsage struct {
|
|
TotalBytes int64
|
|
FreeBytes int64
|
|
}
|
|
|
|
func ReadProcessStats(ctx context.Context, pid int) (ProcessStats, error) {
|
|
_ = ctx
|
|
if pid <= 0 {
|
|
return ProcessStats{}, errors.New("pid is required")
|
|
}
|
|
statData, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "stat"))
|
|
if err != nil {
|
|
return ProcessStats{}, err
|
|
}
|
|
statmData, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "statm"))
|
|
if err != nil {
|
|
return ProcessStats{}, err
|
|
}
|
|
uptimeData, err := os.ReadFile("/proc/uptime")
|
|
if err != nil {
|
|
return ProcessStats{}, err
|
|
}
|
|
|
|
procStat, err := parseProcStat(string(statData))
|
|
if err != nil {
|
|
return ProcessStats{}, err
|
|
}
|
|
memStat, err := parseProcStatm(string(statmData))
|
|
if err != nil {
|
|
return ProcessStats{}, err
|
|
}
|
|
uptimeSeconds, err := parseProcUptime(string(uptimeData))
|
|
if err != nil {
|
|
return ProcessStats{}, err
|
|
}
|
|
|
|
const ticksPerSecond = 100.0
|
|
elapsedSeconds := uptimeSeconds - (float64(procStat.startTicks) / ticksPerSecond)
|
|
cpuPercent := 0.0
|
|
if elapsedSeconds > 0 {
|
|
totalCPUSeconds := float64(procStat.userTicks+procStat.systemTicks) / ticksPerSecond
|
|
cpuPercent = math.Max(0, (totalCPUSeconds/elapsedSeconds)*100)
|
|
}
|
|
pageSize := int64(os.Getpagesize())
|
|
return ProcessStats{
|
|
CPUPercent: cpuPercent,
|
|
RSSBytes: memStat.residentPages * pageSize,
|
|
VSZBytes: memStat.sizePages * pageSize,
|
|
}, nil
|
|
}
|
|
|
|
func ReadHostResources() (HostResources, error) {
|
|
data, err := os.ReadFile("/proc/meminfo")
|
|
if err != nil {
|
|
return HostResources{}, err
|
|
}
|
|
totalMemoryBytes, err := parseMemTotal(string(data))
|
|
if err != nil {
|
|
return HostResources{}, err
|
|
}
|
|
return HostResources{
|
|
CPUCount: runtime.NumCPU(),
|
|
TotalMemoryBytes: totalMemoryBytes,
|
|
}, nil
|
|
}
|
|
|
|
func ReadFilesystemUsage(path string) (FilesystemUsage, error) {
|
|
if strings.TrimSpace(path) == "" {
|
|
return FilesystemUsage{}, errors.New("filesystem path is required")
|
|
}
|
|
var stat syscall.Statfs_t
|
|
if err := syscall.Statfs(path, &stat); err != nil {
|
|
return FilesystemUsage{}, err
|
|
}
|
|
return FilesystemUsage{
|
|
TotalBytes: int64(stat.Blocks) * int64(stat.Bsize),
|
|
FreeBytes: int64(stat.Bavail) * int64(stat.Bsize),
|
|
}, nil
|
|
}
|
|
|
|
func TailCommand(path string, follow bool) *exec.Cmd {
|
|
if follow {
|
|
return exec.Command("tail", "-f", path)
|
|
}
|
|
return exec.Command("cat", path)
|
|
}
|
|
|
|
func ParseMetricsFile(path string) map[string]any {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil || len(bytes.TrimSpace(data)) == 0 {
|
|
return nil
|
|
}
|
|
raw := bytes.TrimSpace(data)
|
|
var result map[string]any
|
|
if err := json.Unmarshal(raw, &result); err == nil {
|
|
return result
|
|
}
|
|
lastLine := lastJSONLine(raw)
|
|
if lastLine == nil {
|
|
return nil
|
|
}
|
|
if err := json.Unmarshal(lastLine, &result); err != nil {
|
|
return nil
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseMemTotal(data string) (int64, error) {
|
|
scanner := bufio.NewScanner(strings.NewReader(data))
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if !strings.HasPrefix(line, "MemTotal:") {
|
|
continue
|
|
}
|
|
fields := strings.Fields(line)
|
|
if len(fields) < 2 {
|
|
return 0, errors.New("meminfo MemTotal is malformed")
|
|
}
|
|
value, err := strconv.ParseInt(fields[1], 10, 64)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("parse meminfo MemTotal: %w", err)
|
|
}
|
|
unit := "kB"
|
|
if len(fields) >= 3 {
|
|
unit = fields[2]
|
|
}
|
|
switch unit {
|
|
case "kB":
|
|
return value * 1024, nil
|
|
case "mB", "MB":
|
|
return value * 1024 * 1024, nil
|
|
case "gB", "GB":
|
|
return value * 1024 * 1024 * 1024, nil
|
|
default:
|
|
return 0, fmt.Errorf("unsupported meminfo unit %q", unit)
|
|
}
|
|
}
|
|
if err := scanner.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return 0, errors.New("meminfo MemTotal not found")
|
|
}
|
|
|
|
func lastJSONLine(data []byte) []byte {
|
|
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
var last []byte
|
|
for scanner.Scan() {
|
|
line := bytes.TrimSpace(scanner.Bytes())
|
|
if len(line) == 0 {
|
|
continue
|
|
}
|
|
last = append([]byte(nil), line...)
|
|
}
|
|
return last
|
|
}
|
|
|
|
func CopyDirContents(ctx context.Context, runner CommandRunner, sourceDir, targetDir string, useSudo bool) error {
|
|
// Trailing "/." on the source tells cp -a to copy the directory's
|
|
// contents rather than the directory itself. filepath.Join would
|
|
// strip the dot, hence the manual concat.
|
|
args := []string{"-a", strings.TrimRight(sourceDir, "/") + "/.", targetDir + "/"}
|
|
var err error
|
|
if useSudo {
|
|
_, err = runner.RunSudo(ctx, append([]string{"cp"}, args...)...)
|
|
} else {
|
|
_, err = runner.Run(ctx, "cp", args...)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func ResizeExt4Image(ctx context.Context, runner CommandRunner, path string, bytes int64) error {
|
|
if err := os.Truncate(path, bytes); err != nil {
|
|
return err
|
|
}
|
|
if _, err := runner.Run(ctx, "e2fsck", "-p", "-f", path); err != nil {
|
|
return err
|
|
}
|
|
_, err := runner.Run(ctx, "resize2fs", path)
|
|
return err
|
|
}
|
|
|
|
func ReadDebugFSText(ctx context.Context, runner CommandRunner, imagePath, guestPath string) (string, error) {
|
|
out, err := runner.Run(ctx, "debugfs", "-R", "cat "+guestPath, imagePath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(out), nil
|
|
}
|
|
|
|
func WriteExt4File(ctx context.Context, runner CommandRunner, imagePath, guestPath string, data []byte) error {
|
|
return WriteExt4FileMode(ctx, runner, imagePath, guestPath, 0o600, data)
|
|
}
|
|
|
|
func WriteExt4FileMode(ctx context.Context, runner CommandRunner, imagePath, guestPath string, mode os.FileMode, data []byte) error {
|
|
tmp, err := os.CreateTemp("", "banger-ext4-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.Remove(tmp.Name())
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Chmod(tmp.Name(), mode); err != nil {
|
|
return err
|
|
}
|
|
_, _ = runner.RunSudo(ctx, "e2rm", imagePath+":"+guestPath)
|
|
if _, err := runner.RunSudo(ctx, "e2cp", tmp.Name(), imagePath+":"+guestPath); err != nil {
|
|
return err
|
|
}
|
|
if mode.Perm()&0o111 != 0 {
|
|
modeValue := fmt.Sprintf("%#o", uint32(0o100000|mode.Perm()))
|
|
if _, err := runner.RunSudo(ctx, "debugfs", "-w", "-R", "sif "+guestPath+" mode "+modeValue, imagePath); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func MountTempDir(ctx context.Context, runner CommandRunner, source string, readOnly bool) (string, func() error, error) {
|
|
mountDir, err := os.MkdirTemp("", "banger-mnt-*")
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
args := []string{"mount"}
|
|
var opts []string
|
|
if readOnly {
|
|
opts = append(opts, "ro")
|
|
}
|
|
if useLoopMount(source) {
|
|
opts = append(opts, "loop")
|
|
}
|
|
if len(opts) > 0 {
|
|
args = append(args, "-o", strings.Join(opts, ","))
|
|
}
|
|
args = append(args, source, mountDir)
|
|
if _, err := runner.RunSudo(ctx, args...); err != nil {
|
|
_ = os.RemoveAll(mountDir)
|
|
return "", nil, err
|
|
}
|
|
cleanup := func() error {
|
|
_, err := runner.RunSudo(context.Background(), "umount", mountDir)
|
|
_ = os.RemoveAll(mountDir)
|
|
return err
|
|
}
|
|
return mountDir, cleanup, nil
|
|
}
|
|
|
|
func useLoopMount(source string) bool {
|
|
info, err := os.Stat(source)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return info.Mode().IsRegular()
|
|
}
|
|
|
|
func UpdateFSTab(existing string) string {
|
|
lines := strings.Split(existing, "\n")
|
|
var out []string
|
|
hasRoot := false
|
|
hasRun := false
|
|
hasTmp := false
|
|
for _, line := range lines {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" {
|
|
continue
|
|
}
|
|
fields := strings.Fields(trimmed)
|
|
if len(fields) >= 2 {
|
|
if fields[0] == "/dev/vdb" && fields[1] == "/home" {
|
|
continue
|
|
}
|
|
if fields[0] == "/dev/vdc" && fields[1] == "/var" {
|
|
continue
|
|
}
|
|
if fields[0] == "/dev/vdb" && fields[1] == "/root" {
|
|
hasRoot = true
|
|
}
|
|
if fields[0] == "tmpfs" && fields[1] == "/run" {
|
|
hasRun = true
|
|
}
|
|
if fields[0] == "tmpfs" && fields[1] == "/tmp" {
|
|
hasTmp = true
|
|
}
|
|
}
|
|
out = append(out, line)
|
|
}
|
|
if !hasRoot {
|
|
out = append(out, "/dev/vdb /root ext4 defaults 0 2")
|
|
}
|
|
if !hasRun {
|
|
out = append(out, "tmpfs /run tmpfs defaults,nodev,nosuid,mode=0755 0 0")
|
|
}
|
|
if !hasTmp {
|
|
out = append(out, "tmpfs /tmp tmpfs defaults,nodev,nosuid,mode=1777 0 0")
|
|
}
|
|
return strings.Join(out, "\n") + "\n"
|
|
}
|
|
|
|
func BuildBootArgs(vmName string) string {
|
|
return fmt.Sprintf(
|
|
"console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rootfstype=ext4 rw hostname=%s systemd.mask=home.mount systemd.mask=var.mount",
|
|
vmName,
|
|
)
|
|
}
|
|
|
|
func BuildBootArgsWithKernelIP(vmName, guestIP, bridgeIP, dns string) string {
|
|
return fmt.Sprintf(
|
|
"console=ttyS0 reboot=k panic=1 pci=off root=/dev/vda rootfstype=ext4 rw ip=%s::%s:255.255.255.0:%s:eth0:off:%s hostname=%s systemd.mask=home.mount systemd.mask=var.mount",
|
|
guestIP,
|
|
bridgeIP,
|
|
vmName,
|
|
dns,
|
|
vmName,
|
|
)
|
|
}
|
|
|
|
func ShortID(id string) string {
|
|
if len(id) <= 8 {
|
|
return id
|
|
}
|
|
return id[:8]
|
|
}
|
|
|
|
func TouchNow(vm *model.VMRecord) {
|
|
now := model.Now()
|
|
vm.UpdatedAt = now
|
|
vm.LastTouchedAt = now
|
|
}
|
|
|
|
func CopyStream(dst io.Writer, cmd *exec.Cmd) error {
|
|
cmd.Stdout = dst
|
|
cmd.Stderr = os.Stderr
|
|
cmd.Stdin = os.Stdin
|
|
return cmd.Run()
|
|
}
|
|
|
|
type procStat struct {
|
|
userTicks uint64
|
|
systemTicks uint64
|
|
startTicks uint64
|
|
}
|
|
|
|
type procStatm struct {
|
|
sizePages int64
|
|
residentPages int64
|
|
}
|
|
|
|
func parseProcStat(raw string) (procStat, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
end := strings.LastIndex(raw, ")")
|
|
if end == -1 || end+2 >= len(raw) {
|
|
return procStat{}, fmt.Errorf("unexpected /proc stat format: %q", raw)
|
|
}
|
|
fields := strings.Fields(raw[end+2:])
|
|
if len(fields) < 20 {
|
|
return procStat{}, fmt.Errorf("unexpected /proc stat field count: %q", raw)
|
|
}
|
|
userTicks, err := strconv.ParseUint(fields[11], 10, 64)
|
|
if err != nil {
|
|
return procStat{}, err
|
|
}
|
|
systemTicks, err := strconv.ParseUint(fields[12], 10, 64)
|
|
if err != nil {
|
|
return procStat{}, err
|
|
}
|
|
startTicks, err := strconv.ParseUint(fields[19], 10, 64)
|
|
if err != nil {
|
|
return procStat{}, err
|
|
}
|
|
return procStat{
|
|
userTicks: userTicks,
|
|
systemTicks: systemTicks,
|
|
startTicks: startTicks,
|
|
}, nil
|
|
}
|
|
|
|
func parseProcStatm(raw string) (procStatm, error) {
|
|
fields := strings.Fields(strings.TrimSpace(raw))
|
|
if len(fields) < 2 {
|
|
return procStatm{}, fmt.Errorf("unexpected /proc statm format: %q", raw)
|
|
}
|
|
sizePages, err := strconv.ParseInt(fields[0], 10, 64)
|
|
if err != nil {
|
|
return procStatm{}, err
|
|
}
|
|
residentPages, err := strconv.ParseInt(fields[1], 10, 64)
|
|
if err != nil {
|
|
return procStatm{}, err
|
|
}
|
|
return procStatm{sizePages: sizePages, residentPages: residentPages}, nil
|
|
}
|
|
|
|
func parseProcUptime(raw string) (float64, error) {
|
|
fields := strings.Fields(strings.TrimSpace(raw))
|
|
if len(fields) == 0 {
|
|
return 0, fmt.Errorf("unexpected /proc uptime format: %q", raw)
|
|
}
|
|
return strconv.ParseFloat(fields[0], 64)
|
|
}
|