899 lines
30 KiB
Go
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)
|
|
}
|