Close all 14 findings from the codebase audit (1 high, 5 medium, 8 low): - Locking: replace the global API and workspace mutexes with per-instance and per-user keyed locks (new internal/api/locks.go) so a slow restore, upgrade or transfer no longer blocks unrelated users or instances; move Docker I/O out of the build queue and invalidate concurrent image-index observations with a generation counter; serialize instance deletion behind in-flight creation. - Security: equalize login timing for unknown usernames, periodically purge expired sessions/refresh tokens/lock rows, URL-encode orphan IDs in the frontend, upgrade vitest 3.x->5.x to clear 3 npm audit vulnerabilities (2 critical RCE). - Deployment: add a -healthcheck mode probing /healthz (used by Dockerfile HEALTHCHECK and compose), make ADMIN_PASSWORD optional in compose with removal guidance after first start, exclude test scripts from the image, and cap panel resources (cpus: 2, pids_limit: 512). - Reliability: check rows.Err() on admin listing loops, surface intent polling errors without sticking, disable close during in-flight create. Verified: go build/vet, full go test -race -count=1 ./..., frontend tsc build + 32 tests, npm audit 0, compose config, real-container healthcheck.
141 lines
3.9 KiB
Go
141 lines
3.9 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestImageIndexDoesNotUndoConcurrentDelete(t *testing.T) {
|
|
for _, initial := range []string{"available", "missing"} {
|
|
t.Run(initial, func(t *testing.T) {
|
|
id := "sha256:" + strings.Repeat("a", 64)
|
|
blocker := "sha256:" + strings.Repeat("b", 64)
|
|
reached, release := make(chan struct{}), make(chan struct{})
|
|
var once sync.Once
|
|
var reachedOnce sync.Once
|
|
var deleted atomic.Bool
|
|
unblock := func() { once.Do(func() { close(release) }) }
|
|
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case r.Method == http.MethodDelete:
|
|
deleted.Store(true)
|
|
writeJSON(w, []any{})
|
|
case r.URL.Path == "/v1.47/images/"+id+"/json":
|
|
if deleted.Load() {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
return
|
|
}
|
|
writeImageInspect(w, id, "1.2.3")
|
|
case r.URL.Path == "/v1.47/images/"+blocker+"/json":
|
|
reachedOnce.Do(func() { close(reached) })
|
|
select {
|
|
case <-release:
|
|
case <-r.Context().Done():
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
case r.URL.Path == "/v1.47/images/json", r.URL.Path == "/v1.47/containers/json":
|
|
writeJSON(w, []any{})
|
|
default:
|
|
t.Errorf("unexpected Docker request: %s %s", r.Method, r.URL)
|
|
w.WriteHeader(500)
|
|
}
|
|
})
|
|
defer unblock()
|
|
seedVerifiedImage(t, a, id, "1.2.3")
|
|
for _, q := range []struct {
|
|
sql string
|
|
args []any
|
|
}{
|
|
{"UPDATE users SET admin=1 WHERE id='u'", nil},
|
|
{"INSERT INTO images(image_id,version,status,built_at) VALUES(?,'1.2.3',?,'2'),(?,'2.0.0','missing','1')", []any{id, initial, blocker}},
|
|
} {
|
|
if _, err := a.s.DB.Exec(q.sql, q.args...); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
type outcome struct {
|
|
images []imageRecord
|
|
err error
|
|
}
|
|
done := make(chan outcome, 1)
|
|
go func() { images, err := a.imageIndex(ctx); done <- outcome{images, err} }()
|
|
select {
|
|
case <-reached:
|
|
case <-ctx.Done():
|
|
t.Fatal("index did not reach second image")
|
|
}
|
|
r := httptest.NewRequest(http.MethodDelete, "/api/images?image_id="+id, nil).WithContext(ctx)
|
|
r.AddCookie(&http.Cookie{Name: "dsh_session", Value: sid})
|
|
addTestAccess(r, sid)
|
|
w := httptest.NewRecorder()
|
|
a.images(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("delete: %d %s", w.Code, w.Body.String())
|
|
}
|
|
unblock()
|
|
select {
|
|
case out := <-done:
|
|
if out.err != nil {
|
|
t.Fatal(out.err)
|
|
}
|
|
matches := 0
|
|
for _, im := range out.images {
|
|
if im.ImageID == id {
|
|
matches++
|
|
}
|
|
if im.ImageID == id && im.Status != "missing" {
|
|
t.Errorf("returned deleted image as %s", im.Status)
|
|
}
|
|
}
|
|
if matches != 1 {
|
|
t.Errorf("returned %d records for deleted image, want 1", matches)
|
|
}
|
|
case <-ctx.Done():
|
|
t.Fatal("index did not finish")
|
|
}
|
|
var status string
|
|
if err := a.s.DB.QueryRow("SELECT status FROM images WHERE image_id=?", id).Scan(&status); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != "missing" {
|
|
t.Errorf("persisted deleted image as %s", status)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestImageIndexBoundsInvalidatedSnapshots(t *testing.T) {
|
|
var a *API
|
|
var calls atomic.Int32
|
|
a, _ = imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/v1.47/images/json" {
|
|
t.Errorf("unexpected request: %s", r.URL)
|
|
w.WriteHeader(500)
|
|
return
|
|
}
|
|
// Model a completed deletion during every unlocked observation phase.
|
|
buildQueue.Lock()
|
|
a.imageDeleteGeneration++
|
|
buildQueue.Unlock()
|
|
calls.Add(1)
|
|
writeJSON(w, []any{})
|
|
})
|
|
images, err := a.imageIndex(context.Background())
|
|
if !errors.Is(err, errImageIndexChanged) || images != nil {
|
|
t.Fatalf("images=%v err=%v; want no inventory and invalidation error", images, err)
|
|
}
|
|
if calls.Load() != 3 {
|
|
t.Fatalf("attempts=%d, want 3", calls.Load())
|
|
}
|
|
}
|