mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
Add the add-on runtime to LocalAI: manifests, the host operation broker, admin gating by account role, the add-on UI bundle builder and the SDK for add-on pages. Move the navigation to Chat, Models, Add-ons and Settings, with pinned add-ons in the sidebar and an Add-ons drawer. Ship the first-party add-ons (voice, face, fine-tuning, quantization) as packages for linux/amd64, linux/arm64 and darwin/arm64. Intel macOS (darwin/amd64) is enabled in the platform files. Squashed from the review branch. The tree is unchanged; the pre-squash history is kept on a local backup branch. Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
339 lines
14 KiB
Go
339 lines
14 KiB
Go
// SPDX-License-Identifier: MIT
|
|
//go:build auth
|
|
|
|
package application
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/pem"
|
|
"errors"
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/http/auth"
|
|
"github.com/mudler/LocalAI/core/services/addons"
|
|
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
|
"github.com/mudler/LocalAI/core/services/addons/policy"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
"github.com/mudler/LocalAI/pkg/system"
|
|
"github.com/mudler/xlog"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
"gorm.io/gorm"
|
|
"log/slog"
|
|
"math/big"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
)
|
|
|
|
// ownerTransport builds the smallest real transport identity of an owner: one
|
|
// pinned certificate, one signing key and a loopback listen address.
|
|
func ownerTransport() *gateway.CarrierConfig {
|
|
pub, key, e := ed25519.GenerateKey(rand.Reader)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "test CA"}, NotBefore: time.Now().Add(-time.Hour), NotAfter: time.Now().Add(time.Hour), IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
|
der, e := x509.CreateCertificate(rand.Reader, ca, ca, pub, key)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
ca, e = x509.ParseCertificate(der)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
pool := x509.NewCertPool()
|
|
pool.AddCert(ca)
|
|
lp, lk, e := ed25519.GenerateKey(rand.Reader)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: "owner"}, NotBefore: ca.NotBefore, NotAfter: ca.NotAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
|
|
d, e := x509.CreateCertificate(rand.Reader, leaf, ca, lp, key)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
kd, e := x509.MarshalPKCS8PrivateKey(lk)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
pair, e := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: d}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: kd}))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
sp, sk, e := ed25519.GenerateKey(rand.Reader)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
return &gateway.CarrierConfig{InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", Certificate: pair, Roots: pool,
|
|
Pins: map[string][32]byte{"owner": sha256.Sum256(d)}, SigningKey: sk, VerificationKeys: map[string]ed25519.PublicKey{"owner": sp}}
|
|
}
|
|
|
|
type logCapture struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (l *logCapture) Write(p []byte) (int, error) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
return l.buf.Write(p)
|
|
}
|
|
func (l *logCapture) String() string { l.mu.Lock(); defer l.mu.Unlock(); return l.buf.String() }
|
|
|
|
var _ = Describe("addon policy bootstrap", func() {
|
|
setup := func() *Application {
|
|
return &Application{applicationConfig: &config.ApplicationConfig{Context: context.Background()}}
|
|
}
|
|
base := func() config.AddonConfig {
|
|
c := config.AddonConfig{Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local", Root: filepath.Join(realTempDir(), "addons"), ListenAddress: "127.0.0.1:0", Transport: ownerTransport()}
|
|
return c
|
|
}
|
|
authDB := func() *gorm.DB {
|
|
db, e := auth.InitDB(filepath.Join(GinkgoT().TempDir(), "auth.db"))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
return db
|
|
}
|
|
start := func(a *Application, c config.AddonConfig) error {
|
|
a.applicationConfig.Addons = c
|
|
return a.startAddons()
|
|
}
|
|
namedActor := func(db *gorm.DB) contract.Actor {
|
|
Expect(db.Create(&auth.User{ID: "alice", Role: auth.RoleAdmin, Status: auth.StatusActive}).Error).To(Succeed())
|
|
_, key, e := auth.CreateAPIKey(db, "alice", "test", auth.RoleAdmin, "secret", nil)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
return contract.Actor{UserID: "alice", CredentialID: "key:" + key.ID, Mode: "named-user"}
|
|
}
|
|
policyDir := func(c config.AddonConfig) string { return filepath.Join(c.Root, "policy") }
|
|
|
|
It("opens the policy on the owner and keeps frontends and hosts without it denying", func() {
|
|
requireAddonRuntime()
|
|
c := base()
|
|
a := setup()
|
|
a.authDB = authDB()
|
|
Expect(start(a, c)).To(Succeed())
|
|
DeferCleanup(a.Shutdown)
|
|
Expect(a.AddonGateway()).NotTo(BeNil())
|
|
Expect(a.AddonGateway().Admission).NotTo(BeNil())
|
|
Expect(a.AddonGateway().Resolver).NotTo(BeNil())
|
|
Expect(a.AddonPolicy()).NotTo(BeNil())
|
|
// The owner holds the store: a second opener is refused.
|
|
_, e := policy.OpenStore(policyDir(c), policy.ModeNamedUser)
|
|
Expect(e).To(MatchError(policy.ErrStoreBusy))
|
|
// Unknown resources deny through the installed provider.
|
|
_, e = a.AddonGateway().Dispatch(context.Background(), namedActor(a.authDB), contract.Request{AddonID: "sample", ResourceID: "nope", OperationID: "op", Generation: 1, Action: "read", Arguments: []byte(`{}`)})
|
|
Expect(errors.Is(e, gateway.ErrForbidden)).To(BeTrue())
|
|
|
|
// A frontend forwards to the owner and never opens or even creates the store.
|
|
f := base()
|
|
f.InstanceID = "frontend"
|
|
f.Transport.InstanceID = "frontend"
|
|
f.Transport.Pins["frontend"] = f.Transport.Pins["owner"]
|
|
f.Root = filepath.Join(realTempDir(), "frontend")
|
|
b := setup()
|
|
b.authDB = authDB()
|
|
Expect(start(b, f)).To(Succeed())
|
|
DeferCleanup(b.Shutdown)
|
|
Expect(b.AddonGateway()).NotTo(BeNil())
|
|
Expect(b.AddonGateway().Admission).To(BeNil())
|
|
Expect(b.AddonGateway().Resolver).To(BeNil())
|
|
Expect(b.AddonPolicy()).To(BeNil())
|
|
Expect(policyDir(f)).NotTo(BeADirectory())
|
|
|
|
// A host with no transport has no gateway and no policy.
|
|
n := base()
|
|
n.Transport = nil
|
|
n.AdminCheck = func(context.Context, contract.Actor) error { return nil }
|
|
n.Root = filepath.Join(realTempDir(), "plain")
|
|
h := setup()
|
|
Expect(start(h, n)).To(Succeed())
|
|
DeferCleanup(h.Shutdown)
|
|
Expect(h.AddonGateway()).To(BeNil())
|
|
Expect(h.AddonPolicy()).To(BeNil())
|
|
Expect(policyDir(n)).NotTo(BeADirectory())
|
|
})
|
|
|
|
It("requires the addon to be installed and enabled before a resource can be registered", func() {
|
|
requireAddonRuntime()
|
|
c := base()
|
|
a := setup()
|
|
a.authDB = authDB()
|
|
Expect(start(a, c)).To(Succeed())
|
|
DeferCleanup(a.Shutdown)
|
|
actor := namedActor(a.authDB)
|
|
// An addon that was never installed cannot own resources.
|
|
_, e := a.AddonPolicy().Register(context.Background(), actor, "ghost")
|
|
Expect(errors.Is(e, gateway.ErrForbidden)).To(BeTrue())
|
|
// The decision follows the manager's installed and enabled state.
|
|
status := &statusFixture{}
|
|
authority := addonCreateAuthority(status, func(context.Context, contract.Actor) error { return nil })
|
|
for _, row := range []struct {
|
|
name string
|
|
status addons.Status
|
|
err error
|
|
want int
|
|
}{
|
|
{"absent", addons.Status{}, nil, 403},
|
|
{"installed but disabled", addons.Status{Installed: true}, nil, 403},
|
|
{"enabled", addons.Status{Installed: true, DesiredEnabled: true, StartEligible: true}, nil, 0},
|
|
{"change in progress", addons.Status{Installed: true, DesiredEnabled: true}, nil, 403},
|
|
{"journal needs recovery", addons.Status{}, addons.ErrRecoveryRequired, 503},
|
|
} {
|
|
status.s, status.err = row.status, row.err
|
|
err := authority(context.Background(), actor, "sample")
|
|
if row.want == 0 {
|
|
Expect(err).NotTo(HaveOccurred(), row.name)
|
|
continue
|
|
}
|
|
Expect(err).To(HaveOccurred(), row.name)
|
|
Expect(gateway.StatusFor(err)).To(Equal(row.want), row.name)
|
|
}
|
|
Expect(addonCreateAuthority(nil, func(context.Context, contract.Actor) error { return nil })(context.Background(), actor, "sample")).NotTo(Succeed())
|
|
})
|
|
|
|
It("reconciles registrations left pending before the owner starts serving", func() {
|
|
requireAddonRuntime()
|
|
c := base()
|
|
Expect(os.MkdirAll(c.Root, 0o700)).To(Succeed())
|
|
store, e := policy.OpenStore(policyDir(c), policy.ModeNamedUser)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
allow := func(context.Context, contract.Actor) error { return nil }
|
|
p := policy.New(store, policy.Options{Revalidate: allow, CreateAuthority: func(context.Context, contract.Actor, string) error { return nil }})
|
|
db := authDB()
|
|
actor := namedActor(db)
|
|
kept, e := p.Register(context.Background(), actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(p.Commit(context.Background(), actor, kept)).To(Succeed())
|
|
pending, e := p.Register(context.Background(), actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(store.Close()).To(Succeed())
|
|
|
|
a := setup()
|
|
a.authDB = db
|
|
Expect(start(a, c)).To(Succeed())
|
|
DeferCleanup(a.Shutdown)
|
|
// Committed work stays; the host default drops a registration whose
|
|
// backing resource nobody can confirm.
|
|
Expect(a.AddonPolicy().Authorize(context.Background(), actor, kept, policy.ActionRead)).To(Succeed())
|
|
Expect(a.AddonPolicy().Commit(context.Background(), actor, pending)).NotTo(Succeed())
|
|
Expect(a.applicationConfig.Addons.OwnershipMode).To(BeEmpty())
|
|
})
|
|
|
|
It("starts with a pending registration whose addon cannot answer and settles it when the addon is ready", func() {
|
|
requireAddonRuntime()
|
|
c := base()
|
|
Expect(os.MkdirAll(c.Root, 0o700)).To(Succeed())
|
|
store, e := policy.OpenStore(policyDir(c), policy.ModeNamedUser)
|
|
Expect(e).NotTo(HaveOccurred())
|
|
allow := func(context.Context, contract.Actor) error { return nil }
|
|
p := policy.New(store, policy.Options{Revalidate: allow, CreateAuthority: func(context.Context, contract.Actor, string) error { return nil }})
|
|
db := authDB()
|
|
actor := namedActor(db)
|
|
kept, e := p.Register(context.Background(), actor, "sample")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(p.Commit(context.Background(), actor, kept)).To(Succeed())
|
|
pending, e := p.Register(context.Background(), actor, "tables")
|
|
Expect(e).NotTo(HaveOccurred())
|
|
Expect(store.Close()).To(Succeed())
|
|
|
|
var ready atomic.Bool
|
|
ticks := make(chan time.Time)
|
|
a := setup()
|
|
a.authDB = db
|
|
a.addonReconcileTick = ticks
|
|
a.addonResourceExists = func(context.Context, policy.ResourceKey) (bool, error) {
|
|
if !ready.Load() {
|
|
return false, policy.ErrUnavailable
|
|
}
|
|
return true, nil
|
|
}
|
|
// A disabled or broken addon makes the check unavailable: the host
|
|
// still starts, the committed resource stays and the record is kept.
|
|
Expect(start(a, c)).To(Succeed())
|
|
DeferCleanup(a.Shutdown)
|
|
Expect(a.AddonPolicy().Authorize(context.Background(), actor, kept, policy.ActionRead)).To(Succeed())
|
|
ids, _ := a.AddonPolicy().Pending()
|
|
Expect(ids).To(Equal([]string{pending.ResourceID}))
|
|
Expect(a.AddonPolicy().Authorize(context.Background(), actor, pending, policy.ActionRead)).NotTo(Succeed())
|
|
// The addon becomes ready: the retry settles the kept record.
|
|
ready.Store(true)
|
|
ticks <- time.Time{}
|
|
Eventually(func() error {
|
|
return a.AddonPolicy().Authorize(context.Background(), actor, pending, policy.ActionRead)
|
|
}).Should(Succeed())
|
|
ids, _ = a.AddonPolicy().Pending()
|
|
Expect(ids).To(BeEmpty())
|
|
})
|
|
|
|
It("keeps named-user mode by default and selects local-owner only explicitly with a warning", func() {
|
|
requireAddonRuntime()
|
|
logs := &logCapture{}
|
|
xlog.SetLogger(xlog.NewLoggerWithHandler(slog.NewTextHandler(logs, &slog.HandlerOptions{Level: slog.LevelWarn}), xlog.LogLevelWarn))
|
|
DeferCleanup(func() { xlog.SetLogger(xlog.NewLogger(xlog.LogLevel("info"), "text")) })
|
|
|
|
named := setup()
|
|
named.authDB = authDB()
|
|
Expect(start(named, base())).To(Succeed())
|
|
DeferCleanup(named.Shutdown)
|
|
Expect(named.AddonOwnershipMode()).To(Equal("named-user"))
|
|
Expect(logs.String()).NotTo(ContainSubstring("local-owner"))
|
|
// Named mode still refuses to run without the account database.
|
|
noDB := setup()
|
|
Expect(start(noDB, base())).NotTo(Succeed())
|
|
|
|
c := base()
|
|
c.OwnershipMode = "local-owner"
|
|
a := setup() // no account database: local-owner mode does not need one
|
|
Expect(start(a, c)).To(Succeed())
|
|
DeferCleanup(a.Shutdown)
|
|
Expect(a.AddonOwnershipMode()).To(Equal("local-owner"))
|
|
Expect(logs.String()).To(ContainSubstring("local-owner"))
|
|
Expect(logs.String()).To(ContainSubstring("unauthenticated callers share"))
|
|
Expect(logs.String()).To(ContainSubstring("can reach this port can confirm addon proposals"))
|
|
local := contract.Actor{UserID: "local-owner", CredentialID: "local-owner", Mode: "local-owner"}
|
|
key, e := a.AddonPolicy().Register(context.Background(), local, "ghost")
|
|
Expect(e).To(HaveOccurred(), "creation authority still needs an installed addon")
|
|
_ = key
|
|
// Sharing is disabled by the store mode.
|
|
Expect(a.AddonPolicy().SetGrant(context.Background(), local, policy.ResourceKey{AddonID: "sample", ResourceID: "x"}, "bob", policy.RoleViewer)).NotTo(Succeed())
|
|
|
|
bad := base()
|
|
bad.OwnershipMode = "shared"
|
|
Expect(start(setup(), bad)).NotTo(Succeed())
|
|
})
|
|
})
|
|
|
|
type statusFixture struct {
|
|
s addons.Status
|
|
err error
|
|
}
|
|
|
|
func (f *statusFixture) Status(context.Context, string) (addons.Status, error) { return f.s, f.err }
|
|
|
|
var _ = Describe("addon owner in a full application start", func() {
|
|
It("starts the named-user gateway when authentication is enabled", func() {
|
|
requireAddonRuntime()
|
|
dir := realTempDir()
|
|
models, backends := filepath.Join(dir, "models"), filepath.Join(dir, "backends")
|
|
Expect(os.Mkdir(models, 0o750)).To(Succeed())
|
|
Expect(os.Mkdir(backends, 0o750)).To(Succeed())
|
|
state, e := system.GetSystemState(system.WithBackendPath(backends), system.WithModelPath(models))
|
|
Expect(e).NotTo(HaveOccurred())
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
DeferCleanup(cancel)
|
|
root := filepath.Join(dir, "addons")
|
|
DeferCleanup(func() {
|
|
_ = filepath.Walk(root, func(p string, i os.FileInfo, e error) error {
|
|
if e == nil && i.IsDir() {
|
|
_ = os.Chmod(p, 0700)
|
|
}
|
|
return nil
|
|
})
|
|
})
|
|
owner := config.AddonConfig{Enabled: true, InstanceID: "owner", OwnerID: "owner", OwnerAddress: "https://owner.example", StorageID: "local", Root: root, ListenAddress: "127.0.0.1:0", Transport: ownerTransport()}
|
|
a, e := New(
|
|
config.WithContext(ctx), config.WithSystemState(state), config.WithDataPath(dir),
|
|
config.WithAuthEnabled(true), config.WithAuthDatabaseURL(filepath.Join(dir, "auth.db")), config.WithAuthAPIKeyHMACSecret("secret"),
|
|
config.WithAddons(owner),
|
|
)
|
|
Expect(e).NotTo(HaveOccurred(), "the account database must exist before the owner starts: named-user mode checks it")
|
|
DeferCleanup(a.Shutdown)
|
|
Expect(a.AuthDB()).NotTo(BeNil())
|
|
Expect(a.AddonGateway()).NotTo(BeNil())
|
|
Expect(a.AddonManager()).NotTo(BeNil())
|
|
})
|
|
})
|