361 lines
11 KiB
Go
361 lines
11 KiB
Go
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)
|
|
}
|