diff --git a/frontend/resources/vue/App.vue b/frontend/resources/vue/App.vue index 8e552bf..fa555b5 100644 --- a/frontend/resources/vue/App.vue +++ b/frontend/resources/vue/App.vue @@ -8,11 +8,11 @@ @@ -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) { diff --git a/integration-tests/proxies/haproxy/Makefile b/integration-tests/proxies/haproxy/Makefile new file mode 100644 index 0000000..506b99b --- /dev/null +++ b/integration-tests/proxies/haproxy/Makefile @@ -0,0 +1,2 @@ +make: + haproxy -f ./haproxy.conf diff --git a/integration-tests/proxies/haproxy/haproxy.conf b/integration-tests/proxies/haproxy/haproxy.conf index 0528b78..705a3a6 100644 --- a/integration-tests/proxies/haproxy/haproxy.conf +++ b/integration-tests/proxies/haproxy/haproxy.conf @@ -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 + diff --git a/service/internal/acl/acl.go b/service/internal/acl/acl.go index 59c3358..227e537 100644 --- a/service/internal/acl/acl.go +++ b/service/internal/acl/acl.go @@ -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 } diff --git a/service/internal/api/api.go b/service/internal/api/api.go index d467bcb..8eecbe9 100644 --- a/service/internal/api/api.go +++ b/service/internal/api/api.go @@ -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") } } diff --git a/service/internal/config/config.go b/service/internal/config/config.go index 0e1e800..de0efca 100644 --- a/service/internal/config/config.go +++ b/service/internal/config/config.go @@ -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 { diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index ab8e5d7..9909f02 100644 --- a/service/internal/config/config_reloader.go +++ b/service/internal/config/config_reloader.go @@ -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 { diff --git a/service/internal/executor/executor_actions.go b/service/internal/executor/executor_actions.go index 36327ea..053af80 100644 --- a/service/internal/executor/executor_actions.go +++ b/service/internal/executor/executor_actions.go @@ -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 != "" { diff --git a/service/internal/httpservers/singleFrontend.go b/service/internal/httpservers/singleFrontend.go index e7627e6..e42e493 100644 --- a/service/internal/httpservers/singleFrontend.go +++ b/service/internal/httpservers/singleFrontend.go @@ -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) })) diff --git a/service/internal/httpservers/webuiServer.go b/service/internal/httpservers/webuiServer.go index 86ba263..7a0aa8f 100644 --- a/service/internal/httpservers/webuiServer.go +++ b/service/internal/httpservers/webuiServer.go @@ -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) } diff --git a/service/internal/websocket/websocket.go b/service/internal/websocket/websocket.go deleted file mode 100644 index f489988..0000000 --- a/service/internal/websocket/websocket.go +++ /dev/null @@ -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() - - // - // 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("}")...) - // - - 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, - } -} diff --git a/service/main.go b/service/main.go index 1068d81..b0ed402 100644 --- a/service/main.go +++ b/service/main.go @@ -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)