Files
dsh-web/internal/api/images_delete.go
T
m1saka 66abb76318 Fix audit findings: lock architecture, security, deployment hardening
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.
2026-10-13 20:59:28 +08:00

146 lines
4.8 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"net/http"
"net/url"
"time"
"git.misaka.ren/m1saka/dsh-web/internal/docker"
)
func (a *API) deleteImage(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
id := r.URL.Query().Get("image_id")
if !runtimeImageID.MatchString(id) {
fail(w, 400, "invalid image_id")
return
}
// Hold the build queue through the checks and the Docker deletion so a
// record cannot flip to missing mid-flight. Upgrades write their journal
// entry before creating containers; the transaction below re-checks that
// journal, and an upgrade racing an administrator deletion fails safely
// with its own rollback path.
buildQueue.Lock()
defer buildQueue.Unlock()
if buildQueue.active[a.s.DB] {
fail(w, 409, "image build worker is active")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
tx, err := a.s.DB.BeginTx(ctx, nil)
if err != nil {
fail(w, 500, "database error")
return
}
defer tx.Rollback()
// Take SQLite's writer lock before checking intents: reservation does not use the build queue.
if _, err = tx.ExecContext(ctx, "UPDATE images SET status=status WHERE image_id=?", id); err != nil {
fail(w, 500, "image index unavailable")
return
}
var version string
err = tx.QueryRowContext(ctx, "SELECT version FROM images WHERE image_id=?", id).Scan(&version)
if errors.Is(err, sql.ErrNoRows) {
fail(w, 404, "managed image not found")
return
}
if err != nil {
fail(w, 500, "image index unavailable")
return
}
for _, check := range []struct{ table, query string }{
{"image_builds", "SELECT count(*) FROM image_builds WHERE status IN ('queued','running','validating')"},
{"creation_intents", "SELECT count(*) FROM creation_intents WHERE status IN ('waiting','creating')"},
{"upgrade_journal", "SELECT count(*) FROM upgrade_journal WHERE phase NOT IN ('complete','rolled_back')"},
} {
var exists, count int
if err = tx.QueryRowContext(ctx, "SELECT count(*) FROM sqlite_master WHERE type='table' AND name=?", check.table).Scan(&exists); err == nil && exists > 0 {
err = tx.QueryRowContext(ctx, check.query).Scan(&count)
}
if err != nil {
fail(w, 500, "active task lookup failed")
return
}
if count > 0 {
fail(w, 409, "active task prevents image deletion")
return
}
}
var references int
if err = tx.QueryRowContext(ctx, "SELECT count(*) FROM instances WHERE image_id=?", id).Scan(&references); err != nil {
fail(w, 500, "instance reference lookup failed")
return
}
if references > 0 {
fail(w, 409, "image is referenced by an instance")
return
}
var image struct {
ID string `json:"Id"`
Config struct{ Labels map[string]string }
}
path := "/images/" + url.PathEscape(id)
if err = a.docker.Do(ctx, http.MethodGet, path+"/json", nil, &image); err != nil {
fail(w, 502, "image inspection failed")
return
}
if image.ID != id || image.Config.Labels["dsh-mgr.role"] != "runtime" || version == "" || image.Config.Labels["dsh-mgr.version"] != version {
fail(w, 409, "managed runtime image required")
return
}
var containers []struct{ ImageID string }
if err = a.docker.Do(ctx, http.MethodGet, "/containers/json?all=1", nil, &containers); err != nil || containers == nil {
fail(w, 502, "container reference lookup failed")
return
}
for _, container := range containers {
if container.ImageID == "" {
fail(w, 502, "container image identity unavailable")
return
}
if container.ImageID == id {
fail(w, 409, "image is referenced by a container")
return
}
}
if _, err = tx.ExecContext(ctx, "UPDATE images SET status='missing',usage_count=0 WHERE image_id=?", id); err == nil {
_, err = tx.ExecContext(ctx, "UPDATE image_builds SET status='missing',error='image deleted by administrator' WHERE image_id=? AND status='success'", id)
}
if err == nil {
_, err = tx.ExecContext(ctx, "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,'image.delete',?,'',?)", u.ID, id, time.Now().Unix())
}
if err != nil {
fail(w, 500, "image deletion recording failed")
return
}
if err = a.docker.Do(ctx, http.MethodDelete, path+"?force=false", nil, nil); err != nil {
status := http.StatusBadGateway
var apiErr *docker.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusConflict {
status = http.StatusConflict
}
fail(w, status, "docker image deletion failed")
return
}
// Docker deletion is already authoritative, even if the DB commit fails.
// Any inventory observation taken before it must be discarded.
a.imageDeleteGeneration++
if err = tx.Commit(); err != nil {
fail(w, 500, "image deleted but recording failed")
return
}
writeJSON(w, map[string]string{"image_id": id, "status": "missing"})
}