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.
146 lines
4.8 KiB
Go
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"})
|
|
}
|