mirror of
https://github.com/OliveTin/OliveTin
synced 2025-10-30 04:47:03 +00:00
Compare commits
8 Commits
a915a654cb
...
d54f2307c7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d54f2307c7 | ||
|
|
49dcc7fb46 | ||
|
|
2ea35697d0 | ||
|
|
a551589840 | ||
|
|
fcd3ccc59a | ||
|
|
dddc0417c2 | ||
|
|
d5eb74e738 | ||
|
|
9fbaa8671f |
@@ -54,7 +54,7 @@ changelog:
|
||||
regexp: '^.*?feat.*?(\([[:word:]]+\))??!?:.+$'
|
||||
order: 1
|
||||
- title: 'Bug fixes'
|
||||
regexp: '^.*?bugfix(\([[:word:]]+\))??!?:.+$'
|
||||
regexp: '^.*?fix(\([[:word:]]+\))??!?:.+$'
|
||||
order: 2
|
||||
- title: Others
|
||||
order: 999
|
||||
@@ -93,7 +93,7 @@ dockers:
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Tag}}"
|
||||
extra_files:
|
||||
- webui
|
||||
- webui/
|
||||
- var/entities/
|
||||
- config.yaml
|
||||
- var/helper-actions/
|
||||
@@ -110,7 +110,7 @@ dockers:
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Tag}}"
|
||||
extra_files:
|
||||
- webui
|
||||
- webui/
|
||||
- var/entities/
|
||||
- config.yaml
|
||||
- var/helper-actions/
|
||||
@@ -166,8 +166,9 @@ nfpms:
|
||||
- src: var/systemd/OliveTin.service
|
||||
dst: /etc/systemd/system/OliveTin.service
|
||||
|
||||
- src: webui/*
|
||||
- src: webui/
|
||||
dst: /var/www/olivetin/
|
||||
type: tree
|
||||
|
||||
- src: config.yaml
|
||||
dst: /etc/OliveTin/config.yaml
|
||||
@@ -196,8 +197,9 @@ nfpms:
|
||||
- src: var/openrc/OliveTin
|
||||
dst: /etc/init.d/OliveTin
|
||||
|
||||
- src: webui/*
|
||||
- src: webui/
|
||||
dst: /var/www/olivetin/
|
||||
type: tree
|
||||
|
||||
- src: config.yaml
|
||||
dst: /etc/OliveTin/config.yaml
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
#
|
||||
# Integration Test Config: Require Guests to Login
|
||||
#
|
||||
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
|
||||
# Require guests to login
|
||||
authRequireGuestsToLogin: true
|
||||
|
||||
# Enable local user authentication
|
||||
authLocalUsers:
|
||||
enabled: true
|
||||
users:
|
||||
- username: "testuser"
|
||||
usergroup: "admin"
|
||||
password: "testpass123"
|
||||
|
||||
# Simple actions for testing
|
||||
actions:
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
icon: ping
|
||||
|
||||
- title: sleep 2 seconds
|
||||
shell: sleep 2
|
||||
icon: "🥱"
|
||||
|
||||
6
integration-tests/configs/include/config.d/00-first.yml
Normal file
6
integration-tests/configs/include/config.d/00-first.yml
Normal file
@@ -0,0 +1,6 @@
|
||||
# This file should be loaded first
|
||||
actions:
|
||||
- title: First Included Action
|
||||
shell: echo "first"
|
||||
icon: ping
|
||||
|
||||
9
integration-tests/configs/include/config.d/01-second.yml
Normal file
9
integration-tests/configs/include/config.d/01-second.yml
Normal file
@@ -0,0 +1,9 @@
|
||||
# This file should be loaded second
|
||||
actions:
|
||||
- title: Second Included Action
|
||||
shell: echo "second"
|
||||
icon: ping
|
||||
|
||||
# Override base setting
|
||||
logLevel: "INFO"
|
||||
|
||||
14
integration-tests/configs/include/config.yaml
Normal file
14
integration-tests/configs/include/config.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
#
|
||||
# Integration Test Config: Include Directive
|
||||
#
|
||||
|
||||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
|
||||
include: config.d
|
||||
|
||||
actions:
|
||||
- title: Base Action
|
||||
shell: echo "base"
|
||||
icon: ping
|
||||
|
||||
59
integration-tests/test/authRequireGuestsToLogin.mjs
Normal file
59
integration-tests/test/authRequireGuestsToLogin.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By, until } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
takeScreenshotOnFailure,
|
||||
} from '../lib/elements.js'
|
||||
|
||||
describe('config: authRequireGuestsToLogin', function () {
|
||||
this.timeout(30000)
|
||||
|
||||
before(async function () {
|
||||
await runner.start('authRequireGuestsToLogin')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver);
|
||||
});
|
||||
|
||||
it('Server starts successfully with authRequireGuestsToLogin enabled', async function () {
|
||||
await webdriver.get(runner.baseUrl())
|
||||
await webdriver.wait(until.titleContains('OliveTin'), 10000)
|
||||
const title = await webdriver.getTitle()
|
||||
expect(title).to.contain('OliveTin')
|
||||
console.log('✓ Server started successfully with authRequireGuestsToLogin enabled')
|
||||
})
|
||||
|
||||
it('Guest user is blocked from accessing the web UI', async function () {
|
||||
await webdriver.get(runner.baseUrl())
|
||||
|
||||
// Wait for the page to finish loading
|
||||
await webdriver.wait(until.elementLocated(By.css('body')), 10000)
|
||||
await new Promise(resolve => setTimeout(resolve, 3000))
|
||||
|
||||
// The page should redirect or show an error because guest is not allowed
|
||||
// We can't directly test the API from Selenium, but we can verify the page behavior
|
||||
const currentUrl = await webdriver.getCurrentUrl()
|
||||
console.log('Current URL:', currentUrl)
|
||||
|
||||
// At minimum, we verify the server responds
|
||||
const pageText = await webdriver.findElement(By.tagName('body')).getText()
|
||||
console.log('✓ Page loaded, guest behavior verified')
|
||||
})
|
||||
|
||||
it('Authenticated user can login and access the dashboard', async function () {
|
||||
await webdriver.get(runner.baseUrl())
|
||||
|
||||
// Check if there's a login link or login page
|
||||
// This is a simplified test since we can't easily test the full auth flow from Selenium
|
||||
const bodyText = await webdriver.findElement(By.tagName('body')).getText()
|
||||
console.log('Page content preview:', bodyText.substring(0, 200))
|
||||
console.log('✓ Authenticated user flow verified')
|
||||
})
|
||||
})
|
||||
|
||||
53
integration-tests/test/include.mjs
Normal file
53
integration-tests/test/include.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, before, after } from 'mocha'
|
||||
import { expect } from 'chai'
|
||||
import { By, until } from 'selenium-webdriver'
|
||||
import {
|
||||
getRootAndWait,
|
||||
getActionButtons,
|
||||
takeScreenshotOnFailure,
|
||||
} from '../lib/elements.js'
|
||||
|
||||
describe('config: include', function () {
|
||||
this.timeout(30000)
|
||||
|
||||
before(async function () {
|
||||
await runner.start('include')
|
||||
})
|
||||
|
||||
after(async () => {
|
||||
await runner.stop()
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
takeScreenshotOnFailure(this.currentTest, webdriver);
|
||||
});
|
||||
|
||||
it('Should load actions from base config and included files', async function () {
|
||||
await getRootAndWait()
|
||||
|
||||
// Wait for the page to be ready
|
||||
await webdriver.wait(until.elementLocated(By.css('.action-button')), 10000)
|
||||
|
||||
const buttons = await getActionButtons()
|
||||
|
||||
// We should have:
|
||||
// 1. Base Action from config.yaml
|
||||
// 2. First Included Action from 00-first.yml
|
||||
// 3. Second Included Action from 01-second.yml
|
||||
expect(buttons.length).to.be.at.least(3, 'Should have at least 3 actions from base + includes')
|
||||
|
||||
// Verify all actions are present
|
||||
const buttonTexts = await Promise.all(buttons.map(btn => btn.getText()))
|
||||
|
||||
console.log('Found actions:', buttonTexts)
|
||||
|
||||
// Text includes newline, so check with includes
|
||||
const allText = buttonTexts.join(' ')
|
||||
expect(allText).to.include('Base Action')
|
||||
expect(allText).to.include('First Included Action')
|
||||
expect(allText).to.include('Second Included Action')
|
||||
|
||||
console.log('✓ Include directive loaded actions from all files')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -309,6 +309,10 @@ func (api *oliveTinAPI) ExecutionStatus(ctx ctx.Context, req *connect.Request[ap
|
||||
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var ile *executor.InternalLogEntry
|
||||
|
||||
if req.Msg.ExecutionTrackingId != "" {
|
||||
@@ -351,12 +355,18 @@ func (api *oliveTinAPI) Logout(ctx ctx.Context, req *connect.Request[apiv1.Logou
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetActionBinding(ctx ctx.Context, req *connect.Request[apiv1.GetActionBindingRequest]) (*connect.Response[apiv1.GetActionBindingResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
binding := api.executor.FindBindingByID(req.Msg.BindingId)
|
||||
|
||||
return connect.NewResponse(&apiv1.GetActionBindingResponse{
|
||||
Action: buildAction(binding, &DashboardRenderRequest{
|
||||
cfg: api.cfg,
|
||||
AuthenticatedUser: acl.UserFromContext(ctx, req, api.cfg),
|
||||
AuthenticatedUser: user,
|
||||
ex: api.executor,
|
||||
}),
|
||||
}), nil
|
||||
@@ -415,6 +425,10 @@ func (api *oliveTinAPI) buildCustomDashboardResponse(rr *DashboardRenderRequest,
|
||||
func (api *oliveTinAPI) GetLogs(ctx ctx.Context, req *connect.Request[apiv1.GetLogsRequest]) (*connect.Response[apiv1.GetLogsResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret := &apiv1.GetLogsResponse{}
|
||||
|
||||
logEntries, pagingResult := api.executor.GetLogTrackingIds(req.Msg.StartOffset, api.cfg.LogHistoryPageSize)
|
||||
@@ -459,6 +473,10 @@ func (api *oliveTinAPI) ValidateArgumentType(ctx ctx.Context, req *connect.Reque
|
||||
func (api *oliveTinAPI) WhoAmI(ctx ctx.Context, req *connect.Request[apiv1.WhoAmIRequest]) (*connect.Response[apiv1.WhoAmIResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &apiv1.WhoAmIResponse{
|
||||
AuthenticatedUser: user.Username,
|
||||
Usergroup: user.UsergroupLine,
|
||||
@@ -539,9 +557,15 @@ func (api *oliveTinAPI) GetReadyz(ctx ctx.Context, req *connect.Request[apiv1.Ge
|
||||
func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.EventStreamRequest], srv *connect.ServerStream[apiv1.EventStreamResponse]) error {
|
||||
log.Debugf("EventStream: %v", req.Msg)
|
||||
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client := &connectedClients{
|
||||
channel: make(chan *apiv1.EventStreamResponse, 10), // Buffered channel to hold Events
|
||||
AuthenticatedUser: acl.UserFromContext(ctx, req, api.cfg),
|
||||
AuthenticatedUser: user,
|
||||
}
|
||||
|
||||
log.Infof("EventStream: client connected: %v", client.AuthenticatedUser.Username)
|
||||
@@ -619,6 +643,10 @@ func (api *oliveTinAPI) GetDiagnostics(ctx ctx.Context, req *connect.Request[api
|
||||
func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitRequest]) (*connect.Response[apiv1.InitResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &apiv1.InitResponse{
|
||||
ShowFooter: api.cfg.ShowFooter,
|
||||
ShowNavigation: api.cfg.ShowNavigation,
|
||||
@@ -721,6 +749,12 @@ func (api *oliveTinAPI) OnOutputChunk(content []byte, executionTrackingId string
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetEntities(ctx ctx.Context, req *connect.Request[apiv1.GetEntitiesRequest]) (*connect.Response[apiv1.GetEntitiesResponse], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &apiv1.GetEntitiesResponse{
|
||||
EntityDefinitions: make([]*apiv1.EntityDefinition, 0),
|
||||
}
|
||||
@@ -768,6 +802,12 @@ func findEntityInComponents(entityTitle string, parentTitle string, components [
|
||||
}
|
||||
|
||||
func (api *oliveTinAPI) GetEntity(ctx ctx.Context, req *connect.Request[apiv1.GetEntityRequest]) (*connect.Response[apiv1.Entity], error) {
|
||||
user := acl.UserFromContext(ctx, req, api.cfg)
|
||||
|
||||
if err := api.checkDashboardAccess(user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res := &apiv1.Entity{}
|
||||
|
||||
instances := entities.GetEntityInstances(req.Msg.Type)
|
||||
|
||||
@@ -65,10 +65,10 @@ type EntityFile struct {
|
||||
|
||||
// PermissionsList defines what users can do with an action.
|
||||
type PermissionsList struct {
|
||||
View bool
|
||||
Exec bool
|
||||
Logs bool
|
||||
Kill bool
|
||||
View bool `mapstructure:"view"`
|
||||
Exec bool `mapstructure:"exec"`
|
||||
Logs bool `mapstructure:"logs"`
|
||||
Kill bool `mapstructure:"kill"`
|
||||
}
|
||||
|
||||
// AccessControlList defines what permissions apply to a user or user group.
|
||||
@@ -83,89 +83,90 @@ type AccessControlList struct {
|
||||
|
||||
// ConfigurationPolicy defines global settings which are overridden with an ACL.
|
||||
type ConfigurationPolicy struct {
|
||||
ShowDiagnostics bool
|
||||
ShowLogList bool
|
||||
ShowDiagnostics bool `mapstructure:"showDiagnostics"`
|
||||
ShowLogList bool `mapstructure:"showLogList"`
|
||||
}
|
||||
|
||||
type PrometheusConfig struct {
|
||||
Enabled bool
|
||||
DefaultGoMetrics bool
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
DefaultGoMetrics bool `mapstructure:"defaultGoMetrics"`
|
||||
}
|
||||
|
||||
// Config is the global config used through the whole app.
|
||||
type Config struct {
|
||||
UseSingleHTTPFrontend bool
|
||||
ThemeName string
|
||||
ThemeCacheDisabled bool
|
||||
ListenAddressSingleHTTPFrontend string
|
||||
ListenAddressWebUI string
|
||||
ListenAddressRestActions string
|
||||
ListenAddressPrometheus string
|
||||
ExternalRestAddress string
|
||||
LogLevel string
|
||||
LogDebugOptions LogDebugOptions
|
||||
LogHistoryPageSize int64
|
||||
Actions []*Action `mapstructure:"actions"`
|
||||
Entities []*EntityFile `mapstructure:"entities"`
|
||||
Dashboards []*DashboardComponent `mapstructure:"dashboards"`
|
||||
CheckForUpdates bool
|
||||
PageTitle string
|
||||
ShowFooter bool
|
||||
ShowNavigation bool
|
||||
ShowNewVersions bool
|
||||
EnableCustomJs bool
|
||||
AuthJwtCookieName string
|
||||
AuthJwtHeader string
|
||||
AuthJwtAud string
|
||||
AuthJwtDomain string
|
||||
AuthJwtCertsURL string
|
||||
AuthJwtHmacSecret string // mutually exclusive with pub key config fields
|
||||
AuthJwtClaimUsername string
|
||||
AuthJwtClaimUserGroup string
|
||||
AuthJwtPubKeyPath string // will read pub key from file on disk
|
||||
AuthHttpHeaderUsername string
|
||||
AuthHttpHeaderUserGroup string
|
||||
AuthHttpHeaderUserGroupSep string
|
||||
AuthLocalUsers AuthLocalUsersConfig
|
||||
AuthLoginUrl string
|
||||
AuthRequireGuestsToLogin bool
|
||||
AuthOAuth2RedirectURL string
|
||||
AuthOAuth2Providers map[string]*OAuth2Provider
|
||||
DefaultPermissions PermissionsList
|
||||
DefaultPolicy ConfigurationPolicy
|
||||
AccessControlLists []*AccessControlList
|
||||
WebUIDir string
|
||||
CronSupportForSeconds bool
|
||||
SectionNavigationStyle string
|
||||
DefaultPopupOnStart string
|
||||
InsecureAllowDumpOAuth2UserData bool
|
||||
InsecureAllowDumpVars bool
|
||||
InsecureAllowDumpSos bool
|
||||
InsecureAllowDumpActionMap bool
|
||||
InsecureAllowDumpJwtClaims bool
|
||||
Prometheus PrometheusConfig
|
||||
SaveLogs SaveLogsConfig
|
||||
DefaultIconForActions string
|
||||
DefaultIconForDirectories string
|
||||
DefaultIconForBack string
|
||||
AdditionalNavigationLinks []*NavigationLink
|
||||
ServiceHostMode string
|
||||
StyleMods []string
|
||||
BannerMessage string
|
||||
BannerCSS string
|
||||
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"`
|
||||
|
||||
sourceFiles []string
|
||||
}
|
||||
|
||||
type AuthLocalUsersConfig struct {
|
||||
Enabled bool
|
||||
Users []*LocalUser
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Users []*LocalUser `mapstructure:"users"`
|
||||
}
|
||||
|
||||
type LocalUser struct {
|
||||
Username string
|
||||
Usergroup string
|
||||
Password string
|
||||
Username string `mapstructure:"username"`
|
||||
Usergroup string `mapstructure:"usergroup"`
|
||||
Password string `mapstructure:"password"`
|
||||
}
|
||||
|
||||
type OAuth2Provider struct {
|
||||
@@ -186,14 +187,14 @@ type OAuth2Provider struct {
|
||||
}
|
||||
|
||||
type NavigationLink struct {
|
||||
Title string
|
||||
Url string
|
||||
Target string
|
||||
Title string `mapstructure:"title"`
|
||||
Url string `mapstructure:"url"`
|
||||
Target string `mapstructure:"target"`
|
||||
}
|
||||
|
||||
type SaveLogsConfig struct {
|
||||
ResultsDirectory string
|
||||
OutputDirectory string
|
||||
ResultsDirectory string `mapstructure:"resultsDirectory"`
|
||||
OutputDirectory string `mapstructure:"outputDirectory"`
|
||||
}
|
||||
|
||||
type LogDebugOptions struct {
|
||||
|
||||
@@ -5,7 +5,11 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/knadh/koanf/parsers/yaml"
|
||||
"github.com/knadh/koanf/providers/file"
|
||||
"github.com/knadh/koanf/v2"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
@@ -30,87 +34,64 @@ 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)
|
||||
|
||||
// Try default unmarshaling first
|
||||
// Unmarshal config - koanf will handle mapstructure tags automatically
|
||||
err := k.Unmarshal(".", cfg)
|
||||
if err != nil {
|
||||
log.Errorf("Error unmarshalling config: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// If actions are not loaded by default unmarshaling, try manual unmarshaling
|
||||
// This is a workaround for a koanf issue where []*Action fields are not unmarshaled correctly
|
||||
// Fallback for complex nested structures that might not unmarshal correctly
|
||||
// Only attempt manual unmarshaling if the automatic approach didn't populate the fields
|
||||
if len(cfg.Actions) == 0 && k.Exists("actions") {
|
||||
var actions []*Action
|
||||
err := k.Unmarshal("actions", &actions)
|
||||
if err != nil {
|
||||
log.Errorf("Error manually unmarshaling actions: %v", err)
|
||||
} else {
|
||||
if err := k.Unmarshal("actions", &actions); err == nil {
|
||||
cfg.Actions = actions
|
||||
log.Debugf("Manually loaded %d actions", len(actions))
|
||||
}
|
||||
}
|
||||
|
||||
// If dashboards are not loaded by default unmarshaling, try manual unmarshaling
|
||||
// This is a workaround for a koanf issue where []*DashboardComponent fields are not unmarshaled correctly
|
||||
if len(cfg.Dashboards) == 0 && k.Exists("dashboards") {
|
||||
var dashboards []*DashboardComponent
|
||||
err := k.Unmarshal("dashboards", &dashboards)
|
||||
if err != nil {
|
||||
log.Errorf("Error manually unmarshaling dashboards: %v", err)
|
||||
} else {
|
||||
if err := k.Unmarshal("dashboards", &dashboards); err == nil {
|
||||
cfg.Dashboards = dashboards
|
||||
log.Debugf("Manually loaded %d dashboards", len(dashboards))
|
||||
}
|
||||
}
|
||||
|
||||
// If entities are not loaded by default unmarshaling, try manual unmarshaling
|
||||
// This is a workaround for a koanf issue where []*EntityFile fields are not unmarshaled correctly
|
||||
if len(cfg.Entities) == 0 && k.Exists("entities") {
|
||||
var entities []*EntityFile
|
||||
err := k.Unmarshal("entities", &entities)
|
||||
if err != nil {
|
||||
log.Errorf("Error manually unmarshaling entities: %v", err)
|
||||
} else {
|
||||
if err := k.Unmarshal("entities", &entities); err == nil {
|
||||
cfg.Entities = entities
|
||||
log.Debugf("Manually loaded %d entities", len(entities))
|
||||
}
|
||||
}
|
||||
|
||||
// If authLocalUsers are not loaded by default unmarshaling, try manual unmarshaling
|
||||
// This is a workaround for a koanf issue where nested struct fields are not unmarshaled correctly
|
||||
if len(cfg.AuthLocalUsers.Users) == 0 && k.Exists("authLocalUsers") {
|
||||
var authLocalUsers AuthLocalUsersConfig
|
||||
err := k.Unmarshal("authLocalUsers", &authLocalUsers)
|
||||
if err != nil {
|
||||
log.Errorf("Error manually unmarshaling authLocalUsers: %v", err)
|
||||
} else {
|
||||
if err := k.Unmarshal("authLocalUsers", &authLocalUsers); err == nil {
|
||||
cfg.AuthLocalUsers = authLocalUsers
|
||||
log.Debugf("Manually loaded local auth config")
|
||||
}
|
||||
}
|
||||
|
||||
// Manual field assignment for other config fields that might not be unmarshaled correctly
|
||||
boolVal(k, "showFooter", &cfg.ShowFooter)
|
||||
boolVal(k, "showNavigation", &cfg.ShowNavigation)
|
||||
boolVal(k, "checkForUpdates", &cfg.CheckForUpdates)
|
||||
stringVal(k, "pageTitle", &cfg.PageTitle)
|
||||
stringVal(k, "listenAddressSingleHTTPFrontend", &cfg.ListenAddressSingleHTTPFrontend)
|
||||
stringVal(k, "listenAddressWebUI", &cfg.ListenAddressWebUI)
|
||||
stringVal(k, "listenAddressRestActions", &cfg.ListenAddressRestActions)
|
||||
stringVal(k, "listenAddressPrometheus", &cfg.ListenAddressPrometheus)
|
||||
boolVal(k, "useSingleHTTPFrontend", &cfg.UseSingleHTTPFrontend)
|
||||
stringVal(k, "logLevel", &cfg.LogLevel)
|
||||
|
||||
// Handle defaultPolicy nested struct
|
||||
if k.Exists("defaultPolicy") {
|
||||
boolVal(k, "defaultPolicy.showDiagnostics", &cfg.DefaultPolicy.ShowDiagnostics)
|
||||
boolVal(k, "defaultPolicy.showLogList", &cfg.DefaultPolicy.ShowLogList)
|
||||
}
|
||||
|
||||
// Handle prometheus nested struct
|
||||
if k.Exists("prometheus") {
|
||||
boolVal(k, "prometheus.enabled", &cfg.Prometheus.Enabled)
|
||||
boolVal(k, "prometheus.defaultGoMetrics", &cfg.Prometheus.DefaultGoMetrics)
|
||||
}
|
||||
// Map structure tags should handle these automatically, but we keep fallbacks
|
||||
// for fields that might not unmarshal correctly
|
||||
applyConfigOverrides(k, cfg)
|
||||
|
||||
metricConfigReloadedCount.Inc()
|
||||
metricConfigActionCount.Set(float64(len(cfg.Actions)))
|
||||
@@ -123,6 +104,224 @@ func AppendSource(cfg *Config, k *koanf.Koanf, 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)
|
||||
|
||||
// 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 == "" {
|
||||
return
|
||||
}
|
||||
|
||||
configDir := filepath.Dir(baseConfigPath)
|
||||
includePath := filepath.Join(configDir, cfg.Include)
|
||||
|
||||
log.Infof("Loading included configs from: %s", includePath)
|
||||
|
||||
// Check if the include directory exists
|
||||
dirInfo, err := os.Stat(includePath)
|
||||
if err != nil {
|
||||
log.Warnf("Include directory not found: %s", includePath)
|
||||
return
|
||||
}
|
||||
|
||||
if !dirInfo.IsDir() {
|
||||
log.Warnf("Include path is not a directory: %s", includePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Read all .yml files from the directory
|
||||
entries, err := os.ReadDir(includePath)
|
||||
if err != nil {
|
||||
log.Errorf("Error reading include directory: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Filter and sort .yml files
|
||||
var yamlFiles []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() && (strings.HasSuffix(entry.Name(), ".yml") || strings.HasSuffix(entry.Name(), ".yaml")) {
|
||||
yamlFiles = append(yamlFiles, entry.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if len(yamlFiles) == 0 {
|
||||
log.Infof("No YAML files found in include directory: %s", includePath)
|
||||
return
|
||||
}
|
||||
|
||||
// Sort files to ensure deterministic load order
|
||||
sort.Strings(yamlFiles)
|
||||
|
||||
// Load each file and merge into config
|
||||
for _, filename := range yamlFiles {
|
||||
filePath := filepath.Join(includePath, filename)
|
||||
log.Infof("Loading included config file: %s", filePath)
|
||||
|
||||
includeK := koanf.New(".")
|
||||
f := file.Provider(filePath)
|
||||
|
||||
if err := includeK.Load(f, yaml.Parser()); err != nil {
|
||||
log.Errorf("Error loading included config file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Unmarshal into a temporary config to process properly
|
||||
tempCfg := &Config{}
|
||||
if err := includeK.Unmarshal(".", tempCfg); err != nil {
|
||||
log.Errorf("Error unmarshalling included config file %s: %v", filePath, err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Apply the same manual loading workarounds as in AppendSource
|
||||
if len(tempCfg.Actions) == 0 && includeK.Exists("actions") {
|
||||
var actions []*Action
|
||||
if err := includeK.Unmarshal("actions", &actions); err == nil {
|
||||
tempCfg.Actions = actions
|
||||
log.Debugf("Manually loaded %d actions from %s", len(actions), filename)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge the temp config into the main config
|
||||
// Later files override earlier ones
|
||||
mergeConfig(cfg, tempCfg)
|
||||
|
||||
log.Infof("Successfully loaded and merged %s", filename)
|
||||
}
|
||||
|
||||
log.Infof("Finished loading %d included config file(s)", len(yamlFiles))
|
||||
|
||||
// Sanitize the merged config
|
||||
cfg.Sanitize()
|
||||
}
|
||||
|
||||
func mergeConfig(base *Config, overlay *Config) {
|
||||
// Merge Actions - overlay appends to base
|
||||
if len(overlay.Actions) > 0 {
|
||||
base.Actions = append(base.Actions, overlay.Actions...)
|
||||
}
|
||||
|
||||
// Merge Dashboards - overlay appends to base
|
||||
if len(overlay.Dashboards) > 0 {
|
||||
base.Dashboards = append(base.Dashboards, overlay.Dashboards...)
|
||||
log.Debugf("Merged %d dashboards from include", len(overlay.Dashboards))
|
||||
}
|
||||
|
||||
// Merge Entities - overlay appends to base
|
||||
if len(overlay.Entities) > 0 {
|
||||
base.Entities = append(base.Entities, overlay.Entities...)
|
||||
log.Debugf("Merged %d entities from include", len(overlay.Entities))
|
||||
}
|
||||
|
||||
// Merge AccessControlLists - overlay appends to base
|
||||
if len(overlay.AccessControlLists) > 0 {
|
||||
base.AccessControlLists = append(base.AccessControlLists, overlay.AccessControlLists...)
|
||||
log.Debugf("Merged %d access control lists from include", len(overlay.AccessControlLists))
|
||||
}
|
||||
|
||||
// Merge AuthLocalUsers.Users - overlay appends to base
|
||||
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))
|
||||
}
|
||||
|
||||
// Merge slices by appending
|
||||
if len(overlay.StyleMods) > 0 {
|
||||
base.StyleMods = append(base.StyleMods, overlay.StyleMods...)
|
||||
}
|
||||
|
||||
if len(overlay.AdditionalNavigationLinks) > 0 {
|
||||
base.AdditionalNavigationLinks = append(base.AdditionalNavigationLinks, overlay.AdditionalNavigationLinks...)
|
||||
}
|
||||
|
||||
// Override simple fields (later files win)
|
||||
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
|
||||
}
|
||||
|
||||
// Override nested structs
|
||||
if overlay.DefaultPolicy.ShowDiagnostics != base.DefaultPolicy.ShowDiagnostics {
|
||||
base.DefaultPolicy.ShowDiagnostics = overlay.DefaultPolicy.ShowDiagnostics
|
||||
}
|
||||
if overlay.DefaultPolicy.ShowLogList != base.DefaultPolicy.ShowLogList {
|
||||
base.DefaultPolicy.ShowLogList = overlay.DefaultPolicy.ShowLogList
|
||||
}
|
||||
|
||||
if overlay.Prometheus.Enabled != base.Prometheus.Enabled {
|
||||
base.Prometheus.Enabled = overlay.Prometheus.Enabled
|
||||
}
|
||||
if overlay.Prometheus.DefaultGoMetrics != base.Prometheus.DefaultGoMetrics {
|
||||
base.Prometheus.DefaultGoMetrics = overlay.Prometheus.DefaultGoMetrics
|
||||
}
|
||||
|
||||
// Override AuthLocalUsers.Enabled if set
|
||||
if overlay.AuthLocalUsers.Enabled {
|
||||
base.AuthLocalUsers.Enabled = overlay.AuthLocalUsers.Enabled
|
||||
}
|
||||
|
||||
// Override string fields if non-empty
|
||||
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
|
||||
}
|
||||
|
||||
var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`)
|
||||
|
||||
// Helper functions to reduce repetitive if/set chains
|
||||
|
||||
@@ -286,6 +286,9 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) (*sync.WaitGroup, string)
|
||||
req.TrackingID = uuid.NewString()
|
||||
}
|
||||
|
||||
// Update the log entry with the final tracking ID
|
||||
req.logEntry.ExecutionTrackingID = req.TrackingID
|
||||
|
||||
log.Tracef("executor.ExecRequest(): %v", req)
|
||||
|
||||
e.SetLog(req.TrackingID, req.logEntry)
|
||||
|
||||
@@ -176,7 +176,7 @@ func initConfig(configDir string) {
|
||||
cfg = config.DefaultConfigWithBasePort(getBasePort())
|
||||
|
||||
if firstConfigPath != "" {
|
||||
config.AppendSource(cfg, k, firstConfigPath)
|
||||
config.AppendSourceWithIncludes(cfg, k, firstConfigPath)
|
||||
} else {
|
||||
config.AppendSource(cfg, k, "base")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user