mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
The chat's network view, the administrator view and the line the model reads about the network of its commands now take their name from the capabilities.network an add-on declares, or from the title of its commands tool when it declares none. The sentences no longer say "Sandbox" or "the sandbox". The API gains a network object with the title and the preset titles next to the existing addon field. The text of the built-in bash and of the host-exec setting names an add-on that provides bash instead of one add-on. Assisted-by: Claude Code:claude-sonnet-5-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
307 lines
10 KiB
Go
307 lines
10 KiB
Go
package studio
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"slices"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/services/studio/egress"
|
|
"github.com/mudler/LocalAI/core/services/studio/egress/audit"
|
|
"github.com/mudler/LocalAI/core/services/studio/session"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
)
|
|
|
|
// The routes of the network of a chat's commands: the chat's own
|
|
// view and preset for its owner, and the administrator's view.
|
|
|
|
var (
|
|
// ErrEgressPreset means the preset is not off, registries or all.
|
|
ErrEgressPreset = errors.New("studio: the network preset must be off, registries or all")
|
|
// ErrEgressAboveCeiling means the preset is wider than the administrator
|
|
// allows.
|
|
ErrEgressAboveCeiling = errors.New("studio: the network preset is above the limit an administrator set")
|
|
// ErrEgressNoRule means the chat has no rule for that host and port.
|
|
ErrEgressNoRule = errors.New("studio: the chat has no network rule for this host and port")
|
|
)
|
|
|
|
// The reasons the network settings do not apply to a chat.
|
|
const (
|
|
EgressAppliesHost = "host" // commands run on this machine, with its network
|
|
EgressAppliesNone = "none" // no add-on serves bash, or it does not report that it enforces the policy
|
|
)
|
|
|
|
// EgressView is the network of one chat, as its network panel shows it.
|
|
type EgressView struct {
|
|
Preset, Ceiling, Effective egress.Preset
|
|
// Applies is true when the provider that runs the commands reports that it
|
|
// enforces the network policy. It is false when commands run on this
|
|
// machine, no add-on serves bash, or the provider does not report
|
|
// enforcement; AppliesReason then says which.
|
|
Applies bool
|
|
AppliesReason string
|
|
// Addon is the add-on that runs the chat's commands and carries the
|
|
// network-policy host control, which draws the network row of the chat:
|
|
// set only for a provider that carries it. Without it no add-on draws the controls, and
|
|
// the policy still holds.
|
|
Addon string
|
|
// Network names the network of Addon in text and gives the titles it
|
|
// declares for the presets. Set with Addon.
|
|
Network *AddonNetwork
|
|
Registries []egress.Registry
|
|
// AllowedHosts is the administrator's always-allowed list, canonical.
|
|
AllowedHosts []string
|
|
Rules []EgressRuleView
|
|
Cards []egress.Card
|
|
// CardAbove holds the IDs of the pending cards whose host the ceiling does
|
|
// not admit: no answer can make them work.
|
|
CardAbove map[string]bool
|
|
}
|
|
|
|
// EgressRuleView is a rule with whether the ceiling still admits it.
|
|
type EgressRuleView struct {
|
|
egress.Rule
|
|
AboveLimit bool
|
|
}
|
|
|
|
// registriesView is the registries list with the administrator's extra hosts
|
|
// as one more entry.
|
|
func registriesView(s egress.Settings) []egress.Registry {
|
|
out := append([]egress.Registry(nil), egress.BuiltinRegistries().List...)
|
|
if len(s.ExtraRegistries) > 0 {
|
|
out = append(out, egress.Registry{ID: "extra", Title: "Added by an administrator", Hosts: append([]string(nil), s.ExtraRegistries...)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Egress returns the network of a chat for its owner. The chat of another
|
|
// user is session.ErrNotFound.
|
|
func (s *Service) Egress(ctx context.Context, user, conv string) (EgressView, error) {
|
|
ctx, closeScope := s.executionScope(ctx, principal(user), conv)
|
|
defer closeScope()
|
|
p := principal(user)
|
|
rec, err := s.d.Store.GetChat(ctx, p, conv)
|
|
if err != nil {
|
|
return EgressView{}, err
|
|
}
|
|
set, err := s.egressSettings(ctx)
|
|
if err != nil {
|
|
return EgressView{}, err
|
|
}
|
|
pol, err := s.egressPolicyOf(ctx, rec, "")
|
|
if err != nil {
|
|
return EgressView{}, err
|
|
}
|
|
v := EgressView{
|
|
Preset: pol.chat.Preset, Ceiling: egress.Min(set.Ceiling, egress.PresetAll),
|
|
Effective: egress.Min(pol.chat.Preset, set.Ceiling),
|
|
Registries: registriesView(set),
|
|
AllowedHosts: set.AllowedHostList(),
|
|
Rules: []EgressRuleView{},
|
|
Cards: append([]egress.Card{}, pol.rec.Egress.Cards...),
|
|
}
|
|
v.CardAbove = map[string]bool{}
|
|
for _, c := range v.Cards {
|
|
if c.State == egress.CardPending || c.State == egress.CardRefused {
|
|
req := egress.Request{Host: c.Host, Port: c.Port, Kind: c.Kind, TLS: c.Port == 443 && c.Kind == egress.KindConnect, Private: c.Private}
|
|
if !egress.Admits(set, req, egress.BuiltinRegistries()) {
|
|
v.CardAbove[c.ID] = true
|
|
}
|
|
}
|
|
}
|
|
for _, r := range pol.rec.Egress.Rules {
|
|
above := r.Allow && !egress.Admits(set, egress.Request{Host: r.Host, Port: r.Port, TLS: r.Port == 443}, egress.BuiltinRegistries())
|
|
v.Rules = append(v.Rules, EgressRuleView{Rule: r, AboveLimit: above})
|
|
}
|
|
if v.Preset == "" {
|
|
v.Preset = egress.PresetOff
|
|
}
|
|
cmds, err := s.CommandRunner(ctx, p, conv)
|
|
switch {
|
|
case err != nil:
|
|
v.AppliesReason = EgressAppliesNone
|
|
case cmds.State == CommandsAvailable && (cmds.Provider == BuiltinProvider || cmds.Isolation == "host"):
|
|
v.AppliesReason = EgressAppliesHost
|
|
case cmds.State == CommandsAvailable:
|
|
// Isolation alone does not establish network-policy enforcement: the
|
|
// settings apply only when the provider reports that it enforces them
|
|
// in a status that is available and isolated.
|
|
v.Applies = cmds.ExecutionStatus != nil && cmds.ExecutionStatus.NetworkPolicyEnforced
|
|
if !v.Applies {
|
|
v.AppliesReason = EgressAppliesNone
|
|
}
|
|
if c, ok := s.d.AddonTools.(AddonControls); ok && slices.Contains(c.Controls(ctx, p, cmds.Provider), contract.ControlNetworkPolicy) {
|
|
v.Addon = cmds.Provider
|
|
n := s.networkOf(ctx, p, cmds)
|
|
v.Network = &n
|
|
}
|
|
default:
|
|
v.AppliesReason = EgressAppliesNone
|
|
}
|
|
return v, nil
|
|
}
|
|
|
|
// SetEgressPreset sets the preset of a chat. A preset above the ceiling is
|
|
// refused; a sub-agent follows its parent and has none of its own.
|
|
func (s *Service) SetEgressPreset(ctx context.Context, user, conv string, preset egress.Preset) error {
|
|
p := principal(user)
|
|
if !preset.Valid() {
|
|
return ErrEgressPreset
|
|
}
|
|
rec, err := s.d.Store.GetChat(ctx, p, conv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if rec.Kind == session.KindSubAgent {
|
|
return ErrSubAgentReadOnly
|
|
}
|
|
set, err := s.egressSettings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if preset.Rank() > egress.Min(set.Ceiling, egress.PresetAll).Rank() {
|
|
return ErrEgressAboveCeiling
|
|
}
|
|
if err := s.d.Store.UpdateEgress(ctx, p, conv, func(st *egress.ChatState) error {
|
|
st.Preset = preset
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
s.publishEgress(p, conv, "")
|
|
return nil
|
|
}
|
|
|
|
// DeleteEgressRule removes the allow or deny rule of a host and port.
|
|
func (s *Service) DeleteEgressRule(ctx context.Context, user, conv, host string, port int) error {
|
|
p := principal(user)
|
|
host = canonicalHost(host)
|
|
err := s.d.Store.UpdateEgress(ctx, p, conv, func(st *egress.ChatState) error {
|
|
if !st.DeleteRule(host, port) {
|
|
return ErrEgressNoRule
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.publishEgress(p, conv, "")
|
|
return nil
|
|
}
|
|
|
|
// publishEgress tells every open tab of the chat that its network changed:
|
|
// a card (id) or the rules and the preset (empty id).
|
|
func (s *Service) publishEgress(p, conv, card string) {
|
|
ev := session.Event{Kind: session.KindEgressCard}
|
|
if card != "" {
|
|
ev.IDs = []string{card}
|
|
}
|
|
s.bus.Publish(key(p, conv), ev)
|
|
}
|
|
|
|
// networkOf is the network of the add-on that runs the commands in cmds: what
|
|
// the add-on declares, else the title of its commands tool.
|
|
func (s *Service) networkOf(ctx context.Context, user string, cmds Commands) AddonNetwork {
|
|
n := AddonNetwork{Title: cmds.Title}
|
|
if b, ok := s.d.AddonTools.(AddonNetworks); ok {
|
|
if d, ok := b.Network(ctx, user, cmds.Provider); ok {
|
|
n.Presets = d.Presets
|
|
if d.Title != "" {
|
|
n.Title = d.Title
|
|
}
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// EgressAdmin is the administrator's view of the network of commands.
|
|
type EgressAdmin struct {
|
|
Settings egress.Settings
|
|
AuditDays int
|
|
Registries []egress.Registry
|
|
// Runner is the add-on that runs commands for the administrator, and
|
|
// Isolation how ("isolated", "host" or "" when unknown). Empty Runner:
|
|
// none serves, and the settings apply to nothing yet.
|
|
Runner, Isolation string
|
|
// Network names the network of Runner in text, when one serves.
|
|
Network *AddonNetwork
|
|
Last24h audit.Counts
|
|
}
|
|
|
|
// AdminEgress returns the settings in force, the registries list with the
|
|
// extra hosts, whether an add-on serves and the counts of the last 24 hours.
|
|
func (s *Service) AdminEgress(ctx context.Context, user string) (EgressAdmin, error) {
|
|
set, err := s.egressSettings(ctx)
|
|
if err != nil {
|
|
return EgressAdmin{}, err
|
|
}
|
|
set.Ceiling = egress.Min(set.Ceiling, egress.PresetAll)
|
|
set.Default = set.DefaultPreset()
|
|
if len(set.Ports) == 0 {
|
|
set.Ports = []int{80, 443}
|
|
}
|
|
out := EgressAdmin{Settings: set, Registries: registriesView(set), AuditDays: DefaultEgressAuditDays}
|
|
if s.d.EgressAuditDays != nil {
|
|
if d := s.d.EgressAuditDays(); d >= 1 && d <= 365 {
|
|
out.AuditDays = d
|
|
}
|
|
}
|
|
out.Runner, err = s.servingRunner(ctx, principal(user))
|
|
if err != nil {
|
|
return EgressAdmin{}, err
|
|
}
|
|
if out.Runner != "" {
|
|
if iso, ok := s.d.AddonTools.(CommandIsolation); ok {
|
|
out.Isolation = iso.Isolation(ctx, principal(user), out.Runner)
|
|
}
|
|
n := s.networkOf(ctx, principal(user), Commands{Provider: out.Runner, Title: s.commandTitle(ctx, principal(user), out.Runner)})
|
|
out.Network = &n
|
|
}
|
|
c, err := s.egressAudit().Count(ctx, s.now().Add(-24*time.Hour))
|
|
if err != nil {
|
|
return EgressAdmin{}, err
|
|
}
|
|
out.Last24h = c
|
|
return out, nil
|
|
}
|
|
|
|
// EgressEvents reads the network audit, newest first.
|
|
func (s *Service) EgressEvents(ctx context.Context, q audit.Query) ([]audit.Event, error) {
|
|
return s.egressAudit().Events(ctx, q)
|
|
}
|
|
|
|
// servingRunner is the add-on that runs the commands of the user's chats now:
|
|
// of the running add-ons that replace bash, the pinned one or the oldest
|
|
// install, or "" when none runs or the pinned one is stopped (the built-in
|
|
// serves). It asks the broker, so every frontend of a cluster answers the
|
|
// same, also right after a restart, and an add-on that was removed is gone.
|
|
func (s *Service) servingRunner(ctx context.Context, user string) (string, error) {
|
|
pins, err := s.toolPins(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if s.d.AddonTools == nil {
|
|
return "", nil
|
|
}
|
|
defs, err := s.d.AddonTools.Tools(ctx, user)
|
|
if err != nil {
|
|
return "", nil
|
|
}
|
|
var running []string
|
|
installed := map[string]string{}
|
|
for _, d := range defs {
|
|
if d.Shadows == commandTool && !slices.Contains(running, d.AddonID) {
|
|
running = append(running, d.AddonID)
|
|
installed[d.AddonID] = d.InstalledAt
|
|
}
|
|
}
|
|
if len(running) == 0 {
|
|
return "", nil
|
|
}
|
|
pin := pins[commandTool]
|
|
if s.shadowersFor(ctx, user).pinnedButIdle(commandTool, pin, running) {
|
|
return "", nil
|
|
}
|
|
return ChooseProvider(running, installed, pin), nil
|
|
}
|