mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-21 21:54:52 -04:00
* feat(credentials): parse and match download credential rules Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep secrets out of parse errors and tighten URL matching Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(credentials): resolve secrets lazily and authenticate HTTP per hop Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): redact secrets in nested and store formatting Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(credentials): add registry keychain and oras credential adapters Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): match repository rules for Docker Hub in the oras adapter Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(downloads): authenticate HTTP downloads and gallery reads from the credentials store Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(oci): authenticate registry pulls, resumes, blobs and cosign from the credentials store Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(cli): load download credentials from --credentials-file Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(credentials): correct the local-network registry rules Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep secrets out of match and YAML parse errors A match that fails to parse is no longer quoted in the Parse error, since it may be a URL with a token in it. Userinfo is detected before the scheme check, so ftp://user:token@host is refused as userinfo, and a match with a query string or fragment is refused because it can never apply and a query string is where signed URLs carry their token. Every YAML decode error is now redacted, not only type errors: quoted scalars such as a secret under a mismatched !!int tag are replaced and unquoted map keys are cut off. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(downloads): make auth errors name the real cause and never retry unresolved secrets AuthError now appends its cause, so a registry's DENIED or UNAUTHORIZED detail reaches the operator. HTTP auth errors print only the status text in place of the cause, because the downloader builds that cause from the requested URL, which can carry a signed query string. Registry pulls say that docker config credentials were tried too, and a download that carried a caller-provided credential (WithBearerToken, or an explicit authorization on gallery reads) reports that credential as rejected instead of blaming the store. The Range probe for a leftover partial file now returns an unresolved secret as a permanent error, like the download request already did. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): keep oras pulls anonymous on a broken docker helper and close bodies When docker config names a credsStore helper that cannot run, the oras credential func now logs at debug and returns no credential, so public pulls keep working as they did before the adapter existed. The transport closes the request body when a rule's secret cannot be resolved, as the RoundTripper contract requires. The redirect spec now uses a custom header rule on the origin, which net/http would not strip on its own, to prove the transport does not carry credentials to the next hop. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(oci): cover FetchImageBlob authentication against a private registry FetchImageBlob now has a spec that pulls a layer blob by digest from a basic-auth registry through the oras credential adapter, and one that shows the same fetch fails when no rule matches. oras only speaks HTTPS here, so the registry serves TLS and the spec points http.DefaultTransport, which retry.DefaultClient falls back to, at the test server's client for its duration instead of adding a transport seam to production code. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(credentials): document auth error wording, ollama manifests and registry tokens The errors section now lists the registry and provided-credential messages and says the server's reason is appended. ollama:// manifests are fetched without credentials, so only blob downloads use the file. GHCR, Docker Hub and Quay need basic auth with the token as password, and match rules must not carry a query string or fragment. The backend gallery docs and the container troubleshooting section now point to the private sources page. Assisted-by: Claude:claude-opus-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(credentials): document trusted file path The credentials path comes from operator configuration. Mark the file read with a scoped G304 explanation to resolve the gosec false positive. Assisted-by: Codex:gpt-6 gosec --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
214 lines
8.7 KiB
Go
214 lines
8.7 KiB
Go
package downloader_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync/atomic"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
|
|
"github.com/mudler/LocalAI/pkg/credentials"
|
|
"github.com/mudler/LocalAI/pkg/downloader"
|
|
)
|
|
|
|
var _ = Describe("downloads with a credentials store", Serial, func() {
|
|
useStore := func(doc string) {
|
|
s, err := credentials.Parse([]byte(doc), func(string) (string, bool) { return "", false })
|
|
Expect(err).NotTo(HaveOccurred())
|
|
prev := credentials.SetDefault(s)
|
|
DeferCleanup(func() { credentials.SetDefault(prev) })
|
|
}
|
|
|
|
requireBearer := func(token string, requests *atomic.Int32) *httptest.Server {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests.Add(1)
|
|
if r.Header.Get("Authorization") != "Bearer "+token {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte("- name: private-model\n"))
|
|
}))
|
|
DeferCleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
readGallery := func(rawURL string) (string, error) {
|
|
var body string
|
|
err := downloader.URI(rawURL).ReadWithAuthorizationAndCallback(context.Background(), GinkgoT().TempDir(), "",
|
|
func(_ string, b []byte) error {
|
|
body = string(b)
|
|
return nil
|
|
})
|
|
return body, err
|
|
}
|
|
|
|
It("authenticates a gallery index read", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("gallery-token", &requests)
|
|
useStore(fmt.Sprintf("- match: %s\n bearer: gallery-token\n allow_insecure: true\n", srv.URL))
|
|
|
|
body, err := readGallery(srv.URL + "/index.yaml")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(body).To(ContainSubstring("private-model"))
|
|
})
|
|
|
|
It("authenticates a file download", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("file-token", &requests)
|
|
useStore(fmt.Sprintf("- match: %s/models\n bearer: file-token\n allow_insecure: true\n", srv.URL))
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
Expect(downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil)).To(Succeed())
|
|
data, err := os.ReadFile(target)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(data)).To(ContainSubstring("private-model"))
|
|
})
|
|
|
|
It("reports a missing rule on 401 without retrying", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("file-token", &requests)
|
|
useStore("")
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
err := downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil)
|
|
var authErr *credentials.AuthError
|
|
Expect(errors.As(err, &authErr)).To(BeTrue(), "got %v", err)
|
|
Expect(authErr.Match).To(BeEmpty())
|
|
Expect(authErr.Status).To(Equal(http.StatusUnauthorized))
|
|
Expect(requests.Load()).To(BeEquivalentTo(1))
|
|
})
|
|
|
|
It("names the rule a gallery server rejected", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("right-token", &requests)
|
|
useStore(fmt.Sprintf("- match: %s\n bearer: wrong-token\n allow_insecure: true\n", srv.URL))
|
|
|
|
_, err := readGallery(srv.URL + "/index.yaml")
|
|
var authErr *credentials.AuthError
|
|
Expect(errors.As(err, &authErr)).To(BeTrue(), "got %v", err)
|
|
Expect(authErr.Match).To(Equal(srv.URL))
|
|
Expect(err.Error()).NotTo(ContainSubstring("wrong-token"))
|
|
})
|
|
|
|
It("does not treat an unreadable secret as a retryable network error", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("file-token", &requests)
|
|
useStore(fmt.Sprintf("- match: %s\n bearer_env: MISSING_TOKEN\n allow_insecure: true\n", srv.URL))
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
err := downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil)
|
|
Expect(err).To(MatchError(ContainSubstring("MISSING_TOKEN")))
|
|
Expect(errors.Is(err, credentials.ErrUnresolvedSecret)).To(BeTrue())
|
|
Expect(downloader.IsRetryable(context.Background(), err)).To(BeFalse())
|
|
Expect(requests.Load()).To(BeEquivalentTo(0))
|
|
})
|
|
|
|
It("does not retry an unreadable secret met while probing a resume", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("file-token", &requests)
|
|
useStore(fmt.Sprintf("- match: %s\n bearer_env: MISSING_TOKEN\n allow_insecure: true\n", srv.URL))
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
Expect(os.WriteFile(target+".partial", []byte("- name: priv"), 0o600)).To(Succeed())
|
|
err := downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil)
|
|
Expect(errors.Is(err, credentials.ErrUnresolvedSecret)).To(BeTrue(), "got %v", err)
|
|
Expect(downloader.IsRetryable(context.Background(), err)).To(BeFalse())
|
|
Expect(requests.Load()).To(BeEquivalentTo(0))
|
|
})
|
|
|
|
It("lets an explicit bearer token win over a matching rule", func() {
|
|
var seen string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte("- name: private-model\n"))
|
|
}))
|
|
DeferCleanup(srv.Close)
|
|
useStore(fmt.Sprintf("- match: %s\n bearer: store-token\n allow_insecure: true\n", srv.URL))
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
Expect(downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil,
|
|
downloader.WithBearerToken("explicit-token"))).To(Succeed())
|
|
Expect(seen).To(Equal("Bearer explicit-token"))
|
|
})
|
|
|
|
It("blames the provided bearer token, not the store, when it is rejected", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("right-token", &requests)
|
|
rule := srv.URL + "/models"
|
|
useStore(fmt.Sprintf("- match: %s\n bearer: store-token\n allow_insecure: true\n", rule))
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
err := downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil,
|
|
downloader.WithBearerToken("wrong-token"))
|
|
Expect(err).To(MatchError(ContainSubstring("provided credential was rejected")))
|
|
Expect(err).To(MatchError(ContainSubstring("status 401")))
|
|
Expect(err.Error()).NotTo(ContainSubstring(fmt.Sprintf("credential %q", rule)))
|
|
Expect(err.Error()).NotTo(ContainSubstring("credentials rule"))
|
|
Expect(err.Error()).NotTo(ContainSubstring("wrong-token"))
|
|
var authErr *credentials.AuthError
|
|
Expect(errors.As(err, &authErr)).To(BeTrue(), "got %v", err)
|
|
Expect(authErr.Match).To(BeEmpty())
|
|
})
|
|
|
|
It("blames the provided authorization, not the store, when a gallery rejects it", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("right-token", &requests)
|
|
useStore(fmt.Sprintf("- match: %s\n bearer: store-token\n allow_insecure: true\n", srv.URL))
|
|
|
|
err := downloader.URI(srv.URL+"/index.yaml").ReadWithAuthorizationAndCallback(context.Background(), GinkgoT().TempDir(), "Bearer wrong-token",
|
|
func(string, []byte) error { return nil })
|
|
Expect(err).To(MatchError(ContainSubstring("provided credential was rejected")))
|
|
Expect(err.Error()).NotTo(ContainSubstring("no credentials rule"))
|
|
Expect(err.Error()).NotTo(ContainSubstring("wrong-token"))
|
|
})
|
|
|
|
It("keeps signed query strings out of auth errors", func() {
|
|
var requests atomic.Int32
|
|
srv := requireBearer("right-token", &requests)
|
|
useStore("")
|
|
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
err := downloader.URI(srv.URL+"/models/model.yaml?X-Amz-Signature=topsecret").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).NotTo(ContainSubstring("topsecret"))
|
|
})
|
|
|
|
It("behaves as before with no store installed", func() {
|
|
var seen []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = append(seen, r.Header.Get("Authorization"))
|
|
_, _ = w.Write([]byte("- name: public\n"))
|
|
}))
|
|
DeferCleanup(srv.Close)
|
|
prev := credentials.SetDefault(nil)
|
|
DeferCleanup(func() { credentials.SetDefault(prev) })
|
|
|
|
_, err := readGallery(srv.URL + "/index.yaml")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
target := filepath.Join(GinkgoT().TempDir(), "model.yaml")
|
|
Expect(downloader.URI(srv.URL+"/models/model.yaml").DownloadFileWithContext(context.Background(), target, "", 0, 1, nil,
|
|
downloader.WithBearerToken("explicit-token"))).To(Succeed())
|
|
Expect(seen).To(Equal([]string{"", "Bearer explicit-token"}))
|
|
})
|
|
|
|
It("keeps anonymous downloads anonymous when no rule matches", func() {
|
|
var seen string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
seen = r.Header.Get("Authorization")
|
|
_, _ = w.Write([]byte("- name: public\n"))
|
|
}))
|
|
DeferCleanup(srv.Close)
|
|
useStore("- match: https://elsewhere.example.com\n bearer: unrelated\n")
|
|
|
|
_, err := readGallery(srv.URL + "/index.yaml")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(seen).To(BeEmpty())
|
|
})
|
|
})
|