package api import ( "encoding/json" "errors" "fmt" "net/http" "os" "path/filepath" "sort" "strconv" "strings" "syscall" "time" "werkdock/internal/engine" "werkdock/internal/instance" "werkdock/internal/store" ) // portBinding is docker's {"HostIp":"","HostPort":"34259"}. type portBinding struct { HostIp string `json:"HostIp"` HostPort string `json:"HostPort"` } // createRequest is the part of docker's container create body werkdock // can honour. Everything else the client sends (and it sends every // field, mostly null) is ignored unless it promises isolation werkdock // does not have, which is refused below. type createRequest struct { Hostname string `json:"Hostname"` User string `json:"User"` Env []string `json:"Env"` Cmd []string `json:"Cmd"` Entrypoint []string `json:"Entrypoint"` Image string `json:"Image"` WorkingDir string `json:"WorkingDir"` Labels map[string]string `json:"Labels"` ExposedPorts map[string]struct{} `json:"ExposedPorts"` Tty bool `json:"Tty"` HostConfig struct { Binds []string `json:"Binds"` PortBindings map[string][]portBinding `json:"PortBindings"` AutoRemove bool `json:"AutoRemove"` NetworkMode string `json:"NetworkMode"` Privileged bool `json:"Privileged"` Tmpfs map[string]string `json:"Tmpfs"` } `json:"HostConfig"` } func (s *Server) containerCreate(w http.ResponseWriter, r *http.Request) { var req createRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid create body: %v", err) return } if req.Image == "" { writeError(w, http.StatusBadRequest, "create needs an Image") return } if req.HostConfig.Privileged { writeError(w, http.StatusBadRequest, "privileged containers are not available: werkdock isolates the filesystem only and has no privileges to grant") return } switch req.HostConfig.NetworkMode { case "", "host", "default", "bridge": // the sandbox has the host's network by contract; the three // docker names a client sends for "just connect me" are taken // as that (RFC 0002). default: writeError(w, http.StatusBadRequest, "network mode %q is not available in an unprivileged sandbox; werkdock shares the host's network", req.HostConfig.NetworkMode) return } ref, meta, err := s.resolveImage(req.Image) if err != nil { writeError(w, http.StatusNotFound, "no such image: %s", req.Image) return } rootfs, err := s.Store.RootFS(ref) if err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } q := quirkFor(ref) ports, portEnv, err := publishPorts(req.ExposedPorts, req.HostConfig.PortBindings, q) if err != nil { writeError(w, http.StatusBadRequest, "%v", err) return } spec, warnings, err := buildSpec(req, rootfs, meta.Config, q, portEnv) if err != nil { writeError(w, http.StatusBadRequest, "%v", err) return } for _, warning := range warnings { s.logf("create %s: %s", ref, warning) } if err := engine.EnsureMountpoints(spec); err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } var mappings []instance.PortMapping for _, p := range ports { mappings = append(mappings, instance.PortMapping{ContainerPort: p.Container, HostPort: p.Host, Protocol: p.Protocol}) } inst, err := s.Manager.Create(instance.Config{ Name: strings.TrimPrefix(r.URL.Query().Get("name"), "/"), Image: ref, Spec: spec, Volumes: anonymousVolumes(meta.Config.Volumes, spec.Mounts), AutoRemove: req.HostConfig.AutoRemove, Labels: req.Labels, Ports: mappings, }) if err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } s.logf("created %s (%s) as uid %d, ports %v", inst.ShortID(), inst.Config.Name, spec.UID, mappings) writeJSON(w, http.StatusCreated, map[string]interface{}{"Id": inst.Config.ID, "Warnings": warnings}) } // buildSpec merges the request with the image configuration the docker // way, and applies the two werkdock corrections: the port environment // the service must bind, and the user an image needs that expects to // drop privileges itself (see quirk). func buildSpec(req createRequest, rootfs string, config store.ImageConfig, q quirk, portEnv map[string]string) (engine.RunSpec, []string, error) { warnings := []string{} spec := engine.RunSpec{RootFS: rootfs, Workdir: req.WorkingDir} for _, bind := range req.HostConfig.Binds { mount, err := parseBind(bind) if err != nil { return engine.RunSpec{}, nil, err } spec.Mounts = append(spec.Mounts, mount) } for dest := range req.HostConfig.Tmpfs { spec.Mounts = append(spec.Mounts, engine.Mount{Mode: engine.MountTmpfs, Dest: dest}) } if fi, err := os.Stat("/etc/resolv.conf"); err == nil && fi.Mode().IsRegular() { spec.Mounts = append([]engine.Mount{{Mode: engine.MountRoBind, Source: "/etc/resolv.conf", Dest: "/etc/resolv.conf"}}, spec.Mounts...) } for _, e := range config.Env { key, value, _ := strings.Cut(e, "=") spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: value}) } for _, e := range req.Env { key, value, _ := strings.Cut(e, "=") spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: value}) } for _, key := range sortedKeys(portEnv) { spec.Env = append(spec.Env, engine.EnvVar{Key: key, Value: portEnv[key]}) } if spec.Workdir == "" { spec.Workdir = config.WorkingDir } entrypoint, cmd := config.Entrypoint, config.Cmd if len(req.Entrypoint) > 0 { entrypoint, cmd = req.Entrypoint, nil } if len(req.Cmd) > 0 { cmd = req.Cmd } spec.Command = append(append([]string{}, entrypoint...), cmd...) if len(spec.Command) == 0 { return engine.RunSpec{}, nil, errors.New("no command: the request has none and the image has no ENTRYPOINT or CMD") } // The user comes from the request, else the image, else the quirk // table. The first two are instructions and must be met; the third // is werkdock's own guess about an image that drops privileges by // itself, so a mismatch is a warning and the sandbox stays at uid 0. user, guessed := req.User, false if user == "" { user = config.User } if user == "" { user, guessed = q.User, true } if user != "" { u, err := engine.LookupUser(rootfs, user) switch { case err == nil: spec.UID, spec.GID, spec.Home = u.UID, u.GID, u.Home case guessed: warnings = append(warnings, fmt.Sprintf("this image usually runs as %q, which it does not define; running as uid 0", user)) default: return engine.RunSpec{}, nil, fmt.Errorf("user %q: %w", user, err) } } return spec, warnings, nil } func sortedKeys(m map[string]string) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Strings(keys) return keys } func parseBind(bind string) (engine.Mount, error) { parts := strings.Split(bind, ":") if len(parts) < 2 { return engine.Mount{}, fmt.Errorf("invalid bind %q: werkdock has no named volumes, a bind needs SRC:DEST", bind) } mount := engine.Mount{Mode: engine.MountBind, Source: parts[0], Dest: parts[1]} if !filepath.IsAbs(mount.Source) { return engine.Mount{}, fmt.Errorf("invalid bind %q: werkdock has no named volumes, the source must be an absolute path", bind) } if len(parts) > 2 { for _, opt := range strings.Split(parts[2], ",") { if opt == "ro" || opt == "readonly" { mount.Mode = engine.MountRoBind } } } return mount, nil } // anonymousVolumes is the image's VOLUME paths that no bind covers. func anonymousVolumes(volumes map[string]struct{}, mounts []engine.Mount) []string { var paths []string for p := range volumes { p = filepath.Clean(p) covered := false for _, m := range mounts { dest := filepath.Clean(m.Dest) if p == dest || strings.HasPrefix(p, strings.TrimSuffix(dest, "/")+"/") { covered = true break } } if !covered { paths = append(paths, p) } } sort.Strings(paths) return paths } // instanceOf resolves the {id} of a container route, answering 404 the // way docker does when it is unknown. func (s *Server) instanceOf(w http.ResponseWriter, r *http.Request) (*instance.Instance, bool) { id := r.PathValue("id") inst, err := s.Manager.Get(id) if err != nil { if errors.Is(err, instance.ErrNotFound) { writeError(w, http.StatusNotFound, "no such container: %s", id) } else { writeError(w, http.StatusInternalServerError, "%v", err) } return nil, false } return inst, true } func (s *Server) containerStart(w http.ResponseWriter, r *http.Request) { inst, ok := s.instanceOf(w, r) if !ok { return } if inst.Running() { w.WriteHeader(http.StatusNotModified) return } executable := s.Executable if executable == "" { self, err := os.Executable() if err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } executable = self } if err := s.Manager.StartDetached(inst, executable); err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) containerStop(w http.ResponseWriter, r *http.Request) { inst, ok := s.instanceOf(w, r) if !ok { return } if !inst.Running() { w.WriteHeader(http.StatusNotModified) return } timeout := time.Duration(intParam(r, "t", 10)) * time.Second if err := s.Manager.Stop(inst, timeout); err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } w.WriteHeader(http.StatusNoContent) } func (s *Server) containerKill(w http.ResponseWriter, r *http.Request) { inst, ok := s.instanceOf(w, r) if !ok { return } sig := syscall.SIGKILL if name := r.URL.Query().Get("signal"); name != "" { parsed, err := parseSignal(name) if err != nil { writeError(w, http.StatusBadRequest, "%v", err) return } sig = parsed } if err := s.Manager.Kill(inst, sig); err != nil { writeError(w, http.StatusConflict, "%v", err) return } w.WriteHeader(http.StatusNoContent) } var signalNames = map[string]syscall.Signal{ "HUP": syscall.SIGHUP, "INT": syscall.SIGINT, "QUIT": syscall.SIGQUIT, "KILL": syscall.SIGKILL, "USR1": syscall.SIGUSR1, "USR2": syscall.SIGUSR2, "TERM": syscall.SIGTERM, } func parseSignal(s string) (syscall.Signal, error) { if n, err := strconv.Atoi(s); err == nil && n > 0 { return syscall.Signal(n), nil } if sig, ok := signalNames[strings.TrimPrefix(strings.ToUpper(s), "SIG")]; ok { return sig, nil } return 0, fmt.Errorf("unknown signal %q", s) } func (s *Server) containerWait(w http.ResponseWriter, r *http.Request) { inst, ok := s.instanceOf(w, r) if !ok { return } code, err := s.Manager.Wait(inst, 0) if err != nil { writeError(w, http.StatusInternalServerError, "%v", err) return } writeJSON(w, http.StatusOK, map[string]interface{}{"StatusCode": code}) } func (s *Server) containerRemove(w http.ResponseWriter, r *http.Request) { inst, ok := s.instanceOf(w, r) if !ok { return } if err := s.Manager.Remove(inst, boolParam(r, "force")); err != nil { writeError(w, http.StatusConflict, "%v", err) return } w.WriteHeader(http.StatusNoContent) }