Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

899 lines
30 KiB
Go

package nodes
import (
"context"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
"github.com/mudler/LocalAI/core/services/storage"
"github.com/mudler/LocalAI/pkg/downloader"
"github.com/mudler/LocalAI/pkg/model"
"github.com/mudler/xlog"
)
// Headers used by the HEAD probe / skip-if-exists protocol.
const (
HeaderContentSHA256 = "X-Content-SHA256"
HeaderLocalPath = "X-Local-Path"
HeaderFileSize = "X-File-Size"
// HeaderTargetSHA256 is set on HEAD responses for partial (resumable) uploads
// to expose the expected final SHA-256 of the in-progress file. When set,
// the file on disk is not yet the full content — the client may resume by
// PUT'ing the remainder with a matching X-Content-SHA256 header.
HeaderTargetSHA256 = "X-Target-SHA256"
hashSidecarSuffix = ".sha256"
// targetSidecarSuffix stores the expected final SHA-256 of a partially
// uploaded file. Used to detect mid-flight content mismatches across
// resumed PUT requests.
targetSidecarSuffix = ".sha256.target"
)
// StartFileTransferServer starts a small HTTP server for file transfer in distributed mode.
// It provides PUT/GET/POST endpoints for uploading, downloading, and allocating temp files,
// as well as backend log REST and WebSocket endpoints when logStore is non-nil.
// Auth is via Bearer token (registration token), using constant-time comparison.
func StartFileTransferServer(addr, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, logStore ...*model.BackendLogStore) (*http.Server, error) {
listener, err := net.Listen("tcp", addr)
if err != nil {
return nil, fmt.Errorf("listen %s: %w", addr, err)
}
return StartFileTransferServerWithListener(listener, stagingDir, modelsDir, dataDir, token, maxUploadSize, logStore...)
}
// StartFileTransferServerWithListener starts the server on an existing listener.
// This avoids the TOCTOU race of closing a listener and re-binding to the same port.
func StartFileTransferServerWithListener(lis net.Listener, stagingDir, modelsDir, dataDir, token string, maxUploadSize int64, logStore ...*model.BackendLogStore) (*http.Server, error) {
if err := os.MkdirAll(stagingDir, 0750); err != nil {
return nil, fmt.Errorf("creating staging dir %s: %w", stagingDir, err)
}
// An empty token makes checkBearerToken fail open: every /v1/files,
// /v1/files-list and /v1/backend-logs request is served unauthenticated,
// granting read/write to the staging/models/data directories to anyone who
// can reach this port. Surface that loudly — the worker process does not
// run DistributedConfig.Validate(), so this is the only signal an operator
// gets. Set LOCALAI_REGISTRATION_TOKEN (and LOCALAI_REGISTRATION_REQUIRE_AUTH
// to fail closed) to protect it.
if token == "" {
xlog.Warn("HTTP file transfer server starting WITHOUT a registration token — read/write to models/staging/data is unauthenticated for anyone who can reach this port; set LOCALAI_REGISTRATION_TOKEN")
}
mux := http.NewServeMux()
// PUT /v1/files/{key} — upload file
// GET /v1/files/{key} — download file
// POST /v1/files/temp — allocate temp file
// GET /v1/files-list/{key} — list files in a directory
mux.HandleFunc("/v1/files-list/", func(w http.ResponseWriter, r *http.Request) {
if !checkBearerToken(r, token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
key := strings.TrimPrefix(r.URL.Path, "/v1/files-list/")
handleListDir(w, r, stagingDir, modelsDir, dataDir, key)
})
mux.HandleFunc("/v1/files/", func(w http.ResponseWriter, r *http.Request) {
if !checkBearerToken(r, token) {
xlog.Debug("HTTP file transfer: unauthorized request", "method", r.Method, "path", r.URL.Path, "remote", r.RemoteAddr)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Extract key from path: /v1/files/{key}
key := strings.TrimPrefix(r.URL.Path, "/v1/files/")
xlog.Debug("HTTP file transfer request", "method", r.Method, "key", key, "remote", r.RemoteAddr)
switch r.Method {
case http.MethodHead:
handleHead(w, r, stagingDir, modelsDir, dataDir, key)
case http.MethodPut:
handleUpload(w, r, stagingDir, modelsDir, dataDir, key, maxUploadSize)
case http.MethodGet:
handleDownload(w, r, stagingDir, modelsDir, dataDir, key)
case http.MethodPost:
if key == "temp" {
handleAllocTemp(w, r, stagingDir)
} else {
http.Error(w, "not found", http.StatusNotFound)
}
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
// Backend log endpoints (only registered when a log store is provided)
var ls *model.BackendLogStore
if len(logStore) > 0 && logStore[0] != nil {
ls = logStore[0]
}
if ls != nil {
registerBackendLogHandlers(mux, token, ls)
}
// Liveness/readiness probes — unauthenticated so container orchestrators
// (Docker HEALTHCHECK, k8s probes) can hit them without the bearer token.
// Reaching this point means the listener is bound and the mux is serving.
healthHandler := func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
}
mux.HandleFunc("/readyz", healthHandler)
mux.HandleFunc("/healthz", healthHandler)
addr := lis.Addr().String()
server := &http.Server{
Handler: mux,
ReadHeaderTimeout: 30 * time.Second, // prevent slowloris; does not affect body reads
}
go func() {
xlog.Info("HTTP file transfer server started", "addr", addr, "stagingDir", stagingDir, "modelsDir", modelsDir, "dataDir", dataDir)
if err := server.Serve(lis); err != nil && err != http.ErrServerClosed {
xlog.Error("HTTP file transfer server error", "error", err)
}
}()
return server, nil
}
func handleHead(w http.ResponseWriter, r *http.Request, stagingDir, modelsDir, dataDir, key string) {
if key == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}
targetDir, relName := resolveKeyToDir(key, stagingDir, modelsDir, dataDir)
filePath := filepath.Join(targetDir, relName)
if err := validatePathInDir(filePath, targetDir); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
info, err := os.Stat(filePath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("stat error: %v", err), http.StatusInternalServerError)
}
return
}
if info.IsDir() {
http.Error(w, "is a directory", http.StatusBadRequest)
return
}
w.Header().Set(HeaderFileSize, strconv.FormatInt(info.Size(), 10))
w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10))
w.Header().Set(HeaderLocalPath, filePath)
// Advertise resumable-upload support so clients know they may send
// Content-Range PUTs to continue partial transfers.
w.Header().Set("Accept-Ranges", "bytes")
// If a target-hash sidecar is present the file on disk is a partial
// upload, not a finished file. Expose the expected final hash via
// X-Target-SHA256 and skip emitting X-Content-SHA256 (which would otherwise
// be the hash of just the bytes received so far — misleading for clients
// trying to decide whether the file is "the right one").
if target, err := os.ReadFile(filePath + targetSidecarSuffix); err == nil {
t := strings.TrimSpace(string(target))
if len(t) == 64 {
w.Header().Set(HeaderTargetSHA256, t)
w.WriteHeader(http.StatusOK)
return
}
}
hashHex, err := computeAndCacheHash(filePath)
if err != nil {
xlog.Warn("Failed to compute hash for HEAD", "path", filePath, "error", err)
} else {
w.Header().Set(HeaderContentSHA256, hashHex)
}
w.WriteHeader(http.StatusOK)
}
// contentRange describes a parsed Content-Range request header of the form
// "bytes <start>-<end>/<total>". An end of -1 means the request is open-ended
// (unknown end), which is unusual for uploads but accepted.
type contentRange struct {
start int64
end int64
total int64
}
// parseContentRange parses a Content-Range header value of the form
// "bytes <start>-<end>/<total>". RFC 9110 §14.4.
// Returns (nil, nil) when the header is empty (no range request).
func parseContentRange(h string) (*contentRange, error) {
h = strings.TrimSpace(h)
if h == "" {
return nil, nil
}
const prefix = "bytes "
if !strings.HasPrefix(h, prefix) {
return nil, fmt.Errorf("invalid Content-Range: missing %q prefix", strings.TrimSpace(prefix))
}
spec := strings.TrimSpace(h[len(prefix):])
slash := strings.IndexByte(spec, '/')
if slash < 0 {
return nil, fmt.Errorf("invalid Content-Range: missing /total")
}
rangePart, totalPart := spec[:slash], spec[slash+1:]
dash := strings.IndexByte(rangePart, '-')
if dash < 0 {
return nil, fmt.Errorf("invalid Content-Range: missing - separator")
}
start, err := strconv.ParseInt(strings.TrimSpace(rangePart[:dash]), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Content-Range start: %w", err)
}
end, err := strconv.ParseInt(strings.TrimSpace(rangePart[dash+1:]), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Content-Range end: %w", err)
}
total, err := strconv.ParseInt(strings.TrimSpace(totalPart), 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid Content-Range total: %w", err)
}
if start < 0 || end < start || total < end+1 {
return nil, fmt.Errorf("invalid Content-Range range: %d-%d/%d", start, end, total)
}
return &contentRange{start: start, end: end, total: total}, nil
}
func handleUpload(w http.ResponseWriter, r *http.Request, stagingDir, modelsDir, dataDir, key string, maxUploadSize int64) {
if key == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}
if maxUploadSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, maxUploadSize)
}
// Parse optional Content-Range for resumable uploads.
cr, err := parseContentRange(r.Header.Get("Content-Range"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Optional expected total-file SHA-256 used to detect cross-attempt
// content drift on resume.
expectedFinalHash := strings.TrimSpace(r.Header.Get(HeaderContentSHA256))
xlog.Info("Receiving file upload", "key", key, "contentLength", r.ContentLength,
"contentRange", r.Header.Get("Content-Range"), "remote", r.RemoteAddr)
// Route keyed files to the appropriate directory
targetDir, relName := resolveKeyToDir(key, stagingDir, modelsDir, dataDir)
dstPath := filepath.Join(targetDir, relName)
if err := validatePathInDir(dstPath, targetDir); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if err := os.MkdirAll(filepath.Dir(dstPath), 0750); err != nil {
http.Error(w, fmt.Sprintf("creating parent dir: %v", err), http.StatusInternalServerError)
return
}
if cr == nil {
// Non-resumable (legacy) path: truncate-create, single fire-and-forget.
handleFullUpload(w, r, dstPath, key, expectedFinalHash)
return
}
handleRangeUpload(w, r, dstPath, key, cr, expectedFinalHash)
}
// handleFullUpload writes the entire request body to dstPath, replacing any
// existing content. This is the legacy happy-path with no Range header.
func handleFullUpload(w http.ResponseWriter, r *http.Request, dstPath, key, expectedFinalHash string) {
// Reset any in-progress resumable state.
_ = os.Remove(dstPath + targetSidecarSuffix)
f, err := os.Create(dstPath)
if err != nil {
http.Error(w, fmt.Sprintf("creating file: %v", err), http.StatusInternalServerError)
return
}
defer f.Close()
hasher := sha256.New()
n, err := io.Copy(f, io.TeeReader(r.Body, hasher))
if err != nil {
os.Remove(dstPath)
os.Remove(dstPath + hashSidecarSuffix)
xlog.Error("File upload failed", "key", key, "bytesReceived", n, "contentLength", r.ContentLength, "remote", r.RemoteAddr, "error", err)
http.Error(w, fmt.Sprintf("writing file: %v", err), http.StatusInternalServerError)
return
}
hashHex := hex.EncodeToString(hasher.Sum(nil))
if expectedFinalHash != "" && !strings.EqualFold(expectedFinalHash, hashHex) {
_ = os.Remove(dstPath)
_ = os.Remove(dstPath + hashSidecarSuffix)
xlog.Error("Uploaded file SHA-256 mismatch", "key", key, "expected", expectedFinalHash, "got", hashHex)
http.Error(w, fmt.Sprintf("sha256 mismatch: expected %s got %s", expectedFinalHash, hashHex), http.StatusBadRequest)
return
}
if err := os.WriteFile(dstPath+hashSidecarSuffix, []byte(hashHex), 0640); err != nil {
xlog.Warn("Failed to write hash sidecar", "path", dstPath+hashSidecarSuffix, "error", err)
}
xlog.Info("File upload complete", "key", key, "path", dstPath, "size", n, "sha256", hashHex)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"local_path": dstPath}); err != nil {
xlog.Warn("Failed to encode upload response", "error", err)
}
}
// handleRangeUpload appends a Content-Range slice to dstPath, validating that
// the request starts at the current file size. When the slice completes the
// transfer (end+1 == total), it validates the optional expected final hash and
// writes the sidecar.
func handleRangeUpload(w http.ResponseWriter, r *http.Request, dstPath, key string, cr *contentRange, expectedFinalHash string) {
// Determine the current on-disk size (0 if missing).
var currentSize int64
if info, err := os.Stat(dstPath); err == nil {
if info.IsDir() {
http.Error(w, "destination is a directory", http.StatusBadRequest)
return
}
currentSize = info.Size()
} else if !os.IsNotExist(err) {
http.Error(w, fmt.Sprintf("stat dst: %v", err), http.StatusInternalServerError)
return
}
targetSidecar := dstPath + targetSidecarSuffix
// Decide whether the existing on-disk bytes (if any) belong to the same
// logical file the client is uploading now. If they don't, and the client
// is starting from byte 0, we transparently truncate the old file and
// proceed — this is the natural "re-upload" case.
if cr.start == 0 && currentSize > 0 {
sameFile := false
if expectedFinalHash != "" {
// Compare the client's declared target hash against either an
// in-progress target sidecar OR the completed-file sidecar.
if t, err := os.ReadFile(targetSidecar); err == nil {
if strings.EqualFold(strings.TrimSpace(string(t)), expectedFinalHash) {
sameFile = true
}
} else if h, err := os.ReadFile(dstPath + hashSidecarSuffix); err == nil {
if strings.EqualFold(strings.TrimSpace(string(h)), expectedFinalHash) {
sameFile = true
}
}
}
if !sameFile {
// Different file content claimed under the same key — drop any
// existing bytes (completed or partial) so the new upload starts
// from a clean slate.
_ = os.Remove(dstPath)
_ = os.Remove(dstPath + hashSidecarSuffix)
_ = os.Remove(targetSidecar)
currentSize = 0
}
}
// Cross-attempt consistency: if there's an in-progress target sidecar with
// a different hash than what's now being claimed, force a restart.
if expectedFinalHash != "" && cr.start > 0 {
prev, _ := os.ReadFile(targetSidecar)
prevHash := strings.TrimSpace(string(prev))
if prevHash != "" && !strings.EqualFold(prevHash, expectedFinalHash) {
_ = os.Remove(dstPath)
_ = os.Remove(dstPath + hashSidecarSuffix)
_ = os.Remove(targetSidecar)
http.Error(w, fmt.Sprintf("X-Content-SHA256 mismatch with in-progress upload (was %s, now %s); restart from byte 0", prevHash, expectedFinalHash), http.StatusConflict)
return
}
}
// The most important invariant: the client must continue from exactly
// where the server left off. If not, return 416 with the current size in
// the Range header so the client can re-sync.
if cr.start != currentSize {
w.Header().Set("Content-Range", fmt.Sprintf("bytes */%d", cr.total))
w.Header().Set(HeaderFileSize, strconv.FormatInt(currentSize, 10))
http.Error(w, fmt.Sprintf("Content-Range start %d does not match current file size %d", cr.start, currentSize), http.StatusRequestedRangeNotSatisfiable)
return
}
// Open the file in append mode (create if missing).
f, err := os.OpenFile(dstPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0640)
if err != nil {
http.Error(w, fmt.Sprintf("opening dst: %v", err), http.StatusInternalServerError)
return
}
defer func() { _ = f.Close() }()
// Persist the declared expected hash so subsequent chunks can be
// cross-checked.
if expectedFinalHash != "" {
if err := os.WriteFile(targetSidecar, []byte(expectedFinalHash), 0640); err != nil {
xlog.Warn("Failed to write target hash sidecar", "path", targetSidecar, "error", err)
}
}
expectedChunkLen := cr.end - cr.start + 1
limited := io.LimitReader(r.Body, expectedChunkLen)
n, err := io.Copy(f, limited)
if err != nil {
xlog.Error("Range upload chunk failed", "key", key, "bytesReceived", n, "expected", expectedChunkLen, "remote", r.RemoteAddr, "error", err)
http.Error(w, fmt.Sprintf("writing file: %v", err), http.StatusInternalServerError)
return
}
if n != expectedChunkLen {
xlog.Error("Range upload chunk short", "key", key, "bytesReceived", n, "expected", expectedChunkLen, "remote", r.RemoteAddr)
http.Error(w, fmt.Sprintf("short body: got %d expected %d", n, expectedChunkLen), http.StatusBadRequest)
return
}
newSize := currentSize + n
// If this chunk does not complete the transfer, return 308 Resume
// Incomplete (semantically aligns with the GCS/Tus resumable convention,
// which most language ecosystems treat as "keep going") and report the
// current size so the client can continue.
if newSize < cr.total {
w.Header().Set("Range", fmt.Sprintf("bytes=0-%d", newSize-1))
w.Header().Set(HeaderFileSize, strconv.FormatInt(newSize, 10))
w.WriteHeader(http.StatusPermanentRedirect) // 308 — "Resume Incomplete"
xlog.Debug("Range upload chunk accepted", "key", key, "newSize", newSize, "total", cr.total)
return
}
// Upload complete — compute the final hash by re-reading the file.
finalHash, err := downloader.CalculateSHA(dstPath)
if err != nil {
xlog.Error("Failed to compute final hash on range upload", "path", dstPath, "error", err)
http.Error(w, fmt.Sprintf("computing final hash: %v", err), http.StatusInternalServerError)
return
}
if expectedFinalHash != "" && !strings.EqualFold(expectedFinalHash, finalHash) {
_ = os.Remove(dstPath)
_ = os.Remove(dstPath + hashSidecarSuffix)
_ = os.Remove(targetSidecar)
xlog.Error("Resumed upload SHA-256 mismatch", "key", key, "expected", expectedFinalHash, "got", finalHash)
http.Error(w, fmt.Sprintf("sha256 mismatch: expected %s got %s", expectedFinalHash, finalHash), http.StatusBadRequest)
return
}
if err := os.WriteFile(dstPath+hashSidecarSuffix, []byte(finalHash), 0640); err != nil {
xlog.Warn("Failed to write hash sidecar", "path", dstPath+hashSidecarSuffix, "error", err)
}
// Clear the in-progress sidecar — upload is committed.
_ = os.Remove(targetSidecar)
xlog.Info("Resumable file upload complete", "key", key, "path", dstPath, "size", newSize, "sha256", finalHash)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"local_path": dstPath}); err != nil {
xlog.Warn("Failed to encode upload response", "error", err)
}
}
// computeAndCacheHash returns the SHA-256 hex digest for filePath.
// It reads a cached sidecar when available and still fresh (sidecar mtime >=
// file mtime), otherwise computes the hash and writes/updates the sidecar.
func computeAndCacheHash(filePath string) (string, error) {
sidecar := filePath + hashSidecarSuffix
fileStat, err := os.Stat(filePath)
if err != nil {
return "", err
}
if sidecarStat, err := os.Stat(sidecar); err == nil && !sidecarStat.ModTime().Before(fileStat.ModTime()) {
if data, err := os.ReadFile(sidecar); err == nil {
h := strings.TrimSpace(string(data))
if len(h) == 64 { // valid hex-encoded SHA-256
return h, nil
}
}
}
hashHex, err := downloader.CalculateSHA(filePath)
if err != nil {
return "", err
}
if err := os.WriteFile(sidecar, []byte(hashHex), 0640); err != nil {
xlog.Warn("Failed to write hash sidecar", "path", sidecar, "error", err)
}
return hashHex, nil
}
func handleDownload(w http.ResponseWriter, r *http.Request, stagingDir, modelsDir, dataDir, key string) {
if key == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}
// Route keyed files to the appropriate directory
targetDir, relName := resolveKeyToDir(key, stagingDir, modelsDir, dataDir)
srcPath := filepath.Join(targetDir, relName)
if err := validatePathInDir(srcPath, targetDir); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f, err := os.Open(srcPath)
if err != nil {
if os.IsNotExist(err) {
http.Error(w, "file not found", http.StatusNotFound)
} else {
http.Error(w, fmt.Sprintf("opening file: %v", err), http.StatusInternalServerError)
}
return
}
defer f.Close()
fi, err := f.Stat()
var size int64
if err != nil {
xlog.Warn("Failed to stat file for transfer", "path", srcPath, "error", err)
} else {
size = fi.Size()
}
w.Header().Set("Content-Type", "application/octet-stream")
written, err := io.Copy(w, f)
if err != nil {
xlog.Warn("Error during file transfer", "path", srcPath, "error", err)
}
xlog.Debug("HTTP file download complete", "key", key, "path", srcPath, "fileSize", size, "bytesSent", written)
}
func handleAllocTemp(w http.ResponseWriter, r *http.Request, stagingDir string) {
if err := os.MkdirAll(stagingDir, 0750); err != nil {
http.Error(w, fmt.Sprintf("creating staging dir: %v", err), http.StatusInternalServerError)
return
}
f, err := os.CreateTemp(stagingDir, "localai-output-*.tmp")
if err != nil {
http.Error(w, fmt.Sprintf("creating temp file: %v", err), http.StatusInternalServerError)
return
}
localPath := f.Name()
f.Close()
xlog.Debug("HTTP allocated temp file", "path", localPath)
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]string{"local_path": localPath}); err != nil {
xlog.Warn("Failed to encode alloc-temp response", "error", err)
}
}
func handleListDir(w http.ResponseWriter, r *http.Request, stagingDir, modelsDir, dataDir, key string) {
if key == "" {
http.Error(w, "key is required", http.StatusBadRequest)
return
}
targetDir, relName := resolveKeyToDir(key, stagingDir, modelsDir, dataDir)
dirPath := filepath.Join(targetDir, relName)
if err := validatePathInDir(dirPath, targetDir); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
info, err := os.Stat(dirPath)
if err != nil || !info.IsDir() {
http.Error(w, "directory not found", http.StatusNotFound)
return
}
var files []string
filepath.WalkDir(dirPath, func(path string, d os.DirEntry, err error) error {
if err != nil {
return nil
}
if !d.IsDir() {
rel, err := filepath.Rel(dirPath, path)
if err == nil {
files = append(files, rel)
}
}
return nil
})
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]any{"files": files}); err != nil {
xlog.Warn("Failed to encode list-files response", "error", err)
}
}
// resolveKeyToDir maps a storage key to the appropriate local directory and
// relative path. Keys prefixed with "models/" route to modelsDir, "data/" to
// dataDir, and everything else to stagingDir.
func resolveKeyToDir(key, stagingDir, modelsDir, dataDir string) (targetDir, relName string) {
targetDir = stagingDir
relName = key
if rel, ok := strings.CutPrefix(key, storage.ModelKeyPrefix); ok && modelsDir != "" {
return modelsDir, rel
}
if rel, ok := strings.CutPrefix(key, storage.DataKeyPrefix); ok && dataDir != "" {
return dataDir, rel
}
return
}
// checkBearerToken validates a Bearer token from the Authorization header
// using constant-time comparison. Returns true if valid or if expectedToken is empty.
func checkBearerToken(r *http.Request, expectedToken string) bool {
if expectedToken == "" {
return true
}
auth := r.Header.Get("Authorization")
if len(auth) < 7 || auth[:7] != "Bearer " {
return false
}
provided := auth[7:]
return subtle.ConstantTimeCompare([]byte(provided), []byte(expectedToken)) == 1
}
// validatePathInDir checks that targetPath is within the given base directory.
func validatePathInDir(targetPath, baseDir string) error {
absBase, err := filepath.Abs(baseDir)
if err != nil {
return fmt.Errorf("resolving base dir: %w", err)
}
realBase, err := filepath.EvalSymlinks(absBase)
if err != nil {
return fmt.Errorf("resolving base dir symlinks: %w", err)
}
absTarget, err := filepath.Abs(targetPath)
if err != nil {
return fmt.Errorf("resolving target path: %w", err)
}
realTarget, err := filepath.EvalSymlinks(absTarget)
if err != nil {
// File may not exist yet (e.g. upload). Walk up to the first
// existing ancestor so platform symlinks (e.g. /tmp → /private/tmp
// on macOS) are resolved even for deeply nested new paths.
remaining := filepath.Base(absTarget)
dir := filepath.Dir(absTarget)
for {
resolved, resolveErr := filepath.EvalSymlinks(dir)
if resolveErr == nil {
realTarget = filepath.Join(resolved, remaining)
break
}
remaining = filepath.Join(filepath.Base(dir), remaining)
parent := filepath.Dir(dir)
if parent == dir {
// Reached filesystem root without resolving
realTarget = filepath.Clean(absTarget)
break
}
dir = parent
}
}
if !strings.HasPrefix(realTarget, realBase+string(filepath.Separator)) && realTarget != realBase {
return fmt.Errorf("path %q is outside allowed directory", targetPath)
}
return nil
}
// ShutdownFileTransferServer gracefully shuts down the HTTP file transfer server.
func ShutdownFileTransferServer(server *http.Server) {
if server == nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // 5 seconds
defer cancel()
if err := server.Shutdown(ctx); err != nil {
xlog.Error("HTTP file transfer server shutdown error", "error", err)
}
}
// registerBackendLogHandlers adds REST and WebSocket endpoints for streaming
// backend process logs from the worker's BackendLogStore.
func registerBackendLogHandlers(mux *http.ServeMux, token string, logStore *model.BackendLogStore) {
wsUpgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
origin := r.Header.Get("Origin")
if origin == "" {
return true // no origin header = same-origin or non-browser
}
// Parse origin URL and compare host with request host
u, err := url.Parse(origin)
if err != nil {
return false
}
return u.Host == r.Host
},
}
// GET /v1/backend-logs — list model IDs with logs
// GET /v1/backend-logs/{modelId} — get buffered log lines
// GET /v1/backend-logs/{modelId}/ws — WebSocket real-time streaming
mux.HandleFunc("/v1/backend-logs", func(w http.ResponseWriter, r *http.Request) {
if !checkBearerToken(r, token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logStore.ListModels())
})
mux.HandleFunc("/v1/backend-logs/", func(w http.ResponseWriter, r *http.Request) {
if !checkBearerToken(r, token) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse: /v1/backend-logs/{modelId} or /v1/backend-logs/{modelId}/ws
rest := strings.TrimPrefix(r.URL.Path, "/v1/backend-logs/")
if rest == "" {
http.Error(w, "model ID required", http.StatusBadRequest)
return
}
// Check for /ws suffix (WebSocket upgrade)
if strings.HasSuffix(rest, "/ws") {
modelID := strings.TrimSuffix(rest, "/ws")
modelID, _ = url.PathUnescape(modelID)
handleBackendLogsWS(w, r, logStore, modelID, &wsUpgrader)
return
}
// REST: return buffered lines
modelID, _ := url.PathUnescape(rest)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(logStore.GetLines(modelID))
})
}
// handleBackendLogsWS serves a WebSocket connection that streams backend log lines
// for a specific model in real time. Follows the same protocol as the standalone
// /ws/backend-logs/:modelId endpoint: sends an initial batch, then streams new lines.
func handleBackendLogsWS(w http.ResponseWriter, r *http.Request, logStore *model.BackendLogStore, modelID string, upgrader *websocket.Upgrader) {
ws, err := upgrader.Upgrade(w, r, nil)
if err != nil {
xlog.Debug("WebSocket upgrade failed for backend-logs", "error", err)
return
}
defer ws.Close()
ws.SetReadLimit(4096)
ws.SetReadDeadline(time.Now().Add(90 * time.Second))
ws.SetPongHandler(func(string) error {
ws.SetReadDeadline(time.Now().Add(90 * time.Second))
return nil
})
conn := &backendLogsWSConn{Conn: ws}
// Send existing lines as initial batch
existingLines := logStore.GetLines(modelID)
initialMsg := map[string]any{
"type": "initial",
"lines": existingLines,
}
if err := conn.writeJSON(initialMsg); err != nil {
xlog.Debug("WebSocket backend-logs initial write failed", "error", err)
return
}
// Subscribe to new lines
lineCh, unsubscribe := logStore.Subscribe(modelID)
defer unsubscribe()
// Handle close from client side
closeCh := make(chan struct{})
go func() {
for {
_, _, err := ws.ReadMessage()
if err != nil {
close(closeCh)
return
}
}
}()
// Ping ticker for keepalive
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
for {
select {
case line, ok := <-lineCh:
if !ok {
return
}
lineMsg := map[string]any{
"type": "line",
"line": line,
}
if err := conn.writeJSON(lineMsg); err != nil {
xlog.Debug("WebSocket backend-logs write error", "error", err)
return
}
case <-pingTicker.C:
if err := conn.writePing(); err != nil {
return
}
case <-closeCh:
return
}
}
}
// backendLogsWSConn wraps a websocket connection with a mutex for safe concurrent writes.
type backendLogsWSConn struct {
*websocket.Conn
mu sync.Mutex
}
func (c *backendLogsWSConn) writeJSON(v any) error {
c.mu.Lock()
defer c.mu.Unlock()
c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
data, err := json.Marshal(v)
if err != nil {
return fmt.Errorf("marshal error: %w", err)
}
return c.Conn.WriteMessage(websocket.TextMessage, data)
}
func (c *backendLogsWSConn) writePing() error {
c.mu.Lock()
defer c.mu.Unlock()
c.Conn.SetWriteDeadline(time.Now().Add(30 * time.Second))
return c.Conn.WriteMessage(websocket.PingMessage, nil)
}