package api import ( "bufio" "bytes" "context" "crypto/aes" "crypto/cipher" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/binary" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "net" "net/http" "net/http/httputil" "net/url" "os" "path/filepath" "regexp" "strings" "sync" "syscall" "time" "git.misaka.ren/m1saka/dsh-web/internal/docker" "git.misaka.ren/m1saka/dsh-web/internal/store" ) var runtimeImageID = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`) var runtimeToken = regexp.MustCompile(`(?i)[?&]token=([^\s&#\x1b"'<>]+)`) var runtimeStartupToken = regexp.MustCompile(`(?im)^(?:dsh web:\s*)?https?://[^\s]*[?&]token=([^\s&#\x1b"'<>]+)`) var runtimeSecrets = regexp.MustCompile(`(?i)((?:token|api[_-]?key|authorization|password|secret|cookie)\s*[=:]\s*)[^\r\n]+`) func runtimeNetwork(id string) string { return "dsh-inst-" + id } func (a *API) instances(w http.ResponseWriter, r *http.Request) { u, _, err := a.user(r) if err != nil { fail(w, 401, "authentication required") return } if r.Method == http.MethodGet { instances, err := a.s.Instances(u.ID) if err != nil { fail(w, 500, "database error") return } if instances == nil { instances = []store.Instance{} } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() for n := range instances { if instances[n].ContainerID == "" { continue } c, err := a.docker.Inspect(ctx, instances[n].ContainerID) if err == nil { if instances[n].Status != "error" { instances[n].Status = c.State.Status } } else { instances[n].Status = "unavailable" } } writeJSON(w, instances) return } if r.Method != http.MethodPost { fail(w, 405, "method not allowed") return } var in struct { Name string `json:"name"` ImageID string `json:"image_id"` } decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)) decoder.DisallowUnknownFields() if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || strings.TrimSpace(in.Name) == "" || len(in.Name) > 200 || !runtimeImageID.MatchString(in.ImageID) { fail(w, 400, "invalid instance") return } ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() var image struct { ID string `json:"Id"` Config struct{ Labels map[string]string } } if err := a.docker.Do(ctx, "GET", "/images/"+url.PathEscape(in.ImageID)+"/json", nil, &image); err != nil { fail(w, 400, "local runtime image required") return } if image.ID != in.ImageID || image.Config.Labels["dsh-mgr.role"] != "runtime" || image.Config.Labels["dsh-mgr.version"] == "" { fail(w, 400, "local runtime image required") return } verified, err := a.verifiedImages(ctx) if err != nil { fail(w, 500, "database error") return } if verified[image.ID] != image.Config.Labels["dsh-mgr.version"] { fail(w, 400, "verified runtime image required") return } i := store.Instance{ID: store.ID(), UID: u.ID, Name: strings.TrimSpace(in.Name), ImageID: image.ID, Version: image.Config.Labels["dsh-mgr.version"], Status: "creating", Created: time.Now()} if err := a.s.ReserveInstance(i); err != nil { if errors.Is(err, store.ErrInstanceLimit) { fail(w, 409, err.Error()) return } fail(w, 500, "database error") return } created, err := a.createRuntime(ctx, u, i, a.runtimeHost(r)) if err != nil { fail(w, 502, err.Error()) return } writeJSON(w, created) } func (a *API) createRuntime(ctx context.Context, u store.User, i store.Instance, trustedHost string) (store.Instance, error) { // The instance row is visible to its owner while creating, so hold the // instance lock for the whole creation: a concurrent DELETE or file lease // then waits instead of interleaving with container setup. Concurrent // creations of different instances remain independent. inst := a.instanceLocks.Acquire(i.ID) defer inst.Release() if _, err := a.s.GetInstance(i.ID); err != nil { return i, errors.New("instance reservation unavailable") } networkCreated, networkAttempted, containerAttempted, complete := false, false, false, false defer func() { if complete { return } cleanup, done := context.WithTimeout(context.Background(), 20*time.Second) defer done() if networkAttempted && !networkCreated { if err := a.recoverRuntimeNetwork(cleanup, i.ID); err != nil { a.s.SetInstance(i.ID, "error") return } } if containerAttempted && i.ContainerID == "" { if err := a.recoverRuntimeContainer(cleanup, &i); err != nil { a.s.SetInstance(i.ID, "error") return } } if i.ContainerID != "" { if err := a.removeRuntimeContainer(cleanup, i.ContainerID); err != nil { a.s.SetInstance(i.ID, "error") return } } if networkCreated { if err := a.removeRuntimeNetwork(cleanup, i.ID); err != nil { a.s.SetInstance(i.ID, "error") return } } if err := a.removeInstanceData(i); err != nil { a.s.SetInstance(i.ID, "error") return } a.s.DeleteInstance(i.ID) _, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID) _, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID) }() admissionMu.RLock() defer admissionMu.RUnlock() buildQueue.Lock() policyErr := a.ensureBuilds() buildQueue.Unlock() if policyErr != nil { return i, policyErr } policy, policyErr := a.evaluateImage(ctx, i.ImageID) if policyErr != nil { return i, policyErr } if !policy.Selectable || policy.Version != i.Version { return i, errors.New("image is not admitted for creation") } root, err := filepath.Abs(a.root) if err != nil { return i, errors.New("storage unavailable") } suffix := filepath.Join("workspaces", u.ID) for _, relative := range []string{filepath.Join(suffix, "instances", i.ID, "home")} { dir := filepath.Join(root, relative) if err := os.MkdirAll(dir, 0750); err != nil { return i, errors.New("storage unavailable") } if err := os.Chown(dir, 1000, 1000); err != nil { return i, errors.New("storage ownership unavailable") } } hostRoot := os.Getenv("HOST_DATA_ROOT") if hostRoot == "" { hostRoot = root } if !filepath.IsAbs(hostRoot) { return i, errors.New("HOST_DATA_ROOT must be absolute") } homeSource, err := toHostPath(root, hostRoot, filepath.Join(root, suffix, "instances", i.ID, "home")) if err != nil { return i, err } network := runtimeNetwork(i.ID) if err := a.setRuntimeCreatePending(i.ID, "network_pending", true); err != nil { return i, err } networkAttempted = true var createdNetwork struct { ID string `json:"Id"` } if err := a.docker.Do(ctx, "POST", "/networks/create", map[string]any{"Name": network, "Driver": "bridge", "CheckDuplicate": true, "Labels": map[string]string{"dsh-mgr.instance": i.ID}}, &createdNetwork); err != nil { if dockerCreateRejected(err) { if clearErr := a.setRuntimeCreatePending(i.ID, "network_pending", false); clearErr == nil { networkAttempted = false } } return i, errors.New("network creation failed") } networkCreated = true if createdNetwork.ID != "" { if _, err := a.s.DB.ExecContext(ctx, "INSERT INTO runtime_networks(instance_id,network_id) VALUES(?,?)", i.ID, createdNetwork.ID); err != nil { return i, err } } if err := a.setRuntimeCreatePending(i.ID, "network_pending", false); err != nil { return i, err } if panel := os.Getenv("PANEL_CONTAINER_ID"); panel != "" { if err := a.docker.Do(ctx, "POST", "/networks/"+network+"/connect", map[string]string{"Container": panel}, nil); err != nil { return i, errors.New("panel network connection failed") } } host := trustedHost body := map[string]any{ "Image": i.ImageID, "User": "1000:1000", "WorkingDir": "/workspace", "Env": []string{"DSH_HOME=/workspace", "HOME=/workspace", "XDG_DATA_HOME=/workspace/.local/share", "XDG_CONFIG_HOME=/workspace/.config"}, "Cmd": []string{"web", "--no-open", "--port", "3080", "--trusted-host", host}, "Labels": map[string]string{"dsh-mgr.instance": i.ID, "dsh-mgr.owner": u.ID, "dsh-mgr.role": "dsh", "dsh-mgr.version": i.Version}, "HostConfig": map[string]any{ "NetworkMode": network, "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges"}, "Memory": int64(2 << 30), "NanoCpus": int64(2e9), "PidsLimit": 256, "Tmpfs": map[string]string{"/tmp": "rw,exec,nosuid,nodev,size=256m"}, "LogConfig": map[string]any{"Type": "json-file", "Config": map[string]string{"max-size": "10m", "max-file": "3"}}, "Mounts": []map[string]any{ {"Type": "bind", "Source": homeSource, "Target": "/workspace"}, // DSH_HOME is the same bind mount as the visible workspace. }, }, } var created struct { ID string `json:"Id"` } if err := a.setRuntimeCreatePending(i.ID, "container_pending", true); err != nil { return i, err } containerAttempted = true if err := a.docker.Do(ctx, "POST", "/containers/create?name="+network, body, &created); err != nil || created.ID == "" { if dockerCreateRejected(err) { if clearErr := a.setRuntimeCreatePending(i.ID, "container_pending", false); clearErr == nil { containerAttempted = false } } return i, errors.New("container creation failed") } i.ContainerID = created.ID if err := a.setRuntimeCreatePending(i.ID, "container_pending", false); err != nil { return i, err } if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET container_id=? WHERE id=?", i.ContainerID, i.ID); err != nil { return i, errors.New("database error") } if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/start", nil, nil); err != nil { return i, errors.New("container start failed") } i.Status = "running" if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", i.Status, i.ID); err != nil { return i, errors.New("database error") } complete = true _, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=? AND container_pending=0 AND network_pending=0", i.ID) a.s.AddAudit(u.ID, "instance.create", i.ID, i.ImageID) return i, nil } func dockerNotFound(err error) bool { var e *docker.APIError return errors.As(err, &e) && e.StatusCode == 404 } func dockerCreateRejected(err error) bool { var e *docker.APIError return errors.As(err, &e) && e.StatusCode >= 400 && e.StatusCode < 500 && e.StatusCode != 408 && e.StatusCode != 409 } func (a *API) removeRuntimeContainer(ctx context.Context, id string) error { err := a.docker.Do(ctx, "DELETE", "/containers/"+url.PathEscape(id)+"?force=1", nil, nil) if dockerNotFound(err) { return nil } return err } func (a *API) removeRuntimeNetwork(ctx context.Context, id string) error { return a.recoverRuntimeNetwork(ctx, id) } func (a *API) instance(w http.ResponseWriter, r *http.Request) { u, _, err := a.user(r) if err != nil { fail(w, 401, "authentication required") return } p := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/instances/"), "/") if len(p) > 2 || p[0] == "" { fail(w, 404, "not found") return } i, err := a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") return } action := "" if len(p) == 2 { action = p[1] } switch action { case "profile-preview": a.profilePreview(w, r, i) return case "profile", "backups", "restore", "snapshots": a.config(w, r, i, action) return case "upgrade": a.upgrade(w, r, i) return } w, flush := deferJSONResponse(w) defer flush() ctx, cancel := context.WithTimeout(r.Context(), 40*time.Second) defer cancel() if action == "logs" { if r.Method != "GET" { fail(w, 405, "method not allowed") return } logs, err := a.docker.Logs(ctx, i.ContainerID) if err != nil { fail(w, 502, "logs unavailable") return } writeJSON(w, map[string]string{"logs": redactRuntimeLogs(logs)}) return } if action == "" && r.Method == "GET" { c, err := a.docker.Inspect(ctx, i.ContainerID) if err != nil { fail(w, 502, "container unavailable") return } if i.Status != "error" { i.Status = c.State.Status } writeJSON(w, i) return } if action == "" && r.Method == "DELETE" { inst := a.instanceLocks.Acquire(p[0]) defer inst.Release() i, err = a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") return } if a.upgradeRecoveryRequired(i) { fail(w, 409, "instance requires recovery") return } a.readersMu.Lock() readers := a.workspaceReaders[i.ID] a.readersMu.Unlock() if readers > 0 { fail(w, 409, "instance files busy; retry later") return } if i.ContainerID == "" { if err := a.recoverRuntimeContainer(ctx, &i); err != nil { fail(w, 502, "container recovery failed") return } } if i.ContainerID != "" { if err := a.removeRuntimeContainer(ctx, i.ContainerID); err != nil { fail(w, 502, "container deletion failed") return } } if err := a.removeRuntimeNetwork(ctx, i.ID); err != nil { fail(w, 502, "network deletion failed") return } if err := a.removeInstanceData(i); err != nil { a.s.SetInstance(i.ID, "error") fail(w, 500, "instance data deletion failed; retry deletion") return } if _, err := a.s.DB.ExecContext(ctx, "DELETE FROM instances WHERE id=?", i.ID); err != nil { fail(w, 500, "database error") return } _, _ = a.s.DB.Exec("DELETE FROM runtime_networks WHERE instance_id=?", i.ID) a.s.AddAudit(u.ID, "instance.delete", i.ID, "instance data deleted") // The instance row is gone and its resources were just cleaned, so // any pending flags cannot resolve later: drop the row unconditionally. _, _ = a.s.DB.Exec("DELETE FROM runtime_creates WHERE instance_id=?", i.ID) runtimeAuthCache.Delete(runtimeAuthKey{a, i.ID}) writeJSON(w, map[string]bool{"ok": true}) return } if action != "start" && action != "stop" && action != "restart" { fail(w, 404, "not found") return } if r.Method != "POST" { fail(w, 405, "method not allowed") return } inst := a.instanceLocks.Acquire(p[0]) defer inst.Release() i, err = a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") return } if action != "stop" && (i.Status == "error" || a.upgradeRecoveryRequired(i)) { fail(w, 409, "instance requires recovery") return } a.readersMu.Lock() readers := a.workspaceReaders[i.ID] a.readersMu.Unlock() if readers > 0 { fail(w, 409, "instance files busy; retry later") return } admissionMu.RLock() defer admissionMu.RUnlock() if action != "stop" { buildQueue.Lock() e := a.ensureBuilds() buildQueue.Unlock() if e != nil { fail(w, 500, "database error") return } var blocked int e = a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM image_admissions WHERE image_id=? AND state='blocked'", i.ImageID).Scan(&blocked) if e != nil { fail(w, 500, "database error") return } if blocked > 0 { fail(w, 409, "image is blocked; start/restart denied") return } } if err := a.docker.Do(ctx, "POST", "/containers/"+url.PathEscape(i.ContainerID)+"/"+action+"?t=10", nil, nil); err != nil { fail(w, 502, "container action failed") return } c, err := a.docker.Inspect(ctx, i.ContainerID) if err != nil { fail(w, 502, "container inspection failed") return } if i.Status != "error" { i.Status = c.State.Status } if _, err := a.s.DB.ExecContext(ctx, "UPDATE instances SET status=? WHERE id=?", i.Status, i.ID); err != nil { fail(w, 500, "database error") return } a.s.AddAudit(u.ID, "instance."+action, i.ID, i.Status) writeJSON(w, i) } func redactRuntimeLogs(s string) string { s = runtimeToken.ReplaceAllString(s, "?token=[REDACTED]") return runtimeSecrets.ReplaceAllString(s, "${1}[REDACTED]") } func latestRuntimeToken(logs string) string { matches := runtimeStartupToken.FindAllStringSubmatch(logs, -1) if len(matches) == 0 { return "" } token, err := url.QueryUnescape(matches[len(matches)-1][1]) if err != nil { return "" } return token } func (a *API) runtimeHost(r *http.Request) string { if u, err := url.Parse(a.origin); err == nil && u.Host != "" { return u.Host } return r.Host } func (a *API) runtimeOriginAllowed(r *http.Request) bool { origin := r.Header.Get("Origin") if origin == "" { return (r.Method == "GET" || r.Method == "HEAD") && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") } expected := strings.TrimRight(a.origin, "/") if expected == "" { scheme := "http" if r.TLS != nil { scheme = "https" } expected = scheme + "://" + r.Host } return origin == expected } var runtimeTransport = &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext, ResponseHeaderTimeout: 20 * time.Second, IdleConnTimeout: 90 * time.Second} type runtimeAuthKey struct { api *API id string } type runtimeAuth struct { mu sync.Mutex entries map[string]runtimeAuthEntry } type runtimeAuthEntry struct { Container string Started string Token string Host string Cookies string } type runtimeAuthDisk struct { Nonce string `json:"nonce"` Data string `json:"data"` } var runtimeAuthCache sync.Map func (a *API) runtimeCookie(ctx context.Context, i store.Instance, started string, target *url.URL, host string) (string, error) { if a.authErr != nil || len(a.authKey) != 32 { return "", errors.New("runtime authentication unavailable") } value, _ := runtimeAuthCache.LoadOrStore(runtimeAuthKey{a, i.ID}, &runtimeAuth{}) auth := value.(*runtimeAuth) auth.mu.Lock() defer auth.mu.Unlock() if auth.entries == nil { auth.entries = make(map[string]runtimeAuthEntry) } baseKey := i.ContainerID + "/" + started key := baseKey + "/" + host if entry, ok := auth.entries[key]; ok && entry.Cookies != "" { return entry.Cookies, nil } if entry, ok := a.loadRuntimeAuth(i.ID, baseKey); ok { if entry.Host == host && entry.Cookies != "" { auth.entries[key] = entry return entry.Cookies, nil } cookies, err := exchangeRuntimeCookie(ctx, target, host, entry.Token) if err != nil { return "", err } entry.Host, entry.Cookies = host, cookies if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil { return "", errors.New("runtime authentication unavailable") } auth.entries[key] = entry return cookies, nil } startedTime, err := time.Parse(time.RFC3339Nano, started) if err != nil { return "", errors.New("invalid start time") } since := fmt.Sprintf("%d.%09d", startedTime.Unix(), startedTime.Nanosecond()) query := url.Values{"stdout": {"1"}, "stderr": {"1"}, "follow": {"0"}, "since": {since}} resp, err := a.docker.Stream(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/logs?"+query.Encode(), nil, "") if err != nil { return "", errors.New("logs unavailable") } defer resp.Body.Close() token, err := runtimeTokenFromStream(resp.Body) if err != nil { return "", errors.New("logs unavailable") } if token == "" { return "", errors.New("token unavailable") } cookies, err := exchangeRuntimeCookie(ctx, target, host, token) if err != nil { return "", err } entry := runtimeAuthEntry{Container: i.ContainerID, Started: started, Token: token, Host: host, Cookies: cookies} if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil { return "", errors.New("runtime authentication unavailable") } auth.entries[key] = entry return cookies, nil } func (a *API) runtimeAuthKey(id string) []byte { h := sha256.New() h.Write(a.authKey) h.Write([]byte("runtime-auth/" + id)) return h.Sum(nil) } func (a *API) runtimeAuthPath(id, key string) string { h := sha256.Sum256([]byte(key)) return filepath.Join(a.root, "runtime-auth", id, hex.EncodeToString(h[:])+".json") } func (a *API) loadRuntimeAuth(id, key string) (runtimeAuthEntry, bool) { var zero runtimeAuthEntry if len(a.authKey) != 32 { return zero, false } f, err := os.OpenFile(a.runtimeAuthPath(id, key), os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) if err != nil { return zero, false } defer f.Close() st, err := f.Stat() if err != nil || !st.Mode().IsRegular() || st.Mode().Perm() != 0600 || st.Size() > 64<<10 { return zero, false } b, err := io.ReadAll(io.LimitReader(f, (64<<10)+1)) if err != nil || len(b) > 64<<10 { return zero, false } var disk runtimeAuthDisk if json.Unmarshal(b, &disk) != nil { return zero, false } nonce, err := base64.RawStdEncoding.DecodeString(disk.Nonce) if err != nil { return zero, false } ciphertext, err := base64.RawStdEncoding.DecodeString(disk.Data) if err != nil { return zero, false } bl, err := aes.NewCipher(a.runtimeAuthKey(id)) if err != nil { return zero, false } gcm, err := cipher.NewGCM(bl) if err != nil || len(nonce) != gcm.NonceSize() { return zero, false } plain, err := gcm.Open(nil, nonce, ciphertext, []byte(key)) if err != nil { return zero, false } var entry runtimeAuthEntry if json.Unmarshal(plain, &entry) != nil || entry.Container == "" || entry.Started == "" || entry.Token == "" || entry.Host == "" || entry.Container+"/"+entry.Started != key { return zero, false } return entry, true } func (a *API) saveRuntimeAuth(id, key string, entry runtimeAuthEntry) error { if len(a.authKey) != 32 { return errors.New("invalid authentication key") } bl, err := aes.NewCipher(a.runtimeAuthKey(id)) if err != nil { return err } gcm, err := cipher.NewGCM(bl) if err != nil { return err } nonce := make([]byte, gcm.NonceSize()) if _, err = rand.Read(nonce); err != nil { return err } plain, err := json.Marshal(entry) if err != nil { return err } disk, err := json.Marshal(runtimeAuthDisk{base64.RawStdEncoding.EncodeToString(nonce), base64.RawStdEncoding.EncodeToString(gcm.Seal(nil, nonce, plain, []byte(key)))}) if err != nil { return err } dir := filepath.Dir(a.runtimeAuthPath(id, key)) if err = os.MkdirAll(dir, 0700); err != nil { return err } if err = os.Chmod(dir, 0700); err != nil { return err } tmp, err := os.CreateTemp(dir, ".credential-*") if err != nil { return err } name := tmp.Name() defer os.Remove(name) if err = tmp.Chmod(0600); err == nil { _, err = tmp.Write(disk) } if err == nil { err = tmp.Sync() } closeErr := tmp.Close() if err != nil { return err } if closeErr != nil { return closeErr } if err = os.Rename(name, a.runtimeAuthPath(id, key)); err != nil { return err } d, err := os.Open(dir) if err != nil { return err } defer d.Close() return d.Sync() } // Bound total work while allowing all retained runtime logs (3 x 10 MiB). const runtimeLogLimit int64 = 64 << 20 func runtimeTokenFromStream(r io.Reader) (string, error) { br := bufio.NewReader(io.LimitReader(r, runtimeLogLimit+1)) first, err := br.Peek(8) if err != nil && err != io.EOF { return "", err } framed := len(first) == 8 && first[0] <= 2 && first[1] == 0 && first[2] == 0 && first[3] == 0 var used int64 tail, token := "", "" scan := func(p []byte) { text := tail + string(p) for { at := strings.IndexByte(text, '\n') if at < 0 { break } if found := latestRuntimeToken(text[:at]); found != "" { token = found } text = text[at+1:] } if len(text) > 8192 { text = text[len(text)-8192:] } tail = text } buf := make([]byte, 32<<10) for { var source io.Reader = br var frameLength int64 if framed { var header [8]byte n, e := io.ReadFull(br, header[:]) used += int64(n) if e == io.EOF { break } if e != nil { return "", e } if header[0] > 2 || header[1] != 0 || header[2] != 0 || header[3] != 0 { return "", errors.New("invalid logs") } frameLength = int64(binary.BigEndian.Uint32(header[4:])) if frameLength > runtimeLogLimit-used { return "", errors.New("log scan limit") } source = io.LimitReader(br, frameLength) } var read int64 for { n, e := source.Read(buf) used += int64(n) read += int64(n) if used > runtimeLogLimit { return "", errors.New("log scan limit") } if n > 0 { scan(buf[:n]) } if e == io.EOF { break } if e != nil { return "", e } } if !framed { break } if read != frameLength { return "", io.ErrUnexpectedEOF } } if found := latestRuntimeToken(tail); found != "" { token = found } return token, nil } func (a *API) proxy(w http.ResponseWriter, r *http.Request) { u, _, err := a.user(r) if err != nil { destination := r.Header.Get("Sec-Fetch-Dest") if r.Method == http.MethodGet && r.Header.Get("Sec-Fetch-Mode") == "navigate" && (destination == "document" || destination == "iframe") && r.Header.Get("Upgrade") == "" && a.runtimeOriginAllowed(r) { w.Header().Set("Cache-Control", "no-store") w.Header().Set("Referrer-Policy", "no-referrer") target := r.URL.EscapedPath() if query := withoutRuntimeToken(r.URL.RawQuery); query != "" { target += "?" + query } http.Redirect(w, r, "/#runtime-renew?return="+url.QueryEscape(target), http.StatusSeeOther) return } fail(w, 401, "authentication required") return } p := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/dsh/"), "/", 2) if p[0] == "" { fail(w, 404, "not found") return } inst := a.instanceLocks.Acquire(p[0]) locked := inst defer func() { if locked != nil { locked.Release() } }() i, err := a.s.GetInstance(p[0]) if err != nil || i.UID != u.ID { fail(w, 404, "not found") return } if i.Status == "error" || a.upgradeRecoveryRequired(i) { fail(w, 409, "instance requires recovery") return } if !a.runtimeOriginAllowed(r) { fail(w, 403, "origin denied") return } prefix := "/dsh/" + i.ID if len(p) == 1 { http.Redirect(w, r, prefix+"/", http.StatusTemporaryRedirect) return } host := a.runtimeHost(r) bridge := runtimeFreeSearchPath(strings.TrimPrefix(r.URL.Path, prefix)) if bridge { if r.Method != http.MethodPost { fail(w, 405, "method not allowed") return } if r.Header.Get("Sec-Fetch-Site") == "cross-site" { fail(w, 403, "origin denied") return } host = "localhost" } ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) defer cancel() var c struct { State struct { Running bool StartedAt string } NetworkSettings struct { Networks map[string]struct{ IPAddress string } } } err = a.docker.Do(ctx, "GET", "/containers/"+url.PathEscape(i.ContainerID)+"/json", nil, &c) if err != nil || !c.State.Running || c.State.StartedAt == "" { fail(w, 502, "runtime unavailable") return } ip := c.NetworkSettings.Networks[runtimeNetwork(i.ID)].IPAddress if net.ParseIP(ip) == nil { fail(w, 502, "runtime network unavailable") return } target := &url.URL{Scheme: "http", Host: net.JoinHostPort(ip, "8080")} cookies, err := a.runtimeCookie(ctx, i, c.State.StartedAt, target, host) if err != nil { fail(w, 502, "runtime authentication unavailable") return } cancel() locked.Release() locked = nil proxy := runtimeReverseProxy(target, prefix, host, cookies) a.invalidateRejectedRuntimeCookie(proxy, i, c.State.StartedAt, host, cookies) if bridge { // Only after panel authentication, ownership and Origin checks may the // loopback-only plugin receive a matching local Origin through Caddy. rewrite := proxy.Rewrite proxy.Rewrite = func(p *httputil.ProxyRequest) { rewrite(p) p.Out.Header.Set("Origin", "http://localhost") } } proxy.ServeHTTP(w, r) } // Do not replay mutations after an authentication failure. The next request // exchanges a new cookie using the retained token, even after logs rotate. func (a *API) invalidateRejectedRuntimeCookie(proxy *httputil.ReverseProxy, i store.Instance, started, host, cookies string) { modify := proxy.ModifyResponse proxy.ModifyResponse = func(resp *http.Response) error { if resp.StatusCode == 401 || resp.StatusCode == 403 { value, ok := runtimeAuthCache.Load(runtimeAuthKey{a, i.ID}) if ok { auth := value.(*runtimeAuth) auth.mu.Lock() baseKey := i.ContainerID + "/" + started key := baseKey + "/" + host if entry, exists := auth.entries[key]; exists && entry.Cookies == cookies { entry.Cookies = "" delete(auth.entries, key) if err := a.saveRuntimeAuth(i.ID, baseKey, entry); err != nil { auth.mu.Unlock() return errors.New("runtime authentication unavailable") } } auth.mu.Unlock() } } return modify(resp) } } func runtimeFreeSearchPath(path string) bool { switch path { case "/api/dsh-free-search-settings/describe", "/api/dsh-free-search-settings/mutate", "/api/dsh-free-search-settings/credentials-status", "/api/dsh-free-search-settings/credentials-set", "/api/dsh-free-search-settings/credentials-unset", "/api/dsh-free-search-settings/check-update", "/api/dsh-free-search-settings/update", "/api/dsh-free-search-settings/raw-search": return true } return false } func exchangeRuntimeCookie(ctx context.Context, target *url.URL, host, token string) (string, error) { if target == nil { return "", errors.New("exchange target unavailable") } endpoint := *target endpoint.Path = "/" endpoint.RawQuery = url.Values{"token": {token}}.Encode() req, err := http.NewRequestWithContext(ctx, "GET", endpoint.String(), nil) if err != nil { return "", err } req.Host = host client := &http.Client{Transport: runtimeTransport, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} resp, err := client.Do(req) if err != nil { return "", errors.New("exchange failed") } defer resp.Body.Close() if resp.StatusCode != http.StatusSeeOther { return "", errors.New("exchange rejected") } var cookies []string for _, cookie := range resp.Cookies() { if cookie.Value != "" && cookie.MaxAge >= 0 { cookies = append(cookies, (&http.Cookie{Name: cookie.Name, Value: cookie.Value}).String()) } } if len(cookies) == 0 { return "", errors.New("exchange missing cookie") } return strings.Join(cookies, "; "), nil } func withoutRuntimeToken(rawQuery string) string { parts := strings.Split(rawQuery, "&") kept := parts[:0] for _, part := range parts { key, _, _ := strings.Cut(part, "=") decoded, err := url.QueryUnescape(key) if err == nil && decoded == "token" { continue } kept = append(kept, part) } return strings.Join(kept, "&") } func runtimeReverseProxy(target *url.URL, prefix, host, cookies string) *httputil.ReverseProxy { return &httputil.ReverseProxy{ Transport: runtimeTransport, FlushInterval: -1, ErrorLog: log.New(io.Discard, "", 0), Rewrite: func(p *httputil.ProxyRequest) { p.SetURL(target) p.Out.Host = host p.Out.URL.Path = strings.TrimPrefix(p.In.URL.Path, prefix) p.Out.URL.RawPath = "" // DSH combo assets use an opaque ?module1,module2 query prefix. p.Out.URL.RawQuery = withoutRuntimeToken(p.In.URL.RawQuery) p.Out.Header.Del("Cookie") p.Out.Header.Del("Authorization") p.Out.Header.Del("Proxy-Authorization") p.Out.Header.Del("X-CSRF-Token") p.Out.Header.Set("Accept-Encoding", "identity") p.Out.Header.Set("Cookie", cookies) p.SetXForwarded() p.Out.Header.Set("X-Forwarded-Prefix", prefix) }, ModifyResponse: func(resp *http.Response) error { resp.Header.Del("Set-Cookie") if resp.StatusCode == http.StatusOK && strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") && resp.Header.Get("Content-Encoding") == "" { data, err := io.ReadAll(io.LimitReader(resp.Body, (8<<20)+1)) resp.Body.Close() if err != nil || len(data) > 8<<20 { return fmt.Errorf("runtime document unavailable") } data = bytes.Replace(data, []byte(""), []byte(``), 1) resp.Body = io.NopCloser(bytes.NewReader(data)) resp.ContentLength = int64(len(data)) resp.Header.Set("Content-Length", fmt.Sprint(len(data))) resp.Header.Del("ETag") resp.Header.Set("Cache-Control", "no-store") } if location := resp.Header.Get("Location"); location != "" { u, err := url.Parse(location) if err != nil { return fmt.Errorf("invalid redirect") } u.RawQuery = withoutRuntimeToken(u.RawQuery) if u.Host == target.Host || u.Host == host { u.Scheme = "" u.Host = "" } if u.Host == "" && strings.HasPrefix(u.Path, "/") { u.Path = prefix + u.Path u.RawPath = "" } resp.Header.Set("Location", u.String()) } return nil }, ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { fail(w, 502, "runtime proxy unavailable") }, } }