internal/autogen/appsettings.go
Rama main · réplica de lectura del repositorio original.
package autogen
// Process-level settings: the ones that used to be reachable only as command
// line flags, so a packaged install (double-clicked, no start.cmd) had no way to
// change its own ports or its remote-access policy.
//
// These are NOT part of Settings and never reach the generated config. They are
// consumed by main() BEFORE the config exists — the listen address decides which
// socket gets bound, which is settled long before any model is sized — so they
// live in their own block, with their own loader that reads the control files
// directly rather than going through LoadGenerateFile.
//
// Precedence, highest first:
//
// argv > sidecar app block (dashboard) > settings.app (generate file) > built-in default
//
// argv wins because a flag is how you rescue an install whose stored listen
// address no longer works: `quartermaster.exe -listen 127.0.0.1:1250` must come
// up regardless of what the dashboard last saved.
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// AppSettings is the process-level block. Every field is optional: a zero value
// means "not configured here", and the caller falls through to its own default.
// The three tri-state flags are pointers because false is a real choice for each
// — "don't check for updates" must be distinguishable from "unset".
type AppSettings struct {
// Listen is the main API + dashboard address ("host:port"). Changing it
// requires a restart: the socket is bound once at startup.
Listen string `yaml:"listen,omitempty"`
// PlaygroundListen is the extra address serving the standalone playground
// app. Empty means no playground listener. Restart to apply.
PlaygroundListen string `yaml:"playgroundListen,omitempty"`
// AdminAllow is a comma-separated list of extra IPs/CIDRs allowed to reach
// the dashboard/admin endpoints when listening beyond loopback — e.g.
// "100.64.0.0/10" for a tailnet. Restart to apply.
AdminAllow string `yaml:"adminAllow,omitempty"`
// AdminOpen serves the unauthenticated dashboard to EVERY remote host. It is
// the legacy behaviour and a genuine exposure: the admin surface has no
// password, so this hands config editing and the model hub to anyone who can
// reach the port. Restart to apply.
AdminOpen *bool `yaml:"adminOpen,omitempty"`
// TlsCertFile and TlsKeyFile serve the API and dashboard over HTTPS. Both
// are required together; the server refuses to start with one of them. Paths
// are read by the server process, so they must be readable by the account it
// runs as. Restart to apply.
TlsCertFile string `yaml:"tlsCertFile,omitempty"`
TlsKeyFile string `yaml:"tlsKeyFile,omitempty"`
// WatchModels re-scans the models folder periodically and hot-reloads when it
// changes. Defaults to on.
WatchModels *bool `yaml:"watchModels,omitempty"`
// WatchModelsIntervalSec is that poll interval. 0 => the caller's default.
WatchModelsIntervalSec int `yaml:"watchModelsIntervalSec,omitempty"`
// UpdateCheck polls GitHub for new releases. Defaults to on, and is a no-op
// outside a release build regardless.
UpdateCheck *bool `yaml:"updateCheck,omitempty"`
// HfToken authenticates Hugging Face requests (gated repos, higher rate
// limits). The HF_TOKEN / HUGGING_FACE_HUB_TOKEN environment variables still
// win, so a shell that already exports one is not silently overridden.
HfToken string `yaml:"hfToken,omitempty"`
}
// appSettingsFile is the minimal shape needed to pull settings.app out of the
// generate file.
//
// Deliberately not LoadGenerateFile: that applies every sizing default and
// merges the whole sidecar, and it is called with a models dir the caller has
// not resolved yet at this point in startup. This block is read before any of
// that exists, so it parses the two files on its own terms.
type appSettingsFile struct {
Settings struct {
App AppSettings `yaml:"app"`
} `yaml:"settings"`
}
// LoadAppSettings resolves the process-level block: the generate file's
// settings.app, with the dashboard-owned sidecar block layered on top.
// A missing file at either level is not an error — it means "nothing set".
func LoadAppSettings(generatePath string) (AppSettings, error) {
var out AppSettings
if generatePath != "" {
data, err := os.ReadFile(generatePath)
switch {
case err == nil:
var gf appSettingsFile
if err := yaml.Unmarshal(data, &gf); err != nil {
return AppSettings{}, fmt.Errorf("parsing %s: %w", generatePath, err)
}
out = gf.Settings.App
case !os.IsNotExist(err):
return AppSettings{}, err
}
}
sc, err := loadSidecar(generatePath)
if err != nil {
return AppSettings{}, err
}
if sc.App != nil {
out = mergeAppSettings(out, *sc.App)
}
return out, nil
}
// mergeAppSettings layers next over base, field by field: an unset field in next
// inherits base. Written out rather than reflected because "unset" differs by
// field — "" for the strings, 0 for the interval, nil for the tri-state flags —
// and collapsing that into one rule is how a legitimate empty AdminAllow (the
// user clearing their tailnet range) turns back into the old value.
func mergeAppSettings(base, next AppSettings) AppSettings {
out := base
if next.Listen != "" {
out.Listen = next.Listen
}
if next.PlaygroundListen != "" {
out.PlaygroundListen = next.PlaygroundListen
}
if next.AdminAllow != "" {
out.AdminAllow = next.AdminAllow
}
if next.AdminOpen != nil {
out.AdminOpen = next.AdminOpen
}
if next.TlsCertFile != "" {
out.TlsCertFile = next.TlsCertFile
}
if next.TlsKeyFile != "" {
out.TlsKeyFile = next.TlsKeyFile
}
if next.WatchModels != nil {
out.WatchModels = next.WatchModels
}
if next.WatchModelsIntervalSec != 0 {
out.WatchModelsIntervalSec = next.WatchModelsIntervalSec
}
if next.UpdateCheck != nil {
out.UpdateCheck = next.UpdateCheck
}
if next.HfToken != "" {
out.HfToken = next.HfToken
}
return out
}
// UpsertSidecarApp stores the dashboard's process-level block VERBATIM.
//
// A replace, not a merge, unlike the settings patch: the dashboard renders this
// section as one form and PUTs all of it, and the fields here have no meaningful
// "unset from this section" state — clearing AdminAllow must actually clear it,
// which a merge could not express.
func UpsertSidecarApp(generatePath string, app AppSettings) error {
sc, err := loadSidecar(generatePath)
if err != nil {
return err
}
sc.App = &app
return writeSidecar(generatePath, sc)
}
// LoadSidecarApp returns only the dashboard-owned block (nil when unsaved), so
// the settings API can report which values the user actually pinned as opposed
// to inherited from the generate file.
func LoadSidecarApp(generatePath string) (*AppSettings, error) {
sc, err := loadSidecar(generatePath)
if err != nil {
return nil, err
}
return sc.App, nil
}