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:
+114
-54
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user