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
10 changes: 2 additions & 8 deletions .github/workflows/kernel.yml
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
name: kernel

# Builds each guest kernel twice and proves the hashes match the ones shard was built with. A PR that
# touches the build runs that much; a manual dispatch also publishes under the tag services/kernel names.
# Builds each guest kernel twice, proves the hashes match the ones shard was built with, and publishes
# under the tag services/kernel names. Manual only: the kernel changes rarely, and each build takes 13 minutes.
on:
workflow_dispatch:
pull_request:
paths:
- Makefile
- packaging/kernel/**
- services/kernel/**
- .github/workflows/kernel.yml

permissions:
contents: read
Expand Down
4 changes: 2 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ coverage.*
# Local Claude Code worktrees, one per in-flight ticket
/.claude/worktrees/

# The shim binary is build output; pkg/vz embeds it from here
/pkg/vz/shim/shard-vz-shim
# The shim binary is build output; pkg/vzshim embeds it from here
/pkg/vzshim/shim/shard-vz-shim
1 change: 1 addition & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ linters:
- $gostd
- github.com/presmihaylov/shard/models
- github.com/presmihaylov/shard/pkg/pty
- github.com/presmihaylov/shard/pkg/vzshim
- github.com/presmihaylov/shard/services/client
- github.com/presmihaylov/shard/services/sandbox
- github.com/presmihaylov/shard/services/daemon
Expand Down
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ make build build ./cmd/shard into bin/shard
make build-linux cross-compile for the box (GOOS=linux GOARCH=amd64)
make build-shard-init build ./cmd/shard-init into bin/shard-init (static, CGO_ENABLED=0)
make build-shard-init-linux cross-compile the supervisor for the box
make build-shard-vz-shim build and ad-hoc sign the VM shim into bin/shard-vz-shim (darwin only)
make build-shard-vz-shim build and ad-hoc sign the VM shim into pkg/vzshim/shim, where the daemon embeds it (darwin only)
make build-darwin the shim, then ./cmd/shard with cgo for this Mac, into bin/shard-darwin-<arch>
make test unit tests; must stay green on macOS
make test-integration integration tests, on this host; Linux box only, needs root
make itest integration tests for ITEST_PKG, on the devbox
Expand Down Expand Up @@ -60,6 +61,8 @@ pkg/registry/ OCI registry transport
pkg/netns/ netns, veth, bridge, NAT rules
pkg/store/ atomic file write, the daemon singleton lock
pkg/proxy/ intercepting HTTP and TLS proxy
pkg/vz/ the Virtualization.framework driver: the shim protocol, its client and its server
pkg/vzshim/ the shim binary embedded in the daemon, installed and ad-hoc signed on first use

services/sandbox/ the orchestrator: the lifecycle verbs the daemon serves
services/image/ pull, unpack, cache policy
Expand Down Expand Up @@ -90,9 +93,9 @@ docs/
a driver and it belongs in `services/`. `depguard` enforces this in CI.
- **Dependencies point one way: `cli` to `services` to `pkg`.** `models` sits
under all of them.
- **`cli/` imports `services/client`, `pkg/pty`, `models`, the request types in
`services/sandbox`, and `services/daemon` and `services/serve` for the two
verbs that are a process rather than a client. Nothing else.** A verb holds no
- **`cli/` imports `services/client`, `pkg/pty`, `pkg/vzshim`, `models`, the request
types in `services/sandbox`, and `services/daemon` and `services/serve` for the
two verbs that are a process rather than a client. Nothing else.** A verb holds no
store and no provider: it asks the socket.
`depguard` enforces the allow list in CI.
- **`models/` is one package with several files, and it is a leaf.** It imports
Expand Down
15 changes: 10 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
BIN := bin/shard
SHARD_INIT_BIN := bin/shard-init
VZ_SHIM_BIN := pkg/vzshim/shim/shard-vz-shim
Comment thread
presmihaylov marked this conversation as resolved.
PKG := github.com/presmihaylov/shard
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X main.version=$(VERSION)
Expand All @@ -19,7 +20,7 @@ ARCH ?= arm64
KERNEL_OUT := bin/kernel
KERNEL_IMAGE := packaging-kernel-builder

.PHONY: all build build-linux build-shard-init build-shard-init-linux build-shard-vz-shim test test-integration e2e-test vet lint lint-fix fmt fmt-check vuln check clean devbox-sync devbox-test itest e2e devbox-e2e devbox-demo kernel kernel-reproducible
.PHONY: all build build-linux build-shard-init build-shard-init-linux build-shard-vz-shim build-darwin test test-integration e2e-test vet lint lint-fix fmt fmt-check vuln check clean devbox-sync devbox-test itest e2e devbox-e2e devbox-demo kernel kernel-reproducible

all: check build

Expand All @@ -38,10 +39,14 @@ build-shard-init:
build-shard-init-linux:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o $(SHARD_INIT_BIN)-linux-amd64 ./cmd/shard-init

# The shim holds one Virtualization.framework VM; darwin only, and unsigned it cannot create one.
# The shim holds one Virtualization.framework VM. It lands where pkg/vzshim embeds it, signed, so a direct run works too.
build-shard-vz-shim:
go build -o bin/shard-vz-shim ./cmd/shard-vz-shim
codesign --sign - --force --entitlements cmd/shard-vz-shim/entitlements.plist bin/shard-vz-shim
go build -o $(VZ_SHIM_BIN) ./cmd/shard-vz-shim
Comment thread
presmihaylov marked this conversation as resolved.
codesign --sign - --force --entitlements pkg/vzshim/shim/entitlements.plist $(VZ_SHIM_BIN)

# The Mac build: cgo over the framework never cross-compiles, and the daemon carries the shim it will install and sign.
build-darwin: build-shard-vz-shim
CGO_ENABLED=1 go build -ldflags "$(LDFLAGS)" -o $(BIN)-darwin-$(shell go env GOARCH) ./cmd/shard
Comment thread
presmihaylov marked this conversation as resolved.

test:
go test ./...
Expand Down Expand Up @@ -111,7 +116,7 @@ vuln:
check: fmt-check vet lint test e2e-test

clean:
rm -rf bin
rm -rf bin $(VZ_SHIM_BIN)

# One guest kernel, built in the pinned amd64 image so the bytes match CI wherever it runs (SHARD-232).
kernel:
Expand Down
8 changes: 7 additions & 1 deletion cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -428,8 +428,14 @@ func (a App) version(ctx context.Context) error {
if err != nil {
return err
}
if err := a.print("daemon " + daemon.Version); err != nil {
return err
}
if line := shimLine(); line != "" {
return a.print(line)
}

return a.print("daemon " + daemon.Version)
return nil
}

// warn reports something the operator should know that is not a reason to fail the command.
Expand Down
3 changes: 2 additions & 1 deletion cli/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ func TestVersionPrintsBothLines(t *testing.T) {
t.Fatalf("version: %v", err)
}

if got := strings.TrimSpace(out.String()); got != "client test\ndaemon v-daemon" {
// A Mac binary adds a third line for the VM shim after these two.
if got := out.String(); !strings.HasPrefix(got, "client test\ndaemon v-daemon\n") {
t.Errorf("version printed %q, want the client line and the daemon line", got)
}
}
Expand Down
12 changes: 12 additions & 0 deletions cli/shim_darwin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package cli

import "github.com/presmihaylov/shard/pkg/vzshim"

// shimLine says whether this binary carries the VM shim, which only make build-darwin puts there.
func shimLine() string {
if vzshim.Embedded() {
Comment thread
presmihaylov marked this conversation as resolved.
return "vz shim embedded"
}

return "vz shim absent: run make build-darwin"
}
6 changes: 6 additions & 0 deletions cli/shim_other.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
//go:build !darwin

package cli

// Only a Mac daemon carries a VM shim, so there is no line to print elsewhere.
func shimLine() string { return "" }
88 changes: 88 additions & 0 deletions docs/macos-signing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Code signing on macOS

Only `shard-vz-shim` needs a signature that shard makes on purpose. Apple's Virtualization.framework
refuses to create a VM for a process without the `com.apple.security.virtualization` entitlement, and
an entitlement is carried by a code signature. The shim is the only process that touches the
framework (`docs/provider-vz.md`), so it is the only binary shard signs. The daemon and the CLI are
one Go binary, `shard`, and it stays as the Go linker leaves it.

## What ships

`make build-darwin` builds the shim, ad-hoc signs it into `pkg/vzshim/shim/`, and then builds `shard`
with the shim embedded. `pkg/vzshim` is its own package, which the daemon links and the shim does
not: a shim that embedded its own previous build would never hash the same twice. `make clean`
removes the built shim too, so a plain `go build` after it carries none and `Install` returns
`ErrNoShim`. The vz provider (SHARD-218) calls `vzshim.Install` before its first boot: it writes
the shim into the shard root and ad-hoc signs it there, with the entitlements plist it also
embeds, in a temporary file it removes after the signature. A stamp beside the shim,
`shard-vz-shim.sha256`, holds the hash of the embedded build, so a later start finds the shim in
place, and a build with a different shim replaces the file by rename, so a running shim keeps
its inode. Concurrent callers each sign a temporary copy of their own and publish it by rename,
so the path never holds a partial file. The install needs `codesign`, which the Command Line
Tools provide; Xcode is not needed.

The installed shim, on a Mac with only the Command Line Tools:

```
$ codesign -d --entitlements - /var/lib/shard/shard-vz-shim
[Dict]
[Key] com.apple.security.virtualization
[Value]
[Bool] true

$ codesign -dvv /var/lib/shard/shard-vz-shim
Format=Mach-O thin (arm64)
CodeDirectory v=20400 size=56975 flags=0x2(adhoc) hashes=1769+7 location=embedded
Signature=adhoc
TeamIdentifier=not set
```

The daemon, for comparison, carries the linker's own ad-hoc signature and no entitlements. Every
arm64 Mach-O needs one to execute at all, and the Go linker adds it:

```
$ codesign -dvv bin/shard-darwin-arm64
CodeDirectory v=20400 size=122014 flags=0x20002(adhoc,linker-signed) hashes=3810+0 location=embedded
Signature=adhoc
```

## What an ad-hoc signature can and cannot do

An ad-hoc signature (`codesign --sign -`) seals the binary and its entitlements with no identity
behind it. On the machine where it was made it is enough: the kernel checks the seal, the framework
finds the entitlement, the VM boots. That is the whole of what shard needs on a developer Mac or a
self-managed Mac mini.

It cannot pass Gatekeeper on another machine as a downloaded app, it cannot be notarized, and it
carries no team identifier, so nothing can be granted to "shard" as a publisher. None of that
matters for a binary a user builds or installs from a package manager and runs from a terminal:
Gatekeeper judges quarantined downloads, not a process the user execs.

The signature is per build: the daemon re-signs the shim whenever the embedded bytes change, and a
shim copied from another Mac keeps working, since the seal does not name the machine.

## What a Developer ID adds

Signing with a Developer ID certificate (`codesign --sign "Developer ID Application: ..."`) and
notarizing the result lets a downloaded `shard` open without a Gatekeeper refusal, ties the binary to
an Apple team, and lets an MDM allow or deny shard by that team rather than by path or hash. It
changes nothing about the entitlement: `com.apple.security.virtualization` is not restricted, so
an ad-hoc and a Developer ID signature carry it the same way. A future release job can sign the
embedded shim and `shard` itself with a Developer ID; `vzshim.Install` then re-signs the extracted
shim ad-hoc, which is still valid, and a hardened build would sign with the identity instead.

## What an MDM block looks like

A managed Mac can forbid virtualization outright. The signal is not shard's: the shim starts, and
the framework refuses the VM with `VZErrorDomain Code=2` (invalid virtual machine configuration) or
the process is denied at `hv_vm_create` with `HV_DENIED`. shard reports that as the shim's exit
with its log tail, for example:

```
shard: create: start the vm: Error Domain=VZErrorDomain Code=2 "The virtual machine configuration is invalid."
```

The profile behind it is a restrictions payload with `allowVirtualMachines` (or an endpoint security
policy that blocks `com.apple.Virtualization.VirtualMachine`, the framework's helper process). There
is no workaround in shard, and there should not be one: the fix is the MDM policy, and the message
names the framework so the owner knows where to look.
2 changes: 1 addition & 1 deletion docs/provider-vz.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ cross-compile from Linux. `make build-darwin` runs on a Mac with the Command Lin
The daemon starts one `shard-vz-shim` process per sandbox, detached, and speaks to it over a unix
socket in the sandbox's state directory. The shim holds the VM; the daemon holds the record. A daemon
restart re-adopts every running sandbox by that socket (SHARD-235), and only the shim carries the
`com.apple.security.virtualization` entitlement (SHARD-214).
`com.apple.security.virtualization` entitlement (SHARD-214, `docs/macos-signing.md`).

This is not only the re-adopt story. **The framework runs at most two VMs in one process.** The
third `start` in a process fails with `VZErrorDomain Code=1, the virtual machine failed to start`,
Expand Down
36 changes: 35 additions & 1 deletion pkg/vz/boot_darwin_arm64_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import (
"syscall"
"testing"
"time"

"github.com/presmihaylov/shard/pkg/vzshim"
)

// The boot tests want the shard kernel; a Mac without one skips them, and SHARD_KERNEL names one elsewhere.
Expand Down Expand Up @@ -53,6 +55,9 @@ func prepare(t *testing.T) fixtures {
}

f := fixtures{kernel: kernel, shim: os.Getenv("SHARD_VZ_SHIM"), initrd: os.Getenv("SHARD_VZ_INITRD")}
if f.shim == "" && vzshim.Embedded() {
f.shim = installShim(t)
}
if f.shim == "" {
f.shim = buildShim(t)
}
Expand All @@ -63,13 +68,25 @@ func prepare(t *testing.T) fixtures {
return f
}

// A test binary built after make build-shard-vz-shim carries the shim, the way make build-darwin's daemon does.
func installShim(t *testing.T) string {
t.Helper()

shim, err := vzshim.Install(t.TempDir())
if err != nil {
t.Fatalf("vzshim.Install: %v", err)
}

return shim
}

// The shim needs the virtualization entitlement, and an ad hoc signature is enough to carry it.
func buildShim(t *testing.T) string {
t.Helper()

shim := filepath.Join(t.TempDir(), "shard-vz-shim")
run(t, "", "go", "build", "-o", shim, "../../cmd/shard-vz-shim")
run(t, "", "codesign", "--sign", "-", "--force", "--entitlements", "../../cmd/shard-vz-shim/entitlements.plist", shim)
run(t, "", "codesign", "--sign", "-", "--force", "--entitlements", "shim/entitlements.plist", shim)

return shim
}
Expand Down Expand Up @@ -460,3 +477,20 @@ func hold() int {
fmt.Println(info.PID)
select {}
}

// The install itself is proven in pkg/vzshim; this is the boot half of the SHARD-214 AC, over the embedded shim.
func TestTheEmbeddedShimBootsAVM(t *testing.T) {
if !vzshim.Embedded() {
t.Skip("this test binary carries no shim: run make build-shard-vz-shim first")
}
f := prepare(t)
f.shim = installShim(t)
client, info := start(t, f.shim, config(t, f))
if pid := guestPID(t, client); pid != 1 {
t.Fatalf("the guest answered as pid %d", pid)
}
if _, err := client.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
awaitExit(t, info.PID)
}
Loading
Loading