410a131cec
This session (frontend focus):
- Rebuild /apps/new as a 4-step wizard (Basics → Configure → Trigger → Review):
WizardRail, SourceKindPicker card grid, AppManifest review, per-step validation,
ConfirmDialog-based unsaved-changes guard.
- Extract lib/workload/sourceForms.ts (single source of truth for source_config)
+ {Image,Compose,Static,Dockerfile}SourceForm + StaticDiscoveryWizard; fold the
/apps/[id] edit form onto the same components (removes the duplication). Add
vitest + sourceForms unit tests.
- Branch preview environments UI: /chain is_preview/preview_branch + a Preview
environments panel on /apps/[id] (per-branch URLs, ConfirmDialog teardown, armed
state); RegistryImagePicker on the registry trigger and the image source.
- Fixes: image-inspect 404 -> admin-gated POST /api/discovery/image/inspect;
conflict-panel blur flicker; friendly localized discovery errors; CPU/Memory
label hints; dashboard + /apps "Total workloads" count only source_kind workloads
(drop stale trigger_kind gate); NPM cert/access-list name cache; EntityPicker
empty-list guard.
- Update CLAUDE.md frontend conventions + add a Build & Test section.
Also captures pre-existing in-progress platform work (not from this session):
workload notifications, Prometheus metrics export, store lockfile, health probes,
backup hardening, and related store/webhook/scheduler changes.
193 lines
7.3 KiB
Go
193 lines
7.3 KiB
Go
package stack
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// ComposeSpec is a minimal, lenient representation of a compose file.
|
|
// We only decode fields we need for validation + label-based proxy routing;
|
|
// everything else is preserved as-is and passed to `docker compose`.
|
|
type ComposeSpec struct {
|
|
Version string `yaml:"version,omitempty"`
|
|
Services map[string]ServiceSpec `yaml:"services"`
|
|
}
|
|
|
|
// ServiceSpec captures the subset of compose service fields we inspect.
|
|
//
|
|
// All host-escape-adjacent fields are decoded here even though Tinyforge
|
|
// itself never reads them at runtime — surfacing them to Validate() is the
|
|
// only way to *reject* them. Add new fields here when blocking a new
|
|
// escape vector.
|
|
type ServiceSpec struct {
|
|
Image string `yaml:"image,omitempty"`
|
|
Build any `yaml:"build,omitempty"` // banned — see Validate
|
|
Ports []any `yaml:"ports,omitempty"`
|
|
Labels map[string]string `yaml:"labels,omitempty"`
|
|
Privileged bool `yaml:"privileged,omitempty"`
|
|
Volumes []any `yaml:"volumes,omitempty"`
|
|
NetworkMode string `yaml:"network_mode,omitempty"`
|
|
Pid string `yaml:"pid,omitempty"`
|
|
Ipc string `yaml:"ipc,omitempty"`
|
|
UsernsMode string `yaml:"userns_mode,omitempty"`
|
|
CapAdd []string `yaml:"cap_add,omitempty"`
|
|
Devices []any `yaml:"devices,omitempty"`
|
|
SecurityOpt []string `yaml:"security_opt,omitempty"`
|
|
}
|
|
|
|
// Parse decodes YAML into a ComposeSpec. Returns a descriptive error on failure.
|
|
func Parse(yamlText string) (ComposeSpec, error) {
|
|
var spec ComposeSpec
|
|
if err := yaml.Unmarshal([]byte(yamlText), &spec); err != nil {
|
|
return ComposeSpec{}, fmt.Errorf("invalid yaml: %w", err)
|
|
}
|
|
if len(spec.Services) == 0 {
|
|
return ComposeSpec{}, fmt.Errorf("compose file has no services")
|
|
}
|
|
return spec, nil
|
|
}
|
|
|
|
// Validate enforces Tinyforge-level constraints beyond compose schema validity.
|
|
// All blocked fields below are documented host-escape vectors: any one of
|
|
// them on its own gives the container root on the host. Tinyforge already
|
|
// owns the docker socket, so the threat model is "any admin == host root,"
|
|
// and these blocks raise the bar for any *future* viewer-to-admin
|
|
// escalation as well as honest-mistake guardrails.
|
|
//
|
|
// Current rules:
|
|
// - No service may set `privileged: true`.
|
|
// - Every service must declare an image (build contexts disallowed).
|
|
// - No host-IPC / host-PID / host-userns / host networking.
|
|
// - No `cap_add`, `security_opt`, `devices`.
|
|
// - `volumes` may not bind-mount the docker socket, /, /etc, /var, /proc,
|
|
// /sys, /root, or /home — list is conservative; operators with real
|
|
// bind-mount needs should ship a Source plugin or a dedicated wizard.
|
|
func Validate(spec ComposeSpec) error {
|
|
for name, svc := range spec.Services {
|
|
if svc.Privileged {
|
|
return fmt.Errorf("service %q: privileged mode is not allowed", name)
|
|
}
|
|
if svc.Image == "" {
|
|
return fmt.Errorf("service %q: image is required (build contexts not supported)", name)
|
|
}
|
|
if svc.Build != nil {
|
|
return fmt.Errorf("service %q: build: is not supported (use image:)", name)
|
|
}
|
|
if isBlockedNamespaceMode(svc.NetworkMode) {
|
|
return fmt.Errorf("service %q: network_mode %q is not allowed", name, svc.NetworkMode)
|
|
}
|
|
if isBlockedNamespaceMode(svc.Pid) {
|
|
return fmt.Errorf("service %q: pid: %q is not allowed", name, svc.Pid)
|
|
}
|
|
if isBlockedNamespaceMode(svc.Ipc) {
|
|
return fmt.Errorf("service %q: ipc: %q is not allowed", name, svc.Ipc)
|
|
}
|
|
if isHostMode(svc.UsernsMode) {
|
|
return fmt.Errorf("service %q: userns_mode %q is not allowed", name, svc.UsernsMode)
|
|
}
|
|
if len(svc.CapAdd) > 0 {
|
|
return fmt.Errorf("service %q: cap_add is not allowed", name)
|
|
}
|
|
if len(svc.SecurityOpt) > 0 {
|
|
return fmt.Errorf("service %q: security_opt is not allowed", name)
|
|
}
|
|
if len(svc.Devices) > 0 {
|
|
return fmt.Errorf("service %q: devices is not allowed", name)
|
|
}
|
|
for _, v := range svc.Volumes {
|
|
if host, ok := bindMountHostPath(v); ok {
|
|
if isBlockedBindMount(host) {
|
|
return fmt.Errorf("service %q: bind-mounting %q is not allowed", name, host)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isHostMode reports a host-namespace share, i.e. network_mode / pid / ipc /
|
|
// userns_mode set to "host". (It deliberately does NOT match "host-gateway",
|
|
// which is an extra_hosts value, not a namespace mode — matching it here only
|
|
// produced misleading rejections.)
|
|
func isHostMode(v string) bool {
|
|
return v == "host"
|
|
}
|
|
|
|
// isBlockedNamespaceMode reports a namespace mode that must be rejected for
|
|
// network_mode / pid / ipc: either host sharing ("host") or joining another
|
|
// container's / compose service's namespace ("container:<id>",
|
|
// "service:<name>"). The container/service joins are a lateral-movement and
|
|
// sandbox-escape vector — a malicious service could attach to a victim
|
|
// container's network or PID namespace.
|
|
func isBlockedNamespaceMode(v string) bool {
|
|
return isHostMode(v) ||
|
|
strings.HasPrefix(v, "container:") ||
|
|
strings.HasPrefix(v, "service:")
|
|
}
|
|
|
|
// bindMountHostPath extracts the host-side path from a compose volume
|
|
// declaration. Compose accepts two shapes: a short string "src:dst[:mode]"
|
|
// and a long form map with a "source" key. Returns ok=false for named
|
|
// volumes (no host source).
|
|
func bindMountHostPath(v any) (string, bool) {
|
|
switch t := v.(type) {
|
|
case string:
|
|
// "named:/in/container" has no '/' or '.' prefix on the source.
|
|
if t == "" {
|
|
return "", false
|
|
}
|
|
parts := strings.SplitN(t, ":", 3)
|
|
src := parts[0]
|
|
if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
|
|
return src, true
|
|
}
|
|
return "", false
|
|
case map[string]any:
|
|
if typ, _ := t["type"].(string); typ != "" && typ != "bind" {
|
|
return "", false
|
|
}
|
|
if src, ok := t["source"].(string); ok {
|
|
if strings.HasPrefix(src, "/") || strings.HasPrefix(src, ".") || strings.HasPrefix(src, "~") {
|
|
return src, true
|
|
}
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// isBlockedBindMount returns true for paths that obviously escape the
|
|
// container's intended sandbox. Conservative deny-list — operators with
|
|
// legitimate bind-mount needs should write a dedicated Source plugin
|
|
// rather than tunnel them through compose.
|
|
func isBlockedBindMount(host string) bool {
|
|
// Normalize trailing slash so "/var" and "/var/" both match.
|
|
clean := strings.TrimRight(host, "/")
|
|
if clean == "" || clean == "/" {
|
|
return true
|
|
}
|
|
// Relative ("./x", "../x", ".") and home-relative ("~/...") sources are
|
|
// resolved by Docker against the compose working directory (which
|
|
// Tinyforge controls and never intends as a host-bind source) or left
|
|
// unexpanded — and "../" can climb out of that directory entirely. The
|
|
// absolute-prefix deny-list below can't see these, so reject them
|
|
// outright rather than give a false sense of coverage.
|
|
if strings.HasPrefix(clean, ".") || strings.HasPrefix(clean, "~") {
|
|
return true
|
|
}
|
|
// Specific blocked files / sockets.
|
|
switch clean {
|
|
case "/var/run/docker.sock", "/run/docker.sock":
|
|
return true
|
|
}
|
|
// Blocked prefixes (cover sub-paths too).
|
|
blocked := []string{"/etc", "/var", "/proc", "/sys", "/root", "/home", "/boot", "/dev"}
|
|
for _, p := range blocked {
|
|
if clean == p || strings.HasPrefix(clean, p+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|