Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions cmd/launcher/internal/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
// SPDX-License-Identifier: MIT
package launcher

import (
"bytes"
"encoding/json"
"fmt"
)

// Config retains the standalone Go fields for existing launcher callers. The
// JSON representation is exclusively the versioned, separated profile contract.
type Config struct {
SchemaVersion int
Mode string
ModelsPath string
BackendsPath string
Address string
AutoStart *bool
StartOnBoot bool // Deprecated: inert compatibility field.
LogLevel string
EnvironmentVars map[string]string
ShowWelcome *bool
Legacy LegacyConfig
Worker WorkerConfig
}

type LegacyConfig struct {
StartOnBoot *bool `json:"start_on_boot,omitempty"`
}
type SharedConfig struct {
ModelsPath string `json:"models_path"`
BackendsPath string `json:"backends_path"`
LogLevel string `json:"log_level"`
AutoStart *bool `json:"auto_start_server"`
ShowWelcome *bool `json:"show_welcome"`
}
type StandaloneConfig struct {
Address string `json:"address"`
EnvironmentVars map[string]string `json:"environment_vars"`
}
type WorkerConfig struct {
Connection WorkerConnectionConfig `json:"connection"`
NATS WorkerNATSConfig `json:"nats"`
EnvironmentVars map[string]string `json:"environment_vars"`
}
type WorkerConnectionConfig struct {
FrontendURL string `json:"frontend_url"`
RegistrationToken string `json:"registration_token"`
AdvertisedAddress string `json:"advertised_address"`
NodeName string `json:"node_name"`
GRPCMaxPort *int `json:"grpc_max_port,omitempty"`
}
type WorkerNATSConfig struct {
URL string `json:"url"`
TLSCA string `json:"tls_ca"`
TLSCert string `json:"tls_cert"`
TLSKey string `json:"tls_key"`
JWT string `json:"jwt"`
UserSeed string `json:"user_seed"`
}
type configDocument struct {
SchemaVersion int `json:"schema_version"`
Mode string `json:"mode"`
Legacy LegacyConfig `json:"legacy"`
Shared SharedConfig `json:"shared"`
Standalone StandaloneConfig `json:"standalone"`
Worker WorkerConfig `json:"worker"`
}

func (c Config) MarshalJSON() ([]byte, error) {
if c.SchemaVersion != 0 && c.SchemaVersion != 2 {
return nil, fmt.Errorf("unsupported launcher settings version; upgrade the launcher")
}
mode := c.Mode
if mode == "" {
mode = "standalone"
}
if mode != "standalone" && mode != "worker" {
return nil, fmt.Errorf("unknown launcher mode; select a supported mode in settings")
}
legacy := c.Legacy
if legacy.StartOnBoot == nil && c.StartOnBoot {
value := true
legacy.StartOnBoot = &value
}
return json.Marshal(configDocument{2, mode, legacy,
SharedConfig{c.ModelsPath, c.BackendsPath, c.LogLevel, c.AutoStart, c.ShowWelcome},
StandaloneConfig{c.Address, c.EnvironmentVars}, c.Worker})
}

func (c *Config) UnmarshalJSON(data []byte) error {
var d configDocument
if err := json.Unmarshal(data, &d); err != nil {
return fmt.Errorf("invalid launcher settings JSON; repair launcher.json or restore a backup")
}
if bytes.Equal(bytes.TrimSpace(data), []byte("null")) {
return fmt.Errorf("launcher settings must be a JSON object")
}
if d.SchemaVersion != 0 && d.SchemaVersion != 1 && d.SchemaVersion != 2 {
return fmt.Errorf("unsupported launcher settings version; upgrade the launcher")
}
if d.Mode != "" && d.Mode != "standalone" && d.Mode != "worker" {
return fmt.Errorf("unknown launcher mode; select a supported mode in settings")
}
if d.SchemaVersion < 2 {
var legacy struct {
SharedConfig
StandaloneConfig
StartOnBoot *bool `json:"start_on_boot"`
}
if err := json.Unmarshal(data, &legacy); err != nil {
return fmt.Errorf("invalid legacy launcher settings; repair launcher.json or restore a backup")
}
d.Shared, d.Standalone = legacy.SharedConfig, legacy.StandaloneConfig
d.Legacy.StartOnBoot = legacy.StartOnBoot
}
if d.Mode == "" {
d.Mode = "standalone"
}
next := Config{SchemaVersion: 2, Mode: d.Mode, Legacy: d.Legacy, Worker: d.Worker,
ModelsPath: d.Shared.ModelsPath, BackendsPath: d.Shared.BackendsPath, LogLevel: d.Shared.LogLevel,
AutoStart: d.Shared.AutoStart, ShowWelcome: d.Shared.ShowWelcome,
Address: d.Standalone.Address, EnvironmentVars: d.Standalone.EnvironmentVars}
if d.Legacy.StartOnBoot != nil {
next.StartOnBoot = *d.Legacy.StartOnBoot
}
*c = next
return nil
}

func (c *Config) clone() *Config {
if c == nil {
return nil
}
next := *c
copyBool := func(p *bool) *bool {
if p == nil {
return nil
}
v := *p
return &v
}
copyMap := func(m map[string]string) map[string]string {
if m == nil {
return nil
}
n := make(map[string]string, len(m))
for k, v := range m {
n[k] = v
}
return n
}
next.AutoStart, next.ShowWelcome = copyBool(c.AutoStart), copyBool(c.ShowWelcome)
next.Legacy.StartOnBoot = copyBool(c.Legacy.StartOnBoot)
next.EnvironmentVars = copyMap(c.EnvironmentVars)
next.Worker.EnvironmentVars = copyMap(c.Worker.EnvironmentVars)
if c.Worker.Connection.GRPCMaxPort != nil {
v := *c.Worker.Connection.GRPCMaxPort
next.Worker.Connection.GRPCMaxPort = &v
}
return &next
}
182 changes: 182 additions & 0 deletions cmd/launcher/internal/config_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// SPDX-License-Identifier: MIT
package launcher

import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"

"golang.org/x/sys/unix"
)

// Never include filesystem paths or JSON decoder payloads in these errors:
// both may contain credentials supplied in settings.
func settingsIOError(action string) error {
return fmt.Errorf("cannot %s launcher.json securely; ensure ~/.localai is an owned, non-symlink directory writable only by you and launcher.json is an owned regular file (0600); check disk space and permissions", action)
}

func configDirectory() (*os.File, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, settingsIOError("locate")
}
path := filepath.Join(home, ".localai")
if err := os.Mkdir(path, 0700); err != nil && !os.IsExist(err) {
return nil, settingsIOError("create")
}
fd, err := unix.Open(path, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0)
if err != nil {
return nil, settingsIOError("open directory for")
}
dir := os.NewFile(uintptr(fd), "launcher settings directory")
var st unix.Stat_t
if err := unix.Fstat(fd, &st); err != nil || int64(st.Uid) != int64(os.Geteuid()) || st.Mode&0022 != 0 {
dir.Close()
return nil, settingsIOError("trust directory for")
}
return dir, nil
}

// The directory descriptor pins the parent and O_NOFOLLOW prevents following a
// replaced destination. Hard links are refused before chmod to avoid changing
// another file. No permissions on model/backend directories are changed.
func openConfig(dir *os.File) (*os.File, error) {
fd, err := unix.Openat(int(dir.Fd()), "launcher.json", unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_NONBLOCK|unix.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
f := os.NewFile(uintptr(fd), "launcher.json")
var st unix.Stat_t
if err = unix.Fstat(fd, &st); err != nil || st.Mode&unix.S_IFMT != unix.S_IFREG || int64(st.Uid) != int64(os.Geteuid()) || st.Nlink != 1 {
f.Close()
return nil, settingsIOError("trust")
}
if err = f.Chmod(0600); err != nil {
f.Close()
return nil, settingsIOError("protect")
}
if err = unix.Fstat(fd, &st); err != nil || st.Mode&07777 != 0600 {
f.Close()
return nil, settingsIOError("verify permissions for")
}
return f, nil
}

func (l *Launcher) loadConfig() (err error) {
defer func() { l.configError = err }()
dir, err := configDirectory()
if err != nil {
return err
}
defer dir.Close()
f, err := openConfig(dir)
if errors.Is(err, unix.ENOENT) {
return l.saveConfig()
}
if err != nil {
return settingsIOError("read")
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return settingsIOError("read")
}
var candidate Config
if err := json.Unmarshal(data, &candidate); err != nil {
return fmt.Errorf("cannot load launcher settings; repair launcher.json or restore a backup using a supported schema and mode")
}
// Migration must be durable before making this profile eligible for startup.
if err := l.persistConfig(&candidate); err != nil {
return err
}
l.config = &candidate
return nil
}

func (l *Launcher) saveConfig() error {
err := l.persistConfig(l.config)
l.configError = err
return err
}

func (l *Launcher) persistConfig(candidate *Config) error {
if candidate == nil {
return fmt.Errorf("launcher settings are missing")
}
data, err := json.MarshalIndent(candidate, "", " ")
if err != nil {
return fmt.Errorf("cannot save launcher settings: %w", err)
}
dir, err := configDirectory()
if err != nil {
return err
}
defer dir.Close()
existing, err := openConfig(dir)
if err != nil && !errors.Is(err, unix.ENOENT) {
return settingsIOError("replace")
}
if existing != nil {
existing.Close()
}
// A random name with exclusive creation keeps temporary files private;
// openat keeps all operations
// relative to the verified parent, including on Linux and macOS.
name, err := temporaryConfigName()
if err != nil {
return settingsIOError("create temporary")
}
fd, err := unix.Openat(int(dir.Fd()), name, unix.O_WRONLY|unix.O_CREAT|unix.O_EXCL|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0600)
if err != nil {
return settingsIOError("create temporary")
}
defer unix.Unlinkat(int(dir.Fd()), name, 0)
f := os.NewFile(uintptr(fd), "temporary launcher settings")
defer f.Close()
if err := f.Chmod(0600); err != nil {
return settingsIOError("protect temporary")
}
info, err := f.Stat()
if err != nil || info.Mode().Perm() != 0600 {
return settingsIOError("verify temporary permissions for")
}
if _, err := f.Write(data); err != nil {
return settingsIOError("write")
}
if err := f.Sync(); err != nil {
return settingsIOError("flush")
}
if err := f.Close(); err != nil {
return settingsIOError("close temporary")
}
// Recheck immediately before rename. The parent disallows other-user writes;
// protection against the same user or root is not part of this boundary.
existing, err = openConfig(dir)
if err != nil && !errors.Is(err, unix.ENOENT) {
return settingsIOError("replace")
}
if existing != nil {
existing.Close()
}
if err := unix.Renameat(int(dir.Fd()), name, int(dir.Fd()), "launcher.json"); err != nil {
return settingsIOError("replace")
}
candidate.SchemaVersion = 2
if candidate.Mode == "" {
candidate.Mode = "standalone"
}
return nil
}

func temporaryConfigName() (string, error) {
var random [16]byte
if _, err := rand.Read(random[:]); err != nil {
return "", err
}
return ".launcher-" + hex.EncodeToString(random[:]) + ".tmp", nil
}
Loading
Loading