diff --git a/core/cli/run.go b/core/cli/run.go index dee32091f..4608d0858 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -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)) diff --git a/core/cli/run_addons.go b/core/cli/run_addons.go new file mode 100644 index 000000000..cbb5e3998 --- /dev/null +++ b/core/cli/run_addons.go @@ -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 +} diff --git a/core/cli/run_addons_test.go b/core/cli/run_addons_test.go new file mode 100644 index 000000000..f5c49bf38 --- /dev/null +++ b/core/cli/run_addons_test.go @@ -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()) + }) +}) diff --git a/core/services/addons/gateway/loopback.go b/core/services/addons/gateway/loopback.go new file mode 100644 index 000000000..566ba4cdf --- /dev/null +++ b/core/services/addons/gateway/loopback.go @@ -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 +} diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index b282b0a1a..9017aecc8 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -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