mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 09:08:54 -04:00
feat(addons): run the add-on owner in the normal binary
The owner was only enabled when an embedding host set the add-on config, so a normal run answered every add-on request as unavailable. Add --addons-enabled (default true) and --addons-ownership-mode (default named-user, or local-owner; anything else stops startup). The node is its own owner: state lives under the data path, and the carrier uses loopback keys made at each start and kept in memory. --addons-enabled=false turns the owner off completely. Named-user mode needs the account database. With authentication off the owner stays off and a warning says how to use local-owner. Assisted-by: Claude Code:claude-sonnet-5-5 go Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
5 files changed
+258
No files matched your search
@@ -34,6 +34,8 @@ import (
|
||||
|
||||
type RunCMD struct {
|
||||
AddonHostOps string `name:"addon-host-ops" env:"LOCALAI_ADDON_HOST_OPS" default:"fine-tuning=finetune.,quantization=quantize." help:"Add-ons that may call host operations, as a comma list of addon-id=operation-prefix. An add-on that is not listed gets no host operation channel. Empty gives none" group:"addons"`
|
||||
AddonsEnabled bool `name:"addons-enabled" env:"LOCALAI_ADDONS_ENABLED" default:"true" help:"Run the add-on owner, which installs and supervises add-ons. Turn it off with --addons-enabled=false" group:"addons"`
|
||||
AddonsOwnershipMode string `name:"addons-ownership-mode" env:"LOCALAI_ADDONS_OWNERSHIP_MODE" default:"named-user" help:"Who may use add-ons: named-user (each account has its own access; needs authentication) or local-owner (every caller shares one owner)" group:"addons"`
|
||||
Pprof bool `name:"pprof" env:"LOCALAI_PPROF" default:"false" help:"Enable the private loopback profiling listener" group:"diagnostics"`
|
||||
PprofAddress string `name:"pprof-address" env:"LOCALAI_PPROF_ADDRESS" default:"127.0.0.1:6060" help:"Numeric loopback address and port for profiling" group:"diagnostics"`
|
||||
PprofMutexProfileFraction int `name:"pprof-mutex-profile-fraction" env:"LOCALAI_PPROF_MUTEX_PROFILE_FRACTION" default:"0" help:"Mutex sampling fraction (0 disables sampling); requires pprof" group:"diagnostics"`
|
||||
@@ -654,6 +656,14 @@ func (r *RunCMD) Run(ctx *cliContext.Context) (result error) {
|
||||
|
||||
// Authentication
|
||||
authEnabled := r.AuthEnabled || r.GitHubClientID != "" || r.OIDCClientID != ""
|
||||
addonCfg, err := addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, r.DataPath, authEnabled)
|
||||
if err != nil {
|
||||
cancelRun(nil)
|
||||
return err
|
||||
}
|
||||
if addonCfg.Enabled {
|
||||
opts = append(opts, config.WithAddons(addonCfg))
|
||||
}
|
||||
if authEnabled {
|
||||
opts = append(opts, config.WithAuthEnabled(true))
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/http/auth"
|
||||
"github.com/mudler/LocalAI/core/services/addons/gateway"
|
||||
"github.com/mudler/xlog"
|
||||
)
|
||||
|
||||
// localAddonOwnerID names the one instance of a single-node run. It is also the
|
||||
// owner, so the node supervises the add-ons itself.
|
||||
const localAddonOwnerID = "local"
|
||||
|
||||
// addonConfigFor derives the add-on owner configuration of a single node from
|
||||
// the run flags. Only the on/off switch and the ownership mode are flags; the
|
||||
// rest follows from the node being its own owner.
|
||||
//
|
||||
// The default mode, named-user, needs the account database. Without
|
||||
// authentication there is no such database, so the owner stays off with a
|
||||
// warning instead of stopping the server: local-owner mode is never inferred
|
||||
// and an operator must select it.
|
||||
func addonConfigFor(enabled bool, mode, dataPath string, authEnabled bool) (config.AddonConfig, error) {
|
||||
if !enabled {
|
||||
return config.AddonConfig{}, nil
|
||||
}
|
||||
switch mode {
|
||||
case auth.AddonModeNamedUser, auth.AddonModeLocalOwner:
|
||||
default:
|
||||
return config.AddonConfig{}, fmt.Errorf("--addons-ownership-mode: %q is not valid; use %q or %q", mode, auth.AddonModeNamedUser, auth.AddonModeLocalOwner)
|
||||
}
|
||||
if mode == auth.AddonModeNamedUser && !authEnabled {
|
||||
xlog.Warn("Add-ons are off: named-user ownership needs authentication. Enable authentication, or set --addons-ownership-mode=local-owner to let every caller share the owner's authority")
|
||||
return config.AddonConfig{}, nil
|
||||
}
|
||||
root, err := filepath.Abs(filepath.Join(dataPath, "addons"))
|
||||
if err != nil {
|
||||
return config.AddonConfig{}, err
|
||||
}
|
||||
transport, address, err := gateway.NewLoopbackCarrierConfig(localAddonOwnerID)
|
||||
if err != nil {
|
||||
return config.AddonConfig{}, fmt.Errorf("add-on owner transport: %w", err)
|
||||
}
|
||||
return config.AddonConfig{
|
||||
Enabled: true,
|
||||
InstanceID: localAddonOwnerID, OwnerID: localAddonOwnerID, OwnerAddress: transport.OwnerAddress,
|
||||
StorageID: "local", Root: root, ListenAddress: address, Transport: transport,
|
||||
OwnershipMode: mode,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
cliContext "github.com/mudler/LocalAI/core/cli/context"
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("Add-on owner flags", Serial, func() {
|
||||
parse := func(args ...string) (*RunCMD, error) {
|
||||
command := struct {
|
||||
cliContext.Context `embed:""`
|
||||
Run RunCMD `cmd:"" default:"withargs"`
|
||||
}{}
|
||||
p, err := kong.New(&command, kong.Vars{
|
||||
"basepath": kong.ExpandPath("."),
|
||||
"generatedcontentpath": DefaultGeneratedContentPath(),
|
||||
"uploadpath": DefaultUploadPath(),
|
||||
"galleries": config.DefaultGalleriesJSON,
|
||||
"backends": config.DefaultBackendGalleriesJSON,
|
||||
})
|
||||
if err != nil {
|
||||
return &command.Run, err
|
||||
}
|
||||
_, err = p.Parse(args)
|
||||
return &command.Run, err
|
||||
}
|
||||
BeforeEach(func() {
|
||||
for _, key := range []string{"LOCALAI_ADDONS_ENABLED", "LOCALAI_ADDONS_OWNERSHIP_MODE"} {
|
||||
value, present := os.LookupEnv(key)
|
||||
DeferCleanup(func() {
|
||||
if present {
|
||||
Expect(os.Setenv(key, value)).To(Succeed())
|
||||
} else {
|
||||
Expect(os.Unsetenv(key)).To(Succeed())
|
||||
}
|
||||
})
|
||||
Expect(os.Unsetenv(key)).To(Succeed())
|
||||
}
|
||||
})
|
||||
|
||||
It("turns the owner on by default, in named-user mode", func() {
|
||||
r, err := parse()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.AddonsEnabled).To(BeTrue())
|
||||
Expect(r.AddonsOwnershipMode).To(Equal("named-user"))
|
||||
dir := GinkgoT().TempDir()
|
||||
c, err := addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, dir, true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(c.Enabled).To(BeTrue())
|
||||
Expect(c.InstanceID).To(Equal(c.OwnerID))
|
||||
Expect(c.OwnershipMode).To(Equal("named-user"))
|
||||
Expect(c.SharedStorage).To(BeFalse())
|
||||
Expect(c.StorageID).To(Equal("local"))
|
||||
Expect(c.Root).To(Equal(filepath.Join(dir, "addons")))
|
||||
Expect(c.Transport).NotTo(BeNil())
|
||||
Expect(c.ListenAddress).NotTo(BeEmpty())
|
||||
Expect(c.OwnerAddress).To(Equal("https://" + c.ListenAddress))
|
||||
Expect(c.Transport.OwnerAddress).To(Equal(c.OwnerAddress))
|
||||
})
|
||||
|
||||
It("turns the owner off completely with --addons-enabled=false", func() {
|
||||
r, err := parse("--addons-enabled=false")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.AddonsEnabled).To(BeFalse())
|
||||
c, err := addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, GinkgoT().TempDir(), true)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(c).To(Equal(config.AddonConfig{}))
|
||||
Expect(c.Transport).To(BeNil())
|
||||
})
|
||||
|
||||
It("turns the owner off from the environment", func() {
|
||||
Expect(os.Setenv("LOCALAI_ADDONS_ENABLED", "false")).To(Succeed())
|
||||
r, err := parse()
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(r.AddonsEnabled).To(BeFalse())
|
||||
})
|
||||
|
||||
It("refuses an unknown ownership mode", func() {
|
||||
r, err := parse("--addons-ownership-mode=everyone")
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
_, err = addonConfigFor(r.AddonsEnabled, r.AddonsOwnershipMode, GinkgoT().TempDir(), true)
|
||||
Expect(err).To(MatchError(ContainSubstring("--addons-ownership-mode")))
|
||||
})
|
||||
|
||||
It("accepts local-owner without authentication", func() {
|
||||
c, err := addonConfigFor(true, "local-owner", GinkgoT().TempDir(), false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(c.Enabled).To(BeTrue())
|
||||
Expect(c.OwnershipMode).To(Equal("local-owner"))
|
||||
})
|
||||
|
||||
It("keeps named-user off, without failing, when authentication is off", func() {
|
||||
c, err := addonConfigFor(true, "named-user", GinkgoT().TempDir(), false)
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(c.Enabled).To(BeFalse())
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: MIT
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewLoopbackCarrierConfig builds the carrier identity of a deployment with one
|
||||
// instance, which is its own owner. The keys and the certificate are made fresh
|
||||
// in memory, never written to disk and never leave the process, so the carrier
|
||||
// is reachable only by this process on the loopback interface. It returns the
|
||||
// config and the loopback address the owner listens on.
|
||||
func NewLoopbackCarrierConfig(instanceID string) (*CarrierConfig, string, error) {
|
||||
probe, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
address := probe.Addr().String()
|
||||
_ = probe.Close()
|
||||
caPub, caKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
notBefore := time.Now().Add(-time.Hour)
|
||||
notAfter := time.Now().AddDate(10, 0, 0)
|
||||
ca := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "local addon owner CA"}, NotBefore: notBefore, NotAfter: notAfter, IsCA: true, BasicConstraintsValid: true, KeyUsage: x509.KeyUsageCertSign}
|
||||
der, err := x509.CreateCertificate(rand.Reader, ca, ca, caPub, caKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if ca, err = x509.ParseCertificate(der); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(ca)
|
||||
leafPub, leafKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
leaf := &x509.Certificate{SerialNumber: big.NewInt(2), Subject: pkix.Name{CommonName: instanceID}, NotBefore: notBefore, NotAfter: notAfter, IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, KeyUsage: x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth}}
|
||||
leafDER, err := x509.CreateCertificate(rand.Reader, leaf, ca, leafPub, caKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
pair, err := tls.X509KeyPair(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}))
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
signPub, signKey, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &CarrierConfig{
|
||||
InstanceID: instanceID, OwnerID: instanceID, OwnerAddress: "https://" + address,
|
||||
Certificate: pair, Roots: pool,
|
||||
Pins: map[string][32]byte{instanceID: sha256.Sum256(leafDER)},
|
||||
SigningKey: signKey,
|
||||
VerificationKeys: map[string]ed25519.PublicKey{instanceID: signPub},
|
||||
}, address, nil
|
||||
}
|
||||
@@ -64,6 +64,24 @@ store and forward the typed operation to the owner. The consequences are by desi
|
||||
A deployment with one instance is the owner and has no frontend; everything above
|
||||
still holds.
|
||||
|
||||
## Turning the owner on or off
|
||||
|
||||
A normal `local-ai run` starts the addon owner, so Apps, Add-ons and Tables work
|
||||
without extra setup. The node is its own owner: it keeps addon state under the
|
||||
`addons` folder of the data path (`--data-path`) and reaches the owner over a
|
||||
loopback carrier with keys that are made at each start and never written to disk.
|
||||
|
||||
| Flag | Environment variable | Default | What it does |
|
||||
|---|---|---|---|
|
||||
| `--addons-enabled` | `LOCALAI_ADDONS_ENABLED` | `true` | Runs the addon owner. `--addons-enabled=false` turns it off completely: no owner starts, no addon runs, and every addon request is refused as unavailable. |
|
||||
| `--addons-ownership-mode` | `LOCALAI_ADDONS_OWNERSHIP_MODE` | `named-user` | `named-user` gives each account its own access. `local-owner` makes every caller share one owner. Any other value stops startup with an error. |
|
||||
|
||||
`named-user` needs authentication. If authentication is off, the owner stays off and
|
||||
LocalAI logs a warning; it does not stop. To use addons without accounts, select
|
||||
`--addons-ownership-mode=local-owner` explicitly (see
|
||||
[Explicit local-owner mode](#explicit-local-owner-mode)). The mode is never chosen
|
||||
for you.
|
||||
|
||||
## Manifest
|
||||
|
||||
`ParseManifest` accepts a single JSON object, at most 64 KiB, and rejects unknown
|
||||
|
||||
Reference in new issue
Block a user