feat: add deployment foundation and cross-device handoff

This commit is contained in:
2026-09-25 08:49:19 +08:00
parent 8ccb8b7c15
commit e965b0943d
77 changed files with 8018 additions and 0 deletions
+78
View File
@@ -0,0 +1,78 @@
# Restricted Compose policy
`Check(manifest, entrypointBytes)` is a pure, offline policy check. The CLI
`check-package` first calls `appbundle.Verify`, then passes the authenticated
entrypoint snapshot here. It never reopens the entrypoint, runs Compose, reads
`.env`, resolves templates, or contacts a daemon. Direct internal callers must
perform the same integrity/trust check first.
Profile: `isolated-compose-v1`. This is an initial restricted backend profile,
not the finished application's Compose contract. It deliberately rejects public
routing, secrets/config injection, environment settings, healthchecks, dependency
ordering and application-specific privilege exceptions until adapters exist.
Existing YAML packages can still pass `verify-package`; that does not mean they
pass `check-package`. Only JSON entrypoint content is accepted by this policy.
## Exact shape
All fields below are mandatory, all unlisted fields are rejected:
```json
{
"services": {
"api": {
"image": "example/api@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"user": "1000:1000",
"read_only": true,
"cap_drop": ["ALL"],
"security_opt": ["no-new-privileges:true"],
"restart": "no",
"networks": ["backend"],
"volumes": [
{"type": "volume", "source": "data", "target": "/data", "read_only": false}
]
}
},
"networks": {"backend": {"internal": true}},
"volumes": {"data": {}}
}
```
The example digest is synthetic, not an installable release.
- 4 MiB input limit. Shared strict decoder rejects duplicate, case-alias,
unknown, missing and null fields, including typed map values.
- 1–32 services, exactly matching manifest component names and pinned images.
- UID and GID must be canonical positive uint32 decimals, excluding 4294967295.
No root, account-name lookup, interpolation or inherited user defaults.
- Restart must be `no` or `unless-stopped`; root filesystem must be read-only,
all capabilities dropped and privilege escalation disabled.
- Exactly one network: `backend`, with `internal: true`. No default network,
external network, host networking, published port or arbitrary router label.
- At most 128 plain named volumes; every declaration must be mounted exactly
once. Empty volume maps/lists are permitted for stateless services. No external
names, drivers, driver options, bind mounts or cross-service sharing.
- Mount paths must be absolute canonical ASCII paths, at most 240 bytes, with
no root, overlapping mount or system-tree mount. Denied trees: /proc, /sys,
/dev, /etc, /run, /var/run, /bin, /sbin, /usr, /lib, /lib64 (including ancestors).
- No command overrides, hooks, build, include, extends, profile, socket access,
devices or arbitrary privilege additions. Unknown future keys also fail closed.
## What passing does not prove
This check does not authenticate a publisher, validate image contents or mount
destinations inside an image, provision usable volume ownership, reserve resource
names, verify engine/Compose compatibility, limit resource consumption, prove
application readiness, or provide backup/restore. Image defaults and existing
Docker resources must still be validated by future adapters and preflight.
Future execution must bind an explicit instance project name, verify resource
ownership under the host lock, and use the exact checked snapshot without extra
Compose files, ambient overrides or subsequent interpolation. An integrity hash
and this restricted policy are not authorization to run a deployment.
References checked during implementation:
[Compose services](https://docs.docker.com/reference/compose-file/services/),
[Compose networks](https://docs.docker.com/reference/compose-file/networks/),
[Compose config](https://docs.docker.com/reference/cli/docker/compose/config/).
No real Docker/Compose execution has been validated in this batch.
+135
View File
@@ -0,0 +1,135 @@
// Package composepolicy validates a deliberately restricted, offline Compose
// profile. Passing this policy never authorizes execution or proves image safety.
package composepolicy
import (
"bytes"
"errors"
"path"
"regexp"
"strconv"
"strings"
"server-deploy/internal/appbundle"
"server-deploy/internal/wire"
)
const Profile = "isolated-compose-v1"
type document struct {
Services map[string]service `json:"services"`
Networks map[string]network `json:"networks"`
Volumes map[string]struct{} `json:"volumes"`
}
type network struct {
Internal bool `json:"internal"`
}
type service struct {
Image string `json:"image"`
User string `json:"user"`
ReadOnly bool `json:"read_only"`
CapDrop []string `json:"cap_drop"`
SecurityOpt []string `json:"security_opt"`
Restart string `json:"restart"`
Networks []string `json:"networks"`
Volumes []mount `json:"volumes"`
}
type mount struct {
Type string `json:"type"`
Source string `json:"source"`
Target string `json:"target"`
ReadOnly bool `json:"read_only"`
}
var identifier = regexp.MustCompile(`^[a-z][a-z0-9-]{0,47}$`)
var pinnedImage = regexp.MustCompile(`^[a-z0-9][a-z0-9._/-]*@sha256:[0-9a-f]{64}$`)
var targetPath = regexp.MustCompile(`^/[a-zA-Z0-9_./-]+$`)
// Check consumes only the authenticated entrypoint bytes and manifest supplied
// by appbundle.Verify. It neither reads files nor renders/interpolates templates.
// All fields in the profile are mandatory; unknown Compose features fail closed.
func Check(manifest appbundle.Manifest, data []byte) error {
reject := errors.New("Compose document rejected by restricted policy")
var d document
if wire.Decode(bytes.NewReader(data), &d, 4<<20) != nil {
return reject
}
if len(manifest.Components) < 1 || len(manifest.Components) > 32 || len(d.Services) != len(manifest.Components) {
return reject
}
if len(d.Networks) != 1 || !d.Networks["backend"].Internal || len(d.Volumes) > 128 {
return reject
}
images := make(map[string]string, len(manifest.Components))
for _, c := range manifest.Components {
if !identifier.MatchString(c.Name) || !pinnedImage.MatchString(c.Image) || images[c.Name] != "" {
return reject
}
images[c.Name] = c.Image
}
for name := range d.Volumes {
if !identifier.MatchString(name) {
return reject
}
}
used := make(map[string]bool)
for name, s := range d.Services {
if images[name] == "" || s.Image != images[name] || !nonRootUser(s.User) || !s.ReadOnly {
return reject
}
if !only(s.CapDrop, "ALL") || !only(s.SecurityOpt, "no-new-privileges:true") || !only(s.Networks, "backend") {
return reject
}
if s.Restart != "unless-stopped" && s.Restart != "no" {
return reject
}
if len(s.Volumes) > 128 {
return reject
}
targets := make([]string, 0, len(s.Volumes))
for _, v := range s.Volumes {
if _, exists := d.Volumes[v.Source]; !exists || used[v.Source] || v.Type != "volume" {
return reject
}
if len(v.Target) > 240 || !targetPath.MatchString(v.Target) || path.Clean(v.Target) != v.Target || v.Target == "/" {
return reject
}
// Deny runtime/system trees as well as overlapping mounts. Only
// application-data destinations belong in this initial profile.
for _, protected := range []string{"/proc", "/sys", "/dev", "/etc", "/run", "/var/run", "/bin", "/sbin", "/usr", "/lib", "/lib64"} {
if overlaps(v.Target, protected) {
return reject
}
}
for _, previous := range targets {
if overlaps(v.Target, previous) {
return reject
}
}
targets = append(targets, v.Target)
used[v.Source] = true
}
}
if len(used) != len(d.Volumes) {
return reject
}
return nil
}
func only(values []string, expected string) bool { return len(values) == 1 && values[0] == expected }
func overlaps(a, b string) bool {
return a == b || strings.HasPrefix(a, b+"/") || strings.HasPrefix(b, a+"/")
}
func nonRootUser(value string) bool {
parts := strings.Split(value, ":")
if len(parts) != 2 {
return false
}
for _, part := range parts {
n, err := strconv.ParseUint(part, 10, 32)
if err != nil || n == 0 || n == 4294967295 || strconv.FormatUint(n, 10) != part {
return false
}
}
return true
}
+161
View File
@@ -0,0 +1,161 @@
package composepolicy
import (
"encoding/json"
"strings"
"testing"
"server-deploy/internal/appbundle"
)
func fixture() (appbundle.Manifest, map[string]any) {
image := "example/api@sha256:" + strings.Repeat("a", 64)
m := appbundle.Manifest{Components: []appbundle.Component{{Name: "api", Image: image}}}
d := map[string]any{
"services": map[string]any{"api": map[string]any{
"image": image, "user": "1000:1000", "read_only": true,
"cap_drop": []string{"ALL"}, "security_opt": []string{"no-new-privileges:true"},
"restart": "unless-stopped", "networks": []string{"backend"},
"volumes": []any{map[string]any{"type": "volume", "source": "data", "target": "/data", "read_only": false}},
}},
"networks": map[string]any{"backend": map[string]any{"internal": true}},
"volumes": map[string]any{"data": map[string]any{}},
}
return m, d
}
func TestAcceptRestrictedService(t *testing.T) {
m, d := fixture()
raw, _ := json.Marshal(d)
if err := Check(m, raw); err != nil {
t.Fatal(err)
}
}
func TestRejectPrivilegeAndExternalInputs(t *testing.T) {
for _, field := range []string{"privileged", "build", "container_name", "network_mode", "pid", "ipc", "userns_mode", "devices", "cap_add", "env_file", "environment", "extends", "ports", "labels", "use_api_socket", "post_start", "pre_stop", "command", "entrypoint", "volumes_from", "develop", "provider"} {
t.Run(field, func(t *testing.T) {
m, d := fixture()
d["services"].(map[string]any)["api"].(map[string]any)[field] = "secret-sentinel"
raw, _ := json.Marshal(d)
err := Check(m, raw)
if err == nil || strings.Contains(err.Error(), "secret-sentinel") {
t.Fatal("forbidden field accepted or echoed")
}
})
}
}
func TestRejectUnsafeValuesAndReferences(t *testing.T) {
mutations := map[string]func(map[string]any, map[string]any){
"image drift": func(d, s map[string]any) { s["image"] = "example/api:latest" },
"root": func(d, s map[string]any) { s["user"] = "0:0" },
"named user": func(d, s map[string]any) { s["user"] = "root" },
"writable root": func(d, s map[string]any) { s["read_only"] = false },
"caps": func(d, s map[string]any) { s["cap_drop"] = []string{} },
"escalation": func(d, s map[string]any) { s["security_opt"] = []string{"seccomp:unconfined"} },
"implicit network": func(d, s map[string]any) { s["networks"] = []string{} },
"other network": func(d, s map[string]any) { s["networks"] = []string{"default"} },
"external network": func(d, s map[string]any) {
d["networks"] = map[string]any{"backend": map[string]any{"internal": true, "external": true}}
},
"outbound network": func(d, s map[string]any) {
d["networks"] = map[string]any{"backend": map[string]any{"internal": false}}
},
"bind": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["type"] = "bind" },
"host source": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["source"] = "/var/run/docker.sock" },
"missing volume": func(d, s map[string]any) { d["volumes"] = map[string]any{} },
"volume driver": func(d, s map[string]any) {
d["volumes"] = map[string]any{"data": map[string]any{"driver_opts": map[string]string{"device": "/"}}}
},
"root mount": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/" },
"traversal": func(d, s map[string]any) { s["volumes"].([]any)[0].(map[string]any)["target"] = "/data/../etc" },
"interpolation": func(d, s map[string]any) { s["user"] = "${UID}:1000" },
"include": func(d, s map[string]any) { d["include"] = []string{"/secret"} },
"null service": func(d, s map[string]any) { d["services"].(map[string]any)["api"] = nil },
"missing security": func(d, s map[string]any) { delete(s, "security_opt") },
"null volume": func(d, s map[string]any) { d["volumes"] = map[string]any{"data": nil} },
"extra service": func(d, s map[string]any) { d["services"].(map[string]any)["rogue"] = s },
"no services": func(d, s map[string]any) { d["services"] = map[string]any{} },
}
for name, mutate := range mutations {
t.Run(name, func(t *testing.T) {
m, d := fixture()
s := d["services"].(map[string]any)["api"].(map[string]any)
mutate(d, s)
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Fatal("unsafe document accepted")
}
})
}
}
func TestRejectMalformedDocument(t *testing.T) {
m, d := fixture()
raw, _ := json.Marshal(d)
for _, input := range []string{"services: {}", string(raw) + "{}", strings.Replace(string(raw), `"read_only":true`, `"read_only":true,"read_only":false`, 1), strings.Repeat(" ", 4<<20) + string(raw)} {
if Check(m, []byte(input)) == nil {
t.Fatal("malformed input accepted")
}
}
}
func TestMountAndIdentityBoundaries(t *testing.T) {
for _, target := range []string{"/proc/x", "/var", "/var/run/docker.sock", "/sys", "/dev", "/etc", "/run", "/usr/local", "/lib", "/lib64", "/bin", "/sbin", "/data/", "/data//x", "/data/$HOME", `C:\data`, "/" + strings.Repeat("a", 241)} {
m, d := fixture()
s := d["services"].(map[string]any)["api"].(map[string]any)
s["volumes"].([]any)[0].(map[string]any)["target"] = target
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Errorf("accepted protected/noncanonical target %q", target)
}
}
for _, user := range []string{"1000:0", "01:1000", "+1:1000", "-1:1000", "4294967295:1000", "4294967296:1000", "1", "1:2:3"} {
m, d := fixture()
d["services"].(map[string]any)["api"].(map[string]any)["user"] = user
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Errorf("accepted ambiguous/root identity %q", user)
}
}
}
func TestTwoServicesRequireExactManifestAndSeparateVolumes(t *testing.T) {
m, d := fixture()
raw, _ := json.Marshal(d)
var copyDoc map[string]any
if err := json.Unmarshal(raw, &copyDoc); err != nil {
t.Fatal(err)
}
second := copyDoc["services"].(map[string]any)["api"].(map[string]any)
second["volumes"].([]any)[0].(map[string]any)["source"] = "db-data"
d["services"].(map[string]any)["db"] = second
d["volumes"].(map[string]any)["db-data"] = map[string]any{}
m.Components = append(m.Components, appbundle.Component{Name: "db", Image: m.Components[0].Image})
raw, _ = json.Marshal(d)
if err := Check(m, raw); err != nil {
t.Fatal("valid separate-service volumes rejected", err)
}
second["volumes"].([]any)[0].(map[string]any)["source"] = "data"
delete(d["volumes"].(map[string]any), "db-data")
raw, _ = json.Marshal(d)
if Check(m, raw) == nil {
t.Fatal("shared service data accepted")
}
}
func TestOverlappingAndUnusedVolumesRejected(t *testing.T) {
for _, target := range []string{"/data", "/data/nested", "/other"} {
m, d := fixture()
d["volumes"].(map[string]any)["second"] = map[string]any{}
if target != "/other" {
s := d["services"].(map[string]any)["api"].(map[string]any)
s["volumes"] = append(s["volumes"].([]any), map[string]any{"type": "volume", "source": "second", "target": target, "read_only": false})
}
raw, _ := json.Marshal(d)
if Check(m, raw) == nil {
t.Fatal("overlapping or unused volume accepted")
}
}
}