3k release: massively improve config loading, reduce log spam, etc (#700)

This commit is contained in:
James Read 2025-11-07 00:55:08 +00:00 committed by GitHub
commit 209856eda9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 288 additions and 615 deletions

View File

@ -8,11 +8,11 @@
<template #user-info>
<div class="flex-row user-info" style="gap: .5em;">
<span id="link-login" v-if="!isLoggedIn"><router-link to="/login">Login</router-link></span>
<router-link v-else to="/user" class="user-link">
<span id="link-login" v-if="!isLoggedIn && showLoginLink"><router-link to="/login">Login</router-link></span>
<router-link v-else to="/user" class="user-link" v-if="isLoggedIn">
<span id="username-text">{{ username }}</span>
</router-link>
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" />
<HugeiconsIcon :icon="UserCircle02Icon" width = "1.5em" height = "1.5em" v-if="isLoggedIn" />
</div>
</template>
@ -71,7 +71,7 @@ import logoUrl from '../../OliveTinLogo.png';
const router = useRouter();
const sidebar = ref(null);
const username = ref('guest');
const username = ref('notset');
const isLoggedIn = ref(false);
const serverConnection = ref('Connected');
const currentVersion = ref('?');
@ -84,6 +84,7 @@ const showLogs = ref(true)
const showDiagnostics = ref(true)
const initError = ref(false)
const initErrorMessage = ref('')
const showLoginLink = ref(true)
function toggleSidebar() {
if (sidebar.value && showNavigation.value) {
@ -102,6 +103,10 @@ function updateHeaderFromInit() {
showNavigation.value = window.initResponse.showNavigation
showLogs.value = window.initResponse.showLogList
showDiagnostics.value = window.initResponse.showDiagnostics
if (!window.initResponse.authLocalLogin && window.initResponse.oAuth2Providers.length === 0) {
showLoginLink.value = false
}
}
}
@ -124,15 +129,7 @@ async function requestInit() {
window.initErrorMessage = ''
window.initCompleted = true
username.value = initResponse.authenticatedUser
isLoggedIn.value = initResponse.authenticatedUser !== '' && initResponse.authenticatedUser !== 'guest'
currentVersion.value = initResponse.currentVersion
bannerMessage.value = initResponse.bannerMessage || '';
bannerCss.value = initResponse.bannerCss || '';
showFooter.value = initResponse.showFooter
showNavigation.value = initResponse.showNavigation
showLogs.value = initResponse.showLogList
showDiagnostics.value = initResponse.showDiagnostics
window.updateHeaderFromInit()
if (showNavigation.value && sidebar.value) {
for (const rootDashboard of initResponse.rootDashboards) {

View File

@ -0,0 +1,2 @@
make:
haproxy -f ./haproxy.conf

View File

@ -1,15 +1,32 @@
global
stats socket /var/run/haproxy/admin.sock mode 660 level admin
log stdout local0
frontend cleartext_frontend
bind 0.0.0.0:80
bind 0.0.0.0:8080
timeout client 10s
stats enable
stats uri /stats
stats refresh 30s
log global
option httplog
option dontlognull
log-format "[%t] %ci:%cp -> BACKEND:%b/%s | %HM %HU | Status:%ST | Bytes:%B"
mode http
use_backend be_olivetin_webs if { hdr(Host) -i olivetin.example.com && path_beg /websocket }
use_backend be_olivetin_http if { hdr(Host) -i olivetin.example.com }
use_backend be_olivetin if { hdr(Host) -i -m beg olivetin.example.com }
backend be_olivetin_http
server olivetinServer 127.0.0.1:1337 check
backend be_olivetin_webs
backend be_olivetin
mode http
timeout connect 10s
timeout server 10s
timeout tunnel 1h
option http-server-close
server olivetinServer 127.0.0.1:1337
http-request set-header X-Forwarded-User "Alice"
http-request set-header X-Forwarded-Group "Group1,Group2"
server olivetinServer 127.0.0.1:1337 check

View File

@ -208,12 +208,19 @@ func UserFromContext[T any](ctx context.Context, req *connect.Request[T], cfg *c
} else {
buildUserAcls(cfg, &user)
}
path := ""
if req != nil {
path = req.Spec().Procedure
}
log.WithFields(log.Fields{
"username": user.Username,
"usergroupLine": user.UsergroupLine,
"provider": user.Provider,
"acls": user.Acls,
}).Debugf("UserFromContext")
"path": path,
}).Debugf("Authenticated API request")
return &user
}

View File

@ -481,16 +481,20 @@ func (api *oliveTinAPI) GetActionLogs(ctx ctx.Context, req *connect.Request[apiv
ret.StartOffset = page.start
return connect.NewResponse(ret), nil
}
// Newest-first slicing: compute reversed indices
startIdx := page.total - page.end
endIdx := page.total - page.start
if startIdx < 0 { startIdx = 0 }
if endIdx > int64(len(filtered)) { endIdx = int64(len(filtered)) }
for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
// Entries older than the returned newest page
ret.CountRemaining = page.start
// Newest-first slicing: compute reversed indices
startIdx := page.total - page.end
endIdx := page.total - page.start
if startIdx < 0 {
startIdx = 0
}
if endIdx > int64(len(filtered)) {
endIdx = int64(len(filtered))
}
for _, le := range filtered[startIdx:endIdx] {
ret.Logs = append(ret.Logs, api.internalLogEntryToPb(le, user))
}
// Entries older than the returned newest page
ret.CountRemaining = page.start
ret.PageSize = page.size
ret.TotalCount = page.total
ret.StartOffset = page.start
@ -662,7 +666,9 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
AuthenticatedUser: user,
}
log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username)
log.WithFields(log.Fields{
"authenticatedUser": user.Username,
}).Debugf("EventStream: client connected")
api.streamingClientsMutex.Lock()
api.streamingClients[client] = struct{}{}
@ -814,7 +820,7 @@ func (api *oliveTinAPI) buildRootDashboards(user *acl.AuthenticatedUser, dashboa
func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) {
defaultDashboard := buildDefaultDashboard(rr)
if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
log.Infof("defaultDashboard: %+v", defaultDashboard.Contents)
log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
*rootDashboards = append(*rootDashboards, "Actions")
}
}

View File

@ -7,212 +7,212 @@ import (
// Action represents the core functionality of OliveTin - commands that show up
// as buttons in the UI.
type Action struct {
ID string
Title string
Icon string
Shell string
Exec []string
ShellAfterCompleted string
Timeout int
Acls []string
Entity string
Hidden bool
ExecOnStartup bool
ExecOnCron []string
ExecOnFileCreatedInDir []string
ExecOnFileChangedInDir []string
ExecOnCalendarFile string
Triggers []string
MaxConcurrent int
MaxRate []RateSpec
Arguments []ActionArgument
PopupOnStart string
SaveLogs SaveLogsConfig
ID string `koanf:"id"`
Title string `koanf:"title"`
Icon string `koanf:"icon"`
Shell string `koanf:"shell"`
Exec []string `koanf:"exec"`
ShellAfterCompleted string `koanf:"shellAfterCompleted"`
Timeout int `koanf:"timeout"`
Acls []string `koanf:"acls"`
Entity string `koanf:"entity"`
Hidden bool `koanf:"hidden"`
ExecOnStartup bool `koanf:"execOnStartup"`
ExecOnCron []string `koanf:"execOnCron"`
ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
Triggers []string `koanf:"triggers"`
MaxConcurrent int `koanf:"maxConcurrent"`
MaxRate []RateSpec `koanf:"maxRate"`
Arguments []ActionArgument `koanf:"arguments"`
PopupOnStart string `koanf:"popupOnStart"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
}
// ActionArgument objects appear on Actions.
type ActionArgument struct {
Name string
Title string
Description string
Type string
Default string
Choices []ActionArgumentChoice
Entity string
RejectNull bool
Suggestions map[string]string
Name string `koanf:"name"`
Title string `koanf:"title"`
Description string `koanf:"description"`
Type string `koanf:"type"`
Default string `koanf:"default"`
Choices []ActionArgumentChoice `koanf:"choices"`
Entity string `koanf:"entity"`
RejectNull bool `koanf:"rejectNull"`
Suggestions map[string]string `koanf:"suggestions"`
}
// ActionArgumentChoice represents a predefined choice for an argument.
type ActionArgumentChoice struct {
Value string
Title string
Value string `koanf:"value"`
Title string `koanf:"title"`
}
// RateSpec allows you to set a max frequency for an action.
type RateSpec struct {
Limit int
Duration string
Limit int `koanf:"limit"`
Duration string `koanf:"duration"`
}
// Entity represents a "thing" that can have multiple actions associated with it.
// for example, a media player with a start and stop action.
type EntityFile struct {
File string
Name string
Icon string
File string `koanf:"file"`
Name string `koanf:"name"`
Icon string `koanf:"icon"`
}
// PermissionsList defines what users can do with an action.
type PermissionsList struct {
View bool `mapstructure:"view"`
Exec bool `mapstructure:"exec"`
Logs bool `mapstructure:"logs"`
Kill bool `mapstructure:"kill"`
View bool `koanf:"view"`
Exec bool `koanf:"exec"`
Logs bool `koanf:"logs"`
Kill bool `koanf:"kill"`
}
// AccessControlList defines what permissions apply to a user or user group.
type AccessControlList struct {
Name string
AddToEveryAction bool
MatchUsergroups []string
MatchUsernames []string
Permissions PermissionsList
Policy ConfigurationPolicy
Name string `koanf:"name"`
AddToEveryAction bool `koanf:"addToEveryAction"`
MatchUsergroups []string `koanf:"matchUsergroups"`
MatchUsernames []string `koanf:"matchUsernames"`
Permissions PermissionsList `koanf:"permissions"`
Policy ConfigurationPolicy `koanf:"policy"`
}
// ConfigurationPolicy defines global settings which are overridden with an ACL.
type ConfigurationPolicy struct {
ShowDiagnostics bool `mapstructure:"showDiagnostics"`
ShowLogList bool `mapstructure:"showLogList"`
ShowDiagnostics bool `koanf:"showDiagnostics"`
ShowLogList bool `koanf:"showLogList"`
}
type PrometheusConfig struct {
Enabled bool `mapstructure:"enabled"`
DefaultGoMetrics bool `mapstructure:"defaultGoMetrics"`
Enabled bool `koanf:"enabled"`
DefaultGoMetrics bool `koanf:"defaultGoMetrics"`
}
// Config is the global config used through the whole app.
type Config struct {
UseSingleHTTPFrontend bool `mapstructure:"useSingleHTTPFrontend"`
ThemeName string `mapstructure:"themeName"`
ThemeCacheDisabled bool `mapstructure:"themeCacheDisabled"`
ListenAddressSingleHTTPFrontend string `mapstructure:"listenAddressSingleHTTPFrontend"`
ListenAddressWebUI string `mapstructure:"listenAddressWebUI"`
ListenAddressRestActions string `mapstructure:"listenAddressRestActions"`
ListenAddressPrometheus string `mapstructure:"listenAddressPrometheus"`
ExternalRestAddress string `mapstructure:"externalRestAddress"`
LogLevel string `mapstructure:"logLevel"`
LogDebugOptions LogDebugOptions `mapstructure:"logDebugOptions"`
LogHistoryPageSize int64 `mapstructure:"logHistoryPageSize"`
Actions []*Action `mapstructure:"actions"`
Entities []*EntityFile `mapstructure:"entities"`
Dashboards []*DashboardComponent `mapstructure:"dashboards"`
CheckForUpdates bool `mapstructure:"checkForUpdates"`
PageTitle string `mapstructure:"pageTitle"`
ShowFooter bool `mapstructure:"showFooter"`
ShowNavigation bool `mapstructure:"showNavigation"`
ShowNewVersions bool `mapstructure:"showNewVersions"`
EnableCustomJs bool `mapstructure:"enableCustomJs"`
AuthJwtCookieName string `mapstructure:"authJwtCookieName"`
AuthJwtHeader string `mapstructure:"authJwtHeader"`
AuthJwtAud string `mapstructure:"authJwtAud"`
AuthJwtDomain string `mapstructure:"authJwtDomain"`
AuthJwtCertsURL string `mapstructure:"authJwtCertsUrl"`
AuthJwtHmacSecret string `mapstructure:"authJwtHmacSecret"` // mutually exclusive with pub key config fields
AuthJwtClaimUsername string `mapstructure:"authJwtClaimUsername"`
AuthJwtClaimUserGroup string `mapstructure:"authJwtClaimUserGroup"`
AuthJwtPubKeyPath string `mapstructure:"authJwtPubKeyPath"` // will read pub key from file on disk
AuthHttpHeaderUsername string `mapstructure:"authHttpHeaderUsername"`
AuthHttpHeaderUserGroup string `mapstructure:"authHttpHeaderUserGroup"`
AuthHttpHeaderUserGroupSep string `mapstructure:"authHttpHeaderUserGroupSep"`
AuthLocalUsers AuthLocalUsersConfig `mapstructure:"authLocalUsers"`
AuthLoginUrl string `mapstructure:"authLoginUrl"`
AuthRequireGuestsToLogin bool `mapstructure:"authRequireGuestsToLogin"`
AuthOAuth2RedirectURL string `mapstructure:"authOAuth2RedirectUrl"`
AuthOAuth2Providers map[string]*OAuth2Provider `mapstructure:"authOAuth2Providers"`
DefaultPermissions PermissionsList `mapstructure:"defaultPermissions"`
DefaultPolicy ConfigurationPolicy `mapstructure:"defaultPolicy"`
AccessControlLists []*AccessControlList `mapstructure:"accessControlLists"`
WebUIDir string `mapstructure:"webUIDir"`
CronSupportForSeconds bool `mapstructure:"cronSupportForSeconds"`
SectionNavigationStyle string `mapstructure:"sectionNavigationStyle"`
DefaultPopupOnStart string `mapstructure:"defaultPopupOnStart"`
InsecureAllowDumpOAuth2UserData bool `mapstructure:"insecureAllowDumpOAuth2UserData"`
InsecureAllowDumpVars bool `mapstructure:"insecureAllowDumpVars"`
InsecureAllowDumpSos bool `mapstructure:"insecureAllowDumpSos"`
InsecureAllowDumpActionMap bool `mapstructure:"insecureAllowDumpActionMap"`
InsecureAllowDumpJwtClaims bool `mapstructure:"insecureAllowDumpJwtClaims"`
Prometheus PrometheusConfig `mapstructure:"prometheus"`
SaveLogs SaveLogsConfig `mapstructure:"saveLogs"`
DefaultIconForActions string `mapstructure:"defaultIconForActions"`
DefaultIconForDirectories string `mapstructure:"defaultIconForDirectories"`
DefaultIconForBack string `mapstructure:"defaultIconForBack"`
AdditionalNavigationLinks []*NavigationLink `mapstructure:"additionalNavigationLinks"`
ServiceHostMode string `mapstructure:"serviceHostMode"`
StyleMods []string `mapstructure:"styleMods"`
BannerMessage string `mapstructure:"bannerMessage"`
BannerCSS string `mapstructure:"bannerCss"`
Include string `mapstructure:"include"`
UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"`
ThemeName string `koanf:"themeName"`
ThemeCacheDisabled bool `koanf:"themeCacheDisabled"`
ListenAddressSingleHTTPFrontend string `koanf:"listenAddressSingleHTTPFrontend"`
ListenAddressWebUI string `koanf:"listenAddressWebUI"`
ListenAddressRestActions string `koanf:"listenAddressRestActions"`
ListenAddressPrometheus string `koanf:"listenAddressPrometheus"`
ExternalRestAddress string `koanf:"externalRestAddress"`
LogLevel string `koanf:"logLevel"`
LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"`
LogHistoryPageSize int64 `koanf:"logHistoryPageSize"`
Actions []*Action `koanf:"actions"`
Entities []*EntityFile `koanf:"entities"`
Dashboards []*DashboardComponent `koanf:"dashboards"`
CheckForUpdates bool `koanf:"checkForUpdates"`
PageTitle string `koanf:"pageTitle"`
ShowFooter bool `koanf:"showFooter"`
ShowNavigation bool `koanf:"showNavigation"`
ShowNewVersions bool `koanf:"showNewVersions"`
EnableCustomJs bool `koanf:"enableCustomJs"`
AuthJwtCookieName string `koanf:"authJwtCookieName"`
AuthJwtHeader string `koanf:"authJwtHeader"`
AuthJwtAud string `koanf:"authJwtAud"`
AuthJwtDomain string `koanf:"authJwtDomain"`
AuthJwtCertsURL string `koanf:"authJwtCertsUrl"`
AuthJwtHmacSecret string `koanf:"authJwtHmacSecret"` // mutually exclusive with pub key config fields
AuthJwtClaimUsername string `koanf:"authJwtClaimUsername"`
AuthJwtClaimUserGroup string `koanf:"authJwtClaimUserGroup"`
AuthJwtPubKeyPath string `koanf:"authJwtPubKeyPath"` // will read pub key from file on disk
AuthHttpHeaderUsername string `koanf:"authHttpHeaderUsername"`
AuthHttpHeaderUserGroup string `koanf:"authHttpHeaderUserGroup"`
AuthHttpHeaderUserGroupSep string `koanf:"authHttpHeaderUserGroupSep"`
AuthLocalUsers AuthLocalUsersConfig `koanf:"authLocalUsers"`
AuthLoginUrl string `koanf:"authLoginUrl"`
AuthRequireGuestsToLogin bool `koanf:"authRequireGuestsToLogin"`
AuthOAuth2RedirectURL string `koanf:"authOAuth2RedirectUrl"`
AuthOAuth2Providers map[string]*OAuth2Provider `koanf:"authOAuth2Providers"`
DefaultPermissions PermissionsList `koanf:"defaultPermissions"`
DefaultPolicy ConfigurationPolicy `koanf:"defaultPolicy"`
AccessControlLists []*AccessControlList `koanf:"accessControlLists"`
WebUIDir string `koanf:"webUIDir"`
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
InsecureAllowDumpSos bool `koanf:"insecureAllowDumpSos"`
InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"`
InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"`
Prometheus PrometheusConfig `koanf:"prometheus"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
DefaultIconForActions string `koanf:"defaultIconForActions"`
DefaultIconForDirectories string `koanf:"defaultIconForDirectories"`
DefaultIconForBack string `koanf:"defaultIconForBack"`
AdditionalNavigationLinks []*NavigationLink `koanf:"additionalNavigationLinks"`
ServiceHostMode string `koanf:"serviceHostMode"`
StyleMods []string `koanf:"styleMods"`
BannerMessage string `koanf:"bannerMessage"`
BannerCSS string `koanf:"bannerCss"`
Include string `koanf:"include"`
sourceFiles []string
}
type AuthLocalUsersConfig struct {
Enabled bool `mapstructure:"enabled"`
Users []*LocalUser `mapstructure:"users"`
Enabled bool `koanf:"enabled"`
Users []*LocalUser `koanf:"users"`
}
type LocalUser struct {
Username string `mapstructure:"username"`
Usergroup string `mapstructure:"usergroup"`
Password string `mapstructure:"password"`
Username string `koanf:"username"`
Usergroup string `koanf:"usergroup"`
Password string `koanf:"password"`
}
type OAuth2Provider struct {
Name string
Title string
ClientID string
ClientSecret string
Icon string
Scopes []string
AuthUrl string
TokenUrl string
WhoamiUrl string
UsernameField string
UserGroupField string
InsecureSkipVerify bool
CallbackTimeout int
CertBundlePath string
Name string `koanf:"name"`
Title string `koanf:"title"`
ClientID string `koanf:"clientId"`
ClientSecret string `koanf:"clientSecret"`
Icon string `koanf:"icon"`
Scopes []string `koanf:"scopes"`
AuthUrl string `koanf:"authUrl"`
TokenUrl string `koanf:"tokenUrl"`
WhoamiUrl string `koanf:"whoamiUrl"`
UsernameField string `koanf:"usernameField"`
UserGroupField string `koanf:"userGroupField"`
InsecureSkipVerify bool `koanf:"insecureSkipVerify"`
CallbackTimeout int `koanf:"callbackTimeout"`
CertBundlePath string `koanf:"certBundlePath"`
}
type NavigationLink struct {
Title string `mapstructure:"title"`
Url string `mapstructure:"url"`
Target string `mapstructure:"target"`
Title string `koanf:"title"`
Url string `koanf:"url"`
Target string `koanf:"target"`
}
type SaveLogsConfig struct {
ResultsDirectory string `mapstructure:"resultsDirectory"`
OutputDirectory string `mapstructure:"outputDirectory"`
ResultsDirectory string `koanf:"resultsDirectory"`
OutputDirectory string `koanf:"outputDirectory"`
}
type LogDebugOptions struct {
SingleFrontendRequests bool
SingleFrontendRequestHeaders bool
AclCheckStarted bool
AclMatched bool
AclNotMatched bool
AclNoneMatched bool
SingleFrontendRequests bool `koanf:"singleFrontendRequests"`
SingleFrontendRequestHeaders bool `koanf:"singleFrontendRequestHeaders"`
AclCheckStarted bool `koanf:"aclCheckStarted"`
AclMatched bool `koanf:"aclMatched"`
AclNotMatched bool `koanf:"aclNotMatched"`
AclNoneMatched bool `koanf:"aclNoneMatched"`
}
type DashboardComponent struct {
Title string
Type string
Entity string
Icon string
CssClass string
Contents []*DashboardComponent
Title string `koanf:"title"`
Type string `koanf:"type"`
Entity string `koanf:"entity"`
Icon string `koanf:"icon"`
CssClass string `koanf:"cssClass"`
Contents []*DashboardComponent `koanf:"contents"`
}
func DefaultConfig() *Config {

View File

@ -34,90 +34,32 @@ func AddListener(l func()) {
listeners = append(listeners, l)
}
// AppendSourceWithIncludes loads base config and any included configs
func AppendSourceWithIncludes(cfg *Config, k *koanf.Koanf, configPath string) {
// Load base config first
AppendSource(cfg, k, configPath)
// Load included configs if specified
if cfg.Include != "" {
LoadIncludedConfigs(cfg, k, configPath)
}
}
func AppendSource(cfg *Config, k *koanf.Koanf, configPath string) {
log.Infof("Appending cfg source: %s", configPath)
log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Appending cfg source")
loadIncludedConfigsFromDir(k, configPath)
if !unmarshalRoot(k, cfg) {
return
}
loadCollectionsFallbacks(k, cfg)
applyConfigOverrides(k, cfg)
afterLoadFinalize(cfg, configPath)
}
func unmarshalRoot(k *koanf.Koanf, cfg *Config) bool {
if err := k.Unmarshal(".", cfg); err != nil {
err := k.UnmarshalWithConf("", cfg, koanf.UnmarshalConf{
Tag: "koanf",
})
if err != nil {
log.Errorf("Error unmarshalling config: %v", err)
return false
}
return true
}
func loadCollectionsFallbacks(k *koanf.Koanf, cfg *Config) {
maybeUnmarshalActions(k, cfg)
maybeUnmarshalDashboards(k, cfg)
maybeUnmarshalEntities(k, cfg)
maybeUnmarshalAuthLocalUsers(k, cfg)
}
func maybeUnmarshalActions(k *koanf.Koanf, cfg *Config) {
if len(cfg.Actions) != 0 || !k.Exists("actions") {
return
}
var actions []*Action
if err := k.Unmarshal("actions", &actions); err == nil {
cfg.Actions = actions
log.Debugf("Manually loaded %d actions", len(actions))
}
}
func maybeUnmarshalDashboards(k *koanf.Koanf, cfg *Config) {
if len(cfg.Dashboards) != 0 || !k.Exists("dashboards") {
return
}
var dashboards []*DashboardComponent
if err := k.Unmarshal("dashboards", &dashboards); err == nil {
cfg.Dashboards = dashboards
log.Debugf("Manually loaded %d dashboards", len(dashboards))
}
}
func maybeUnmarshalEntities(k *koanf.Koanf, cfg *Config) {
if len(cfg.Entities) != 0 || !k.Exists("entities") {
return
}
var entities []*EntityFile
if err := k.Unmarshal("entities", &entities); err == nil {
cfg.Entities = entities
log.Debugf("Manually loaded %d entities", len(entities))
}
}
func maybeUnmarshalAuthLocalUsers(k *koanf.Koanf, cfg *Config) {
if len(cfg.AuthLocalUsers.Users) != 0 || !k.Exists("authLocalUsers") {
return
}
var authLocalUsers AuthLocalUsersConfig
if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
cfg.AuthLocalUsers = authLocalUsers
log.Debugf("Manually loaded local auth config")
}
}
func afterLoadFinalize(cfg *Config, configPath string) {
metricConfigReloadedCount.Inc()
metricConfigActionCount.Set(float64(len(cfg.Actions)))
@ -130,39 +72,19 @@ func afterLoadFinalize(cfg *Config, configPath string) {
}
}
func applyConfigOverrides(k *koanf.Koanf, cfg *Config) {
// Override fields that should be read from config
// mapstructure tags should make most of this unnecessary, but keep for safety
boolVal(k, "showFooter", &cfg.ShowFooter)
boolVal(k, "showNavigation", &cfg.ShowNavigation)
boolVal(k, "checkForUpdates", &cfg.CheckForUpdates)
boolVal(k, "useSingleHTTPFrontend", &cfg.UseSingleHTTPFrontend)
stringVal(k, "logLevel", &cfg.LogLevel)
stringVal(k, "pageTitle", &cfg.PageTitle)
boolVal(k, "authRequireGuestsToLogin", &cfg.AuthRequireGuestsToLogin)
stringVal(k, "include", &cfg.Include)
// loadIncludedConfigsFromDir loads configuration files from an include directory and merges them
func loadIncludedConfigsFromDir(k *koanf.Koanf, baseConfigPath string) {
relativeIncludePath := k.String("include")
// Handle nested defaultPolicy struct
if k.Exists("defaultPolicy") {
boolVal(k, "defaultPolicy.showDiagnostics", &cfg.DefaultPolicy.ShowDiagnostics)
boolVal(k, "defaultPolicy.showLogList", &cfg.DefaultPolicy.ShowLogList)
}
// Handle nested prometheus struct
if k.Exists("prometheus") {
boolVal(k, "prometheus.enabled", &cfg.Prometheus.Enabled)
boolVal(k, "prometheus.defaultGoMetrics", &cfg.Prometheus.DefaultGoMetrics)
}
}
// LoadIncludedConfigs loads configuration files from an include directory and merges them
func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) {
if cfg.Include == "" {
if relativeIncludePath == "" {
return
}
includePath := filepath.Join(filepath.Dir(baseConfigPath), cfg.Include)
log.Infof("Loading included configs from: %s", includePath)
includePath := filepath.Join(filepath.Dir(baseConfigPath), relativeIncludePath)
log.WithFields(log.Fields{
"includePath": includePath,
}).Infof("Loading included configs from dir")
yamlFiles, ok := listYamlFiles(includePath)
if !ok || len(yamlFiles) == 0 {
@ -171,11 +93,10 @@ func LoadIncludedConfigs(cfg *Config, k *koanf.Koanf, baseConfigPath string) {
sort.Strings(yamlFiles)
for _, filename := range yamlFiles {
loadAndMergeIncludedFile(cfg, includePath, filename)
loadAndMergeIncludedFile(k, includePath, filename)
}
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
cfg.Sanitize()
}
func listYamlFiles(includePath string) ([]string, bool) {
@ -209,152 +130,45 @@ func listYamlFiles(includePath string) ([]string, bool) {
return yamlFiles, true
}
func loadAndMergeIncludedFile(cfg *Config, includePath, filename string) {
func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
filePath := filepath.Join(includePath, filename)
log.Infof("Loading included config file: %s", filePath)
includeK := koanf.New(".")
if err := includeK.Load(file.Provider(filePath), yaml.Parser()); err != nil {
if err := k.Load(file.Provider(filePath), yaml.Parser(), koanf.WithMergeFunc(mergeFunc)); err != nil {
log.Errorf("Error loading included config file %s: %v", filePath, err)
return
}
tempCfg := &Config{}
if err := includeK.Unmarshal(".", tempCfg); err != nil {
log.Errorf("Error unmarshalling included config file %s: %v", filePath, err)
return
}
loadCollectionsFallbacks(includeK, tempCfg)
mergeConfig(cfg, tempCfg)
log.Infof("Successfully loaded and merged %s", filename)
log.WithFields(log.Fields{
"filePath": filePath,
}).Info("Successfully loaded included config file")
}
func mergeConfig(base *Config, overlay *Config) {
mergeSlices(base, overlay)
overrideSimple(base, overlay)
overrideNested(base, overlay)
overrideStrings(base, overlay)
}
func mergeFunc(src map[string]interface{}, dest map[string]interface{}) error {
// Handle actions merging - koanf provides []interface{} not []*Action
// Merge src (new) into dest (existing) by appending src's actions to dest's actions
if srcActions, ok := src["actions"]; ok {
if destActions, ok := dest["actions"]; ok {
// Both have actions - append src to dest
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
// Fallback: if types don't match, just use src
dest["actions"] = srcActions
}
} else {
// dest doesn't have actions, so use src's actions
dest["actions"] = srcActions
}
}
// If src doesn't have actions, leave dest unchanged
func mergeSlices(base *Config, overlay *Config) {
if len(overlay.Actions) > 0 {
base.Actions = append(base.Actions, overlay.Actions...)
}
if len(overlay.Dashboards) > 0 {
base.Dashboards = append(base.Dashboards, overlay.Dashboards...)
log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards))
}
if len(overlay.Entities) > 0 {
base.Entities = append(base.Entities, overlay.Entities...)
log.Debugf("Merged %d entities from include", len(overlay.Entities))
}
if len(overlay.AccessControlLists) > 0 {
base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...)
log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists))
}
if len(overlay.AuthLocalUsers.Users) > 0 {
base.AuthLocalUsers.Users = append(base.AuthLocalUsers.Users, overlay.AuthLocalUsers.Users...)
log.Debugf("Merged %d local users from include", len(overlay.AuthLocalUsers.Users))
}
if len(overlay.StyleMods) > 0 {
base.StyleMods = append(base.StyleMods, overlay.StyleMods...)
}
if len(overlay.AdditionalNavigationLinks) > 0 {
base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...)
}
}
func overrideSimple(base *Config, overlay *Config) {
if overlay.LogLevel != "" {
base.LogLevel = overlay.LogLevel
}
if overlay.PageTitle != "" {
base.PageTitle = overlay.PageTitle
}
if overlay.ShowFooter != base.ShowFooter {
base.ShowFooter = overlay.ShowFooter
}
if overlay.ShowNavigation != base.ShowNavigation {
base.ShowNavigation = overlay.ShowNavigation
}
if overlay.CheckForUpdates != base.CheckForUpdates {
base.CheckForUpdates = overlay.CheckForUpdates
}
if overlay.UseSingleHTTPFrontend != base.UseSingleHTTPFrontend {
base.UseSingleHTTPFrontend = overlay.UseSingleHTTPFrontend
}
if overlay.AuthRequireGuestsToLogin != base.AuthRequireGuestsToLogin {
base.AuthRequireGuestsToLogin = overlay.AuthRequireGuestsToLogin
}
if overlay.AuthLocalUsers.Enabled {
base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled
}
}
func overrideNested(base *Config, overlay *Config) {
// Only apply overrides when overlay explicitly enables the option.
// This mirrors the presence-check pattern used elsewhere to avoid
// unintentionally disabling an already-enabled base setting with a default false.
if overlay.DefaultPolicy.ShowDiagnostics {
base.DefaultPolicy.ShowDiagnostics = true
}
if overlay.DefaultPolicy.ShowLogList {
base.DefaultPolicy.ShowLogList = true
}
if overlay.Prometheus.Enabled {
base.Prometheus.Enabled = true
}
if overlay.Prometheus.DefaultGoMetrics {
base.Prometheus.DefaultGoMetrics = true
}
}
func overrideStrings(base *Config, overlay *Config) {
overrideString(&base.BannerMessage, overlay.BannerMessage)
overrideString(&base.BannerCSS, overlay.BannerCSS)
overrideString(&base.LogLevel, overlay.LogLevel)
overrideString(&base.PageTitle, overlay.PageTitle)
overrideString(&base.SectionNavigationStyle, overlay.SectionNavigationStyle)
overrideString(&base.DefaultPopupOnStart, overlay.DefaultPopupOnStart)
}
func overrideString(base *string, overlay string) {
if overlay != "" {
*base = overlay
}
}
func getActionTitles(actions []*Action) []string {
titles := make([]string, len(actions))
for i, action := range actions {
titles[i] = action.Title
}
return titles
return nil
}
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
// Helper functions to reduce repetitive if/set chains
func stringVal(k *koanf.Koanf, key string, dest *string) {
if k.Exists(key) {
*dest = k.String(key)
}
}
func boolVal(k *koanf.Koanf, key string, dest *bool) {
if k.Exists(key) {
*dest = k.Bool(key)
}
}
func int64Val(k *koanf.Koanf, key string, dest *int64) {
if k.Exists(key) {
*dest = k.Int64(key)
}
}
func envDecodeHookFunc(from reflect.Type, to reflect.Type, data any) (any, error) {
log.Debugf("envDecodeHookFunc called: from=%v, to=%v, data=%v", from, to, data)
if from.Kind() != reflect.String {

View File

@ -53,7 +53,9 @@ func (e *Executor) RebuildActionMap() {
findDashboardActionTitles(req)
log.Infof("dashboardActionTitles: %v", req.DashboardActionTitles)
log.WithFields(log.Fields{
"titles": req.DashboardActionTitles,
}).Trace("dashboardActionTitles")
for configOrder, action := range e.Cfg.Actions {
if action.Entity != "" {

View File

@ -53,7 +53,11 @@ func StartSingleHTTPFrontend(cfg *config.Config, ex *executor.Executor) {
r.URL.Path = apiPath + fn
log.Debugf("SingleFrontend HTTP API Req URL after rewrite: %v", r.URL.Path)
log.WithFields(log.Fields{
"path": r.URL.Path,
}).Tracef("SingleFrontend HTTP API Req URL after rewrite")
logDebugRequest(cfg, "api", r)
apiHandler.ServeHTTP(w, r)
}))

View File

@ -45,7 +45,7 @@ func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, path.Join(s.webuiDir, "index.html"))
} else {
log.Infof("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
log.Tracef("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
http.ServeFile(w, r, path.Join(s.webuiDir, r.URL.Path))
// http.StripPrefix(dirName, http.FileServer(http.Dir(s.webuiDir))).ServeHTTP(w, r)
}

View File

@ -1,185 +0,0 @@
package websocket
import (
"net/http"
"sync"
apiv1 "github.com/OliveTin/OliveTin/gen/olivetin/api/v1"
"github.com/OliveTin/OliveTin/internal/executor"
ws "github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
)
var upgrader = ws.Upgrader{
CheckOrigin: checkOriginPermissive,
}
var (
sendmutex = sync.Mutex{}
)
type WebsocketClient struct {
conn *ws.Conn
}
var clients []*WebsocketClient
var marshalOptions = protojson.MarshalOptions{
UseProtoNames: false, // eg: canExec for js instead of can_exec from protobuf
EmitUnpopulated: true,
}
var ExecutionListener WebsocketExecutionListener
type WebsocketExecutionListener struct{}
func (WebsocketExecutionListener) OnExecutionStarted(ile *executor.InternalLogEntry) {
broadcast(&apiv1.EventExecutionStarted{
LogEntry: internalLogEntryToPb(ile),
})
}
func OnEntityChanged() {
broadcast(&apiv1.EventEntityChanged{})
}
func (WebsocketExecutionListener) OnActionMapRebuilt() {
broadcast(&apiv1.EventConfigChanged{})
}
/*
The default checkOrigin function checks that the origin (browser) matches the
request origin. However in OliveTin we expect many users to deliberately proxy
the connection with reverse proxies.
So, we just permit any origin. After some searching I'm not sure if this exposes
OliveTin to security issues, but it seems probably not. It would be possible to
create a config option like PermitWebsocketConnectionsFrom or something, but
I'd prefer if OliveTin works as much as possible "out of the box".
If this does expose OliveTin to security issues, it will be changed in the
future obviously.
*/
func checkOriginPermissive(r *http.Request) bool {
return true
}
func (WebsocketExecutionListener) OnOutputChunk(chunk []byte, executionTrackingId string) {
log.Tracef("outputchunk: %s", string(chunk))
oc := &apiv1.EventOutputChunk{
Output: string(chunk),
ExecutionTrackingId: executionTrackingId,
}
broadcast(oc)
}
func (WebsocketExecutionListener) OnExecutionFinished(logEntry *executor.InternalLogEntry) {
evt := &apiv1.EventExecutionFinished{
LogEntry: internalLogEntryToPb(logEntry),
}
log.Infof("WS Execution finished: %v", evt.LogEntry)
broadcast(evt)
}
func broadcast(pbmsg protoreflect.ProtoMessage) {
payload, err := marshalOptions.Marshal(pbmsg)
if err != nil {
log.Errorf("websocket marshal error: %v", err)
return
}
messageType := pbmsg.ProtoReflect().Descriptor().FullName()
// <EVIL>
// So, the websocket wants to encode messages using the same protomarshaller
// as the REST API - this gives consistency instead of using encoding/json
// and allows us to set specific marshalOptions.
//
// However, the protomarshaller will marshal the type, but the JavaScript at
// the other end has no idea what type this object is - as we're just sending
// it as JSON over the websocket.
//
// Therefore, we wrap the nicely marsheled bytes in a hacky JSON string
// literal and encode that string just with a byte array cast.
hackyMessageEnvelope := "{\"type\": \"" + messageType + "\", \"payload\": "
hackyMessage := []byte{}
hackyMessage = append(hackyMessage, []byte(hackyMessageEnvelope)...)
hackyMessage = append(hackyMessage, payload...)
hackyMessage = append(hackyMessage, []byte("}")...)
// </EVIL>
sendmutex.Lock()
for _, client := range clients {
err := client.conn.WriteMessage(ws.TextMessage, hackyMessage)
if err != nil {
log.Warnf("websocket send error: %v", err)
}
}
sendmutex.Unlock()
}
func (c *WebsocketClient) messageLoop() {
for {
mt, message, err := c.conn.ReadMessage()
if err != nil {
log.Debugf("err: %v", err)
break
}
log.Tracef("websocket recv: %s %d", message, mt)
}
}
func HandleWebsocket(w http.ResponseWriter, r *http.Request) bool {
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Warnf("Websocket issue: %v", err)
return false
}
// defer c.Close()
wsclient := &WebsocketClient{
conn: c,
}
sendmutex.Lock()
clients = append(clients, wsclient)
sendmutex.Unlock()
go wsclient.messageLoop()
return true
}
func internalLogEntryToPb(logEntry *executor.InternalLogEntry) *apiv1.LogEntry {
return &apiv1.LogEntry{
ActionTitle: logEntry.ActionTitle,
ActionIcon: logEntry.ActionIcon,
ActionId: logEntry.ActionId,
DatetimeStarted: logEntry.DatetimeStarted.Format("2006-01-02 15:04:05"),
DatetimeFinished: logEntry.DatetimeFinished.Format("2006-01-02 15:04:05"),
Output: logEntry.Output,
TimedOut: logEntry.TimedOut,
Blocked: logEntry.Blocked,
ExitCode: logEntry.ExitCode,
Tags: logEntry.Tags,
ExecutionTrackingId: logEntry.ExecutionTrackingID,
ExecutionStarted: logEntry.ExecutionStarted,
ExecutionFinished: logEntry.ExecutionFinished,
User: logEntry.Username,
}
}

View File

@ -18,7 +18,6 @@ import (
"github.com/OliveTin/OliveTin/internal/onstartup"
"github.com/OliveTin/OliveTin/internal/servicehost"
updatecheck "github.com/OliveTin/OliveTin/internal/updatecheck"
"github.com/OliveTin/OliveTin/internal/websocket"
"os"
"strconv"
@ -147,23 +146,33 @@ func initConfig(configDir string) {
)
}
var firstConfigPath string
var baseConfigPath string
for _, directory := range directories {
configPath := getConfigPath(directory)
log.Debugf("Checking config path: %s", configPath)
found := true
if _, err := os.Stat(configPath); err != nil {
log.Debugf("Config file not found at %s: %v", configPath, err)
found = false
}
log.WithFields(log.Fields{
"configPath": configPath,
"found": found,
}).Debug("Checking base config path")
if !found {
continue
}
if firstConfigPath == "" {
firstConfigPath = configPath
if baseConfigPath == "" {
baseConfigPath = configPath
}
log.Infof("Loading config from %s", configPath)
log.WithFields(log.Fields{
"configPath": configPath,
}).Info("Loading config from path")
f := file.Provider(configPath)
if err := k.Load(f, yaml.Parser()); err != nil {
@ -177,16 +186,18 @@ func initConfig(configDir string) {
k.Load(f, yaml.Parser())
config.AppendSource(cfg, k, configPath)
})
break
}
cfg = config.DefaultConfigWithBasePort(getBasePort())
if firstConfigPath != "" {
config.AppendSourceWithIncludes(cfg, k, firstConfigPath)
} else {
config.AppendSource(cfg, k, "base")
if baseConfigPath == "" {
log.Fatalf("No base config file found")
os.Exit(1)
}
config.AppendSource(cfg, k, baseConfigPath)
}
func initInstallationInfo() {
@ -225,7 +236,6 @@ func main() {
executor := executor.DefaultExecutor(cfg)
executor.RebuildActionMap()
executor.AddListener(websocket.ExecutionListener)
config.AddListener(executor.RebuildActionMap)
go onstartup.Execute(cfg, executor)
@ -233,7 +243,6 @@ func main() {
go onfileindir.WatchFilesInDirectory(cfg, executor)
go oncalendarfile.Schedule(cfg, executor)
entities.AddListener(websocket.OnEntityChanged)
entities.AddListener(executor.RebuildActionMap)
go entities.SetupEntityFileWatchers(cfg)