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.
This commit is contained in:
2026-10-13 20:59:28 +08:00
parent 85332bbc56
commit 66abb76318
33 changed files with 1065 additions and 540 deletions
+114 -54
View File
@@ -75,34 +75,38 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) {
return
}
}
buildQueue.Lock()
defer buildQueue.Unlock()
if err := a.ensureBuilds(); err != nil {
fail(w, 500, "database error")
return
}
if r.Method == http.MethodGet {
rows, err := a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC")
buildQueue.Lock()
err := a.ensureBuilds()
var rows *sql.Rows
if err == nil {
rows, err = a.s.DB.Query("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds ORDER BY created_at DESC,id DESC")
}
result := []buildRecord{}
if err == nil {
for rows.Next() {
var b buildRecord
var finished, recipe, platform sql.NullString
if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil {
break
}
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
result = append(result, b)
}
scanErr := rows.Err()
rows.Close()
if err == nil {
err = scanErr
}
}
buildQueue.Unlock()
if err != nil {
fail(w, 500, "database error")
return
}
result := []buildRecord{}
for rows.Next() {
var b buildRecord
var finished, recipe, platform sql.NullString
if err = rows.Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished); err != nil {
break
}
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
result = append(result, b)
}
scanErr := rows.Err()
rows.Close()
if err != nil || scanErr != nil {
fail(w, 500, "database error")
return
}
// Docker image reconciliation is I/O-bound and updates rows with
// compare-and-set, so it must not hold the queue lock that instance
// creation and upgrades also need.
if err := a.reconcileBuilds(r.Context(), result); err != nil {
fail(w, 502, "docker image reconciliation failed")
return
@@ -111,7 +115,7 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
return
}
b, err := a.enqueueBuildLocked(r.Context(), u.ID, in.Version)
b, err := a.enqueueBuild(r.Context(), u.ID, in.Version)
if err != nil {
status := 502
if errors.Is(err, errBuildRateLimited) {
@@ -128,54 +132,110 @@ func (a *API) builds(w http.ResponseWriter, r *http.Request) {
var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour")
func (a *API) enqueueBuild(ctx context.Context, uid, version string) (buildRecord, error) {
buildQueue.Lock()
defer buildQueue.Unlock()
if !validBuildVersion(version) {
return buildRecord{}, errors.New("a fixed version is required")
}
if err := a.ensureBuilds(); err != nil {
return buildRecord{}, err
}
return a.enqueueBuildLocked(ctx, uid, version)
}
func (a *API) enqueueBuildLocked(ctx context.Context, uid, version string) (buildRecord, error) {
// lookupActiveBuild returns the active or successful record for a version; it
// performs no I/O and must be called with the build queue held.
func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) {
var b buildRecord
var finished, recipe, platform sql.NullString
err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at FROM image_builds WHERE version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished)
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
if err == nil && b.Status == "success" {
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
id, lookupErr := a.localBuildImage(ctx, b.Version, b.ImageID)
if errors.Is(err, sql.ErrNoRows) {
return b, false, nil
}
if err != nil {
return b, false, err
}
return b, true, nil
}
func (a *API) enqueueBuild(ctx context.Context, uid, version string) (buildRecord, error) {
if !validBuildVersion(version) {
return buildRecord{}, errors.New("a fixed version is required")
}
buildQueue.Lock()
err := a.ensureBuilds()
var b buildRecord
var found bool
if err == nil {
b, found, err = a.lookupActiveBuild(version)
}
buildQueue.Unlock()
if err != nil {
return b, err
}
if found && b.Status != "success" {
a.startBuildWorker()
return b, nil
}
if found {
// Verifying a previously built image talks to Docker; keep that I/O
// out of the queue lock shared with instance creation and upgrades.
verify, cancel := context.WithTimeout(ctx, 15*time.Second)
id, lookupErr := a.localBuildImage(verify, b.Version, b.ImageID)
cancel()
if lookupErr != nil {
return b, lookupErr
}
if id != b.ImageID || id == "" {
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=?", b.ID); err == nil {
err = sql.ErrNoRows
}
if id != "" && id == b.ImageID {
a.startBuildWorker()
return b, nil
}
}
if errors.Is(err, sql.ErrNoRows) {
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil {
buildQueue.Lock()
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=? AND status='success'", b.ID); err == nil {
// Another request may have queued a replacement while the image
// was being verified; honor it instead of inserting a duplicate.
b, found, err = a.lookupActiveBuild(version)
}
if err == nil && !found {
b, err = a.insertBuild(uid, version)
}
buildQueue.Unlock()
if err != nil {
return b, err
}
if count >= 3 {
return b, errBuildRateLimited
}
b = buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform}
err = a.insertBuildRequest(b, uid)
a.startBuildWorker()
return b, nil
}
buildQueue.Lock()
defer buildQueue.Unlock()
// Re-check after the unlocked phases: another request may have inserted.
b, found, err = a.lookupActiveBuild(version)
if err != nil {
return b, err
}
a.startBuildWorker()
if found {
return b, nil
}
b, err = a.insertBuild(uid, version)
if err != nil {
return b, err
}
a.startBuildWorkerLocked()
return b, nil
}
// insertBuild applies the per-user rate limit and inserts a queued record;
// call with the build queue held.
func (a *API) insertBuild(uid, version string) (buildRecord, error) {
var count int
if err := a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil {
return buildRecord{}, err
}
if count >= 3 {
return buildRecord{}, errBuildRateLimited
}
b := buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform}
return b, a.insertBuildRequest(b, uid)
}
func (a *API) startBuildWorker() {
buildQueue.Lock()
defer buildQueue.Unlock()
a.startBuildWorkerLocked()
}
// startBuildWorkerLocked requires the caller to hold buildQueue: the active map
// is only safe under that lock.
func (a *API) startBuildWorkerLocked() {
if buildQueue.active[a.s.DB] {
return
}