This commit is contained in:
James Read 2026-07-29 00:39:30 +00:00 committed by GitHub
commit 1a31c4dee4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
100 changed files with 1862 additions and 723 deletions

View File

@ -22,6 +22,7 @@ Please put a X in the boxes as evidence of reading through the checklist.
- [ ] `make -wC service compile` runs without any issues. - [ ] `make -wC service compile` runs without any issues.
- [ ] `make -wC service codestyle` runs without any issues. - [ ] `make -wC service codestyle` runs without any issues.
- [ ] `make -wC service unittests` runs without any issues. - [ ] `make -wC service unittests` runs without any issues.
- [ ] `make -wC webui codestyle` runs without any issues. - [ ] `make -wC frontend codestyle` runs without any issues.
- [ ] `make -w frontend-unittests` runs without any issues.
- [ ] `make -w it` runs without any issues. - [ ] `make -w it` runs without any issues.
- [ ] I understand and accept the [AGPL-3.0 license](LICENSE) and [code of conduct](CODE_OF_CONDUCT.md), and my contributions fall under these. - [ ] I understand and accept the [AGPL-3.0 license](LICENSE) and [code of conduct](CODE_OF_CONDUCT.md), and my contributions fall under these.

View File

@ -104,6 +104,9 @@ jobs:
- name: unit tests - name: unit tests
run: make -w service-unittests run: make -w service-unittests
- name: frontend unit tests
run: make -w frontend-unittests
- name: build service - name: build service
run: make -w service run: make -w service

View File

@ -38,3 +38,6 @@ jobs:
- name: frontend - name: frontend
run: make -wC frontend codestyle run: make -wC frontend codestyle
- name: frontend unit tests
run: make -wC frontend unittests

View File

@ -47,6 +47,13 @@ repos:
pass_filenames: false pass_filenames: false
files: ^(frontend/|Makefile) files: ^(frontend/|Makefile)
- id: frontend-unittests
name: frontend-unittests
entry: make frontend-unittests
language: system
pass_filenames: false
files: ^(frontend/|Makefile)
- id: service-unittests - id: service-unittests
name: service-unittests name: service-unittests
entry: make service-unittests entry: make service-unittests

View File

@ -23,6 +23,9 @@ service-codestyle:
frontend-codestyle: frontend-codestyle:
$(MAKE) -wC frontend codestyle $(MAKE) -wC frontend codestyle
frontend-unittests:
$(MAKE) -wC frontend unittests
it: it:
$(MAKE) -wC integration-tests $(MAKE) -wC integration-tests
@ -76,4 +79,4 @@ config-tool:
devcheck: devcheck:
python3 scripts/devcheck.py $(ARGS) python3 scripts/devcheck.py $(ARGS)
.PHONY: proto service windows-resources windows-msi devcheck .PHONY: proto service windows-resources windows-msi frontend-unittests devcheck

View File

@ -2,7 +2,8 @@
# one port (this is called the "Single HTTP Frontend") and means you just need # one port (this is called the "Single HTTP Frontend") and means you just need
# one open port in the container/firewalls/etc. # one open port in the container/firewalls/etc.
# #
# Listen on all addresses available, port 1337 # Listen on all addresses available, port 1337.
# If the PORT environment variable is set, it overrides this port (host is kept).
listenAddressSingleHTTPFrontend: 0.0.0.0:1337 listenAddressSingleHTTPFrontend: 0.0.0.0:1337
# Choose from INFO (default), WARN and DEBUG # Choose from INFO (default), WARN and DEBUG
@ -73,7 +74,7 @@ actions:
icon: backup icon: backup
onclick: execution-dialog onclick: execution-dialog
# https://docs.olivetin.app/action_execution/oncalendar.html # https://docs.olivetin.app/action_execution/oncalendar.html
execOnCalendarFile: examples/demo-olivetin-calendar.yaml # execOnCalendarFile: examples/demo-olivetin-calendar.yaml
- title: Verify backup archive - title: Verify backup archive
shell: sleep 3 && echo "Backup archive verified" shell: sleep 3 && echo "Backup archive verified"
@ -145,7 +146,7 @@ actions:
title: Are you sure?! title: Are you sure?!
# Checklist arguments let users pick multiple predefined options. Selected # Checklist arguments let users pick multiple predefined options. Selected
# values are passed to the action as a comma-separated string. # values are passed to the action as a JSON array string.
# #
# Docs: https://docs.olivetin.app/args/input_checklist.html # Docs: https://docs.olivetin.app/args/input_checklist.html
- title: Backup selected directories - title: Backup selected directories
@ -165,7 +166,7 @@ actions:
value: music value: music
- title: Videos - title: Videos
value: videos value: videos
default: documents,photos default: '["documents","photos"]'
# This is an action that runs a script included with OliveTin, that will # This is an action that runs a script included with OliveTin, that will
# download themes. You will still need to set theme "themeName" in your config. # download themes. You will still need to set theme "themeName" in your config.
@ -262,8 +263,8 @@ actions:
icon: ping icon: ping
# https://docs.olivetin.app/action_execution/onfilecreated.html # https://docs.olivetin.app/action_execution/onfilecreated.html
# mkdir -p /tmp/olivetin-demo-file-created # mkdir -p /tmp/olivetin-demo-file-created
execOnFileCreatedInDir: # execOnFileCreatedInDir:
- /tmp/olivetin-demo-file-created # - /tmp/olivetin-demo-file-created
- title: Start {{ .CurrentEntity.Names }} - title: Start {{ .CurrentEntity.Names }}
icon: box icon: box
@ -352,8 +353,10 @@ actionGroups:
# #
# Docs: https://docs.olivetin.app/dashboards/intro.html # Docs: https://docs.olivetin.app/dashboards/intro.html
dashboards: dashboards:
# Top level items are dashboards. # Top level items are dashboards. Optional `category` groups them under
# collapsible headings in the sidebar (same label = same group).
- title: My Servers - title: My Servers
category: Infrastructure
contents: contents:
- title: All Servers - title: All Servers
type: fieldset type: fieldset
@ -399,8 +402,10 @@ dashboards:
contents: contents:
- title: '{{ server.name }} Print server name' - title: '{{ server.name }} Print server name'
# This is the second dashboard. # Second dashboard — same category as My Servers, so both appear under
# "Infrastructure" in the sidebar.
- title: My Containers - title: My Containers
category: Infrastructure
contents: contents:
- title: 'Container {{ .CurrentEntity.Names }} ({{ .CurrentEntity.Image }})' - title: 'Container {{ .CurrentEntity.Names }} ({{ .CurrentEntity.Image }})'
entity: container entity: container

View File

@ -13,13 +13,15 @@ actions:
shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\"" shellAfterCompleted: "apprise -c /config/apprise.yml -t 'Notification: Backup script completed' -b \"$(printf 'Backup completed with exit code %s. Log: %s' {{ exitCode }} {{ output }})\""
---- ----
When running shellAfterCompleted, you *cannot* use argument values - they are not passed to the command. However the following special arguments are defined; When running shellAfterCompleted, you *cannot* use user-defined argument values - they are not passed to the command. However the following special arguments are defined;
* `{{ exitCode }}` - The exit code of the previous shell command. OliveTin substitutes this with the `EXITCODE` environment variable when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting. * `{{ exitCode }}` / `{{ .Arguments.exitCode }}` - The exit code of the previous command. OliveTin rewrites these placeholders to the quoted `"$EXITCODE"` environment reference when running `shellAfterCompleted`, so shell metacharacters in the value cannot break quoting. Placeholders inside single-quoted shell arguments are rewritten so the environment reference can still expand.
* `{{ output }}` - The standard output of the previous shell command. OliveTin substitutes this with the `OUTPUT` environment variable when running `shellAfterCompleted`, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Do not place these placeholders inside single-quoted shell arguments; single quotes prevent `$OUTPUT` and `$EXITCODE` from expanding after substitution. * `{{ output }}` / `{{ .Arguments.output }}` - The standard output of the previous command. OliveTin rewrites these placeholders to the quoted `"$OUTPUT"` environment reference, so shell metacharacters in command output cannot be executed. You can also reference `$OUTPUT` directly in your `shellAfterCompleted` command. Placeholders inside single-quoted shell arguments are rewritten so the environment reference can still expand.
* `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`) * `{{ .Arguments.ot_executionTrackingId }}` - The unique execution tracking id for this execution (version 3k; in 2k use `{{ ot_executionTrackingId }}`)
* `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs. * `{{ .Arguments.ot_username }}` - The username of the user who started the execution (version 3k; in 2k use `{{ ot_username }}`). May be `guest` or `cron` for unauthenticated or automated runs.
Webhooks cannot use `shellAfterCompleted` (or `shell:`). Use `exec:` for webhook-triggered actions without an after-completion shell. See xref:action_execution/shellvsexec.adoc[Shell vs Exec].
You can only use a single `shellAfterCompleted`, so use it for notifications, or similar. It would be an antipattern to use this do run 2 commands making up a mini script. You can only use a single `shellAfterCompleted`, so use it for notifications, or similar. It would be an antipattern to use this do run 2 commands making up a mini script.
The official OliveTin container images from version 2024.03.24 onwards include the fantastic apprise tool, which makes chat notifications on many protocols very easy. The official OliveTin container images from version 2024.03.24 onwards include the fantastic apprise tool, which makes chat notifications on many protocols very easy.

View File

@ -7,7 +7,7 @@ OliveTin provides a dedicated webhook endpoint at `/webhooks` that can receive w
== Basic Configuration == Basic Configuration
To configure an action to run on a webhook, add the `execOnWebhook` property to your action: To configure an action to run on a webhook, add the `execOnWebhook` property to your action. Webhook-triggered actions **must** use `exec:` (not `shell:` or `shellAfterCompleted`).
[source,yaml] [source,yaml]
.`config.yaml` .`config.yaml`
@ -15,7 +15,8 @@ To configure an action to run on a webhook, add the `execOnWebhook` property to
actions: actions:
- title: Deploy Application - title: Deploy Application
id: deploy id: deploy
shell: /opt/scripts/deploy.sh exec:
- /opt/scripts/deploy.sh
execOnWebhook: execOnWebhook:
- matchHeaders: - matchHeaders:
X-Event-Type: deploy X-Event-Type: deploy
@ -51,7 +52,9 @@ Match webhooks based on HTTP header values:
---- ----
actions: actions:
- title: Process Event - title: Process Event
shell: echo "Processing event" exec:
- echo
- "Processing event"
execOnWebhook: execOnWebhook:
- matchHeaders: - matchHeaders:
X-Event-Type: my-event X-Event-Type: my-event
@ -68,7 +71,9 @@ Match webhooks based on URL query parameters:
---- ----
actions: actions:
- title: Process Request - title: Process Request
shell: echo "Processing request for {{ service }}" exec:
- echo
- "Processing request for {{ service }}"
arguments: arguments:
- name: service - name: service
type: ascii type: ascii
@ -76,9 +81,11 @@ actions:
- matchQuery: - matchQuery:
action: deploy action: deploy
env: production env: production
extract:
service: "$.service"
---- ----
A request to `/webhooks?action=deploy&env=production` would match this action. A request to `/webhooks?action=deploy&env=production` with a JSON body containing `"service"` would match this action and pass that field into the `service` argument.
=== Match by JSON Body Path === Match by JSON Body Path
@ -88,12 +95,16 @@ Match webhooks based on values in the JSON request body using JSONPath expressio
---- ----
actions: actions:
- title: Handle Push Event - title: Handle Push Event
shell: echo "Push to {{ branch }}" exec:
- echo
- "Push to {{ branch }}"
arguments: arguments:
- name: branch - name: branch
type: ascii type: ascii
execOnWebhook: execOnWebhook:
- matchPath: "$.event_type=push" - matchPath: "$.event_type=push"
extract:
branch: "$.branch"
---- ----
The `matchPath` format is `jsonpath=value`. You can also just specify a JSONPath without a value to match if the path exists: The `matchPath` format is `jsonpath=value`. You can also just specify a JSONPath without a value to match if the path exists:
@ -112,7 +123,9 @@ Header and query parameter values can use regex patterns by prefixing with `rege
---- ----
actions: actions:
- title: Handle Multiple Events - title: Handle Multiple Events
shell: echo "Handling event" exec:
- echo
- "Handling event"
execOnWebhook: execOnWebhook:
- matchHeaders: - matchHeaders:
X-Event-Type: "regex:^(push|pull_request|release)$" X-Event-Type: "regex:^(push|pull_request|release)$"
@ -126,7 +139,9 @@ You can combine multiple match criteria. All criteria must match for the webhook
---- ----
actions: actions:
- title: Production Deploy - title: Production Deploy
shell: /opt/scripts/deploy.sh production exec:
- /opt/scripts/deploy.sh
- production
execOnWebhook: execOnWebhook:
- matchHeaders: - matchHeaders:
X-Event-Type: deploy X-Event-Type: deploy
@ -143,9 +158,10 @@ You can extract values from the webhook payload and pass them as arguments to yo
---- ----
actions: actions:
- title: Deploy Version - title: Deploy Version
shell: | exec:
echo "Deploying version {{ version }} to {{ environment }}" - /opt/scripts/deploy.sh
/opt/scripts/deploy.sh "{{ version }}" "{{ environment }}" - "{{ version }}"
- "{{ environment }}"
arguments: arguments:
- name: version - name: version
type: ascii type: ascii
@ -176,7 +192,9 @@ For example, to access the `X-Request-Id` header in your action:
---- ----
actions: actions:
- title: Log Request - title: Log Request
shell: echo "Request ID: {{ webhook_header_x-request-id }}" exec:
- echo
- 'Request ID: {{ index .Arguments "webhook_header_x-request-id" }}'
arguments: arguments:
- name: webhook_header_x-request-id - name: webhook_header_x-request-id
type: ascii type: ascii
@ -185,6 +203,8 @@ actions:
X-Event-Type: log X-Event-Type: log
---- ----
Header names that contain hyphens become argument keys with the same hyphens (for example `webhook_header_x-request-id`). Use the `index` map-lookup form shown above, because Go templates treat hyphens in bare identifiers as subtraction.
== Webhook Authentication == Webhook Authentication
OliveTin supports several authentication methods to verify webhook requests: OliveTin supports several authentication methods to verify webhook requests:
@ -278,7 +298,8 @@ An action can have multiple webhook configurations. The action will be triggered
---- ----
actions: actions:
- title: Deploy - title: Deploy
shell: /opt/scripts/deploy.sh exec:
- /opt/scripts/deploy.sh
execOnWebhook: execOnWebhook:
- matchHeaders: - matchHeaders:
X-Event-Type: deploy-manual X-Event-Type: deploy-manual

View File

@ -1,13 +1,25 @@
= Shell vs Exec = Shell vs Exec
OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts strings, and will wrap that whole command in a shell with "bash -c". Exec uses a syscall directly to execute commands. OliveTin supports two different methods to run commands: `shell` and `exec`. The difference between these two is that "shell" accepts a single string and runs it via the system shell (`sh -c` on Unix; `cmd /C` on Windows). Exec passes an argument vector directly to the operating system without invoking a shell.
* **Shell** is more flexible, because it allows you to chain commands (eg, using &&) and redirect or pipe output (eg: ">" or "|"). * **Shell** is more flexible, because it allows you to chain commands (eg, using &&) and redirect or pipe output (eg: ">" or "|").
* **Exec** is more secure, because it does not invoke a shell, and thus avoids shell injection attacks. * **Exec** is more secure, because it does not invoke a shell, and thus avoids shell injection attacks.
Shell can be safe and secure with simple argument types (like ascii_identifier), but some argument types like URL can contain basically any character - /, :, ?, &, etc - which can lead to shell injection vulnerabilities while still being a valid URL. Shell can be safe and secure with simple argument types (like `ascii_identifier`), but some argument types like `url` can contain characters such as `/`, `:`, `?`, and `&` which can lead to shell injection vulnerabilities while still being a valid URL.
OliveTin will try and prevent you from using dangerous characters in shell commands (eg, URL is no longer permitted with Shell). OliveTin blocks unsafe argument types from being used with `shell:` (for example `url`, `email`, `password`, `regex:...`, and raw string types). See xref:args/types.adoc#shell-blocked-arg-types[Types that cannot be used with shell]. Prefer `exec:` when in doubt.
[#shell-entity-env-trust]
== Entity and `.Env` values are not shell-sanitized
User-supplied **argument** values are type-checked (and some types are blocked with `shell`) to reduce shell injection risk. That protection does **not** apply to:
* Entity fields — `{{ .CurrentEntity.field }}` (and legacy forms such as `{{ server.hostname }}`)
* Process environment — `{{ .Env.VAR_NAME }}`
Those values are substituted into `shell` / `shellAfterCompleted` as-is. OliveTin assumes they are **server-controlled** (entity files and the OliveTin process environment under the operator's control). The author of the config is responsible for ensuring that data is trustworthy, or for using `exec` and careful quoting when it might not be.
Webhooks cannot use `shell:` or `shellAfterCompleted`; webhook-triggered actions must use `exec:` only. See xref:action_execution/onwebhook.adoc[Execute on webhook].
The way that you specify these two types of execution is different - `shell` expects a single string, while `exec` expects a list of strings (the first being the command, the rest being the arguments). The way that you specify these two types of execution is different - `shell` expects a single string, while `exec` expects a list of strings (the first being the command, the rest being the arguments).

View File

@ -51,4 +51,6 @@ actions:
`.Env` uses the same Go template context as other action variables (e.g. `.Arguments`, `.CurrentEntity`, `.OliveTin`). The map is built from the process environment when OliveTin starts; values are read at template execution time. If a variable is missing, the template engine will report a missing-key error (with `missingkey=error`), so use defaulting when a variable might be unset, e.g. `{{ or .Env.OPTIONAL_VAR "default" }}`. For template functions such as JSON encoding, see xref:args/templates.adoc#json-encoding[JSON encoding with Json]. `.Env` uses the same Go template context as other action variables (e.g. `.Arguments`, `.CurrentEntity`, `.OliveTin`). The map is built from the process environment when OliveTin starts; values are read at template execution time. If a variable is missing, the template engine will report a missing-key error (with `missingkey=error`), so use defaulting when a variable might be unset, e.g. `{{ or .Env.OPTIONAL_VAR "default" }}`. For template functions such as JSON encoding, see xref:args/templates.adoc#json-encoding[JSON encoding with Json].
`.Env` values are **not** sanitized for shell safety. When you use them in `shell` or `shellAfterCompleted`, OliveTin assumes the process environment is server-controlled and that you accept responsibility for those values. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
This feature addresses the need to use environment variables in templates without changing the config loader (see link:https://github.com/OliveTin/OliveTin/issues/840[GitHub issue #840]). This feature addresses the need to use environment variables in templates without changing the config loader (see link:https://github.com/OliveTin/OliveTin/issues/840[GitHub issue #840]).

View File

@ -67,6 +67,8 @@ showNavigateOnStartIcons: false
image::advanced_configuration/webui/sidebar/sidebar.png[] image::advanced_configuration/webui/sidebar/sidebar.png[]
Root dashboards can optionally set a `category` field so the sidebar groups them under collapsible headings. See xref:dashboards/intro.adoc#categorize-dashboards-in-the-sidebar[Categorize dashboards in the sidebar].
=== Topbar navigation style === Topbar navigation style
`sectionNavigationStyle: topbar` looks like this; `sectionNavigationStyle: topbar` looks like this;

View File

@ -32,6 +32,8 @@ actions:
type: ascii_sentence type: ascii_sentence
---- ----
`description` is optional help text under the field. It is rendered as raw HTML; see xref:args/intro.adoc#arg-descriptions[Argument descriptions].
This will give you a normal button, like this; This will give you a normal button, like this;
image::args/input/args1.png[] image::args/input/args1.png[]

View File

@ -3,6 +3,8 @@
The `checkbox` type argument is a simple checkbox that can be used to toggle a boolean value. It can be especially useful to pass flags to your actions. The `checkbox` type argument is a simple checkbox that can be used to toggle a boolean value. It can be especially useful to pass flags to your actions.
Define `choices` for the checked/unchecked values. A checkbox **without** choices is not allowed with `shell:` (use `exec:` instead).
[source,yaml] [source,yaml]
---- ----
actions: actions:

View File

@ -1,7 +1,7 @@
[#checklist] [#checklist]
= Input: Checklist = Input: Checklist
The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a comma-separated string. The `checklist` type argument renders multiple checkboxes from predefined `choices`. Users can select one or more options, and the selected values are passed to your action as a **JSON array string** (for example `["documents","photos"]`). Legacy comma-separated values are rejected.
[source,yaml] [source,yaml]
---- ----
@ -53,7 +53,7 @@ arguments:
== Choice values == Choice values
Choice `value` fields must not contain commas, because commas are used to join multiple selections together. Choice `value` fields may contain commas; selections are encoded as JSON, not joined with commas.
Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices. Each `title` is shown in the web interface. If a submitted segment matches a choice `title`, OliveTin maps it to the corresponding `value` before validation, matching the behaviour of xref:args/input_checkbox.adoc[checkbox] arguments with choices.
@ -80,4 +80,4 @@ entities:
name: container name: container
---- ----
OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a comma-separated string. OliveTin expands the template once per entity instance and renders each result as a checkbox. Selected values are still passed as a JSON array string.

View File

@ -3,19 +3,24 @@
OliveTin supports multi-line text inputs, which can be useful for longer messages or scripts. You should set your argument `type` to `raw_string_multiline` to use these. OliveTin supports multi-line text inputs, which can be useful for longer messages or scripts. You should set your argument `type` to `raw_string_multiline` to use these.
As the name implies, textareas are raw, and are NOT validated by any regex. As the name implies, textareas are raw, and are NOT validated by any regex. For that reason they **cannot** be used with `shell:` — use `exec:` so the value is a separate argv element.
[source,yaml] [source,yaml]
.`config.yaml` .`config.yaml`
---- ----
actions: actions:
- title: Save text to file - title: Save text to file
shell: echo "$CONTENT" > file exec:
- /bin/sh
- -c
- echo "$CONTENT" > file
arguments: arguments:
- type: raw_string_multiline - type: raw_string_multiline
name: content name: content
---- ----
In that `exec:` example, `$CONTENT` comes from the process environment (OliveTin exports each argument as an uppercase env var), not from shell-string interpolation of the argument into `shell:`.
This renders like this; This renders like this;
image::args/textarea/multiline-text.png[] image::args/textarea/multiline-text.png[]

View File

@ -10,6 +10,24 @@ Examples of valid argument names are `{{ personName }}`, `{{ customer_number }}`
* numbers are allowed (argument names can also start with numbers) * numbers are allowed (argument names can also start with numbers)
* all other characters are invalid for argument names. * all other characters are invalid for argument names.
[#arg-descriptions]
== Argument descriptions
Each argument can include a `description:` shown under the field on the argument form. OliveTin renders that value as **raw HTML**, so you can use markup such as links, line breaks, or emphasis:
[source,yaml]
----
arguments:
- name: host
title: Hostname
type: ascii_identifier
description: |
Enter a host OliveTin can reach.
See the <a href="https://example.com/docs" target="_blank" rel="noopener noreferrer">network guide</a>.
----
Treat `description` as trusted markup that you control (the same trust model as xref:dashboards/4-displays.adoc[dashboard displays]). Do not put untrusted or entity-derived strings into it without careful escaping.
== What's Next? == What's Next?
Now that you understand how arguments work, explore the different argument types and features: Now that you understand how arguments work, explore the different argument types and features:

View File

@ -1,9 +1,9 @@
= Password = Password
Sometimes you want to mask the input you pass, and a password field is useful for this. Sometimes you want to mask the input you pass, and a password field is useful for this.
[WARNING] [WARNING]
Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. Passwords are passed to the OliveTin server in cleartext (unless you're using HTTPS), and are just treated as a string on the server side. Password arguments are **not** type-checked for allowed characters, and they **cannot** be used with `shell:` — use `exec:` so the value is passed as a separate argument instead of being interpolated into a shell string.
[source,yaml] [source,yaml]
.`config.yaml` .`config.yaml`
@ -11,9 +11,10 @@ Passwords are passed to the OliveTin server in cleartext (unless you're using HT
actions: actions:
- title: echo a message - title: echo a message
icon: smile icon: smile
shell: echo {{ my_password }} exec:
- echo
- "{{ my_password }}"
arguments: arguments:
- name: my_password - name: my_password
type: password type: password
---- ----

View File

@ -5,13 +5,17 @@ OliveTin version 2024.02.081 and above support custom regex patterns for argumen
NOTE: The regex pattern should be enclosed in single quotes, otherwise you will probably get a YAML error when starting OliveTin. NOTE: The regex pattern should be enclosed in single quotes, otherwise you will probably get a YAML error when starting OliveTin.
Custom `regex:...` argument types **cannot** be used with `shell:`. Use `exec:` instead (see xref:action_execution/shellvsexec.adoc[Shell vs Exec]).
[source,yaml] [source,yaml]
.`config.yaml` .`config.yaml`
---- ----
actions: actions:
- title: echo a message - title: echo a message
icon: smile icon: smile
shell: echo "{{ message }}" exec:
- echo
- "{{ message }}"
arguments: arguments:
- name: message - name: message
type: 'regex:^\w\w\w$' type: 'regex:^\w\w\w$'
@ -22,4 +26,6 @@ The site http://regex101.com is a good place to test your regex patterns. OliveT
. **Regex in the browser** (which probably uses PCRE or Perl Compatible Regular Expressions) - this is so that the browser can give you a nice validation message. This is ignored when it reaches the server though, or if you are using the API directly. Select "PCRE" on the regex101 site when testing. . **Regex in the browser** (which probably uses PCRE or Perl Compatible Regular Expressions) - this is so that the browser can give you a nice validation message. This is ignored when it reaches the server though, or if you are using the API directly. Select "PCRE" on the regex101 site when testing.
. **Regex on the server** (which uses Golang's regex engine) - this is the one that actually validates the input. Select "Golang" on the regex101 site when testing. . **Regex on the server** (which uses Golang's regex engine) - this is the one that actually validates the input. Select "Golang" on the regex101 site when testing.
On the server, the pattern you provide is anchored to the full string (`^(?:…)$`), so a partial match is not enough.
You cannot specify different regex patterns for the browser and server. The regex pattern you create will need to be compatible with both types of regex engine. You cannot specify different regex patterns for the browser and server. The regex pattern you create will need to be compatible with both types of regex engine.

View File

@ -10,6 +10,8 @@ In OliveTin 3k, use dotted names for template context variables:
* `{{ .Env.VAR_NAME }}` — process environment (see xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements]) * `{{ .Env.VAR_NAME }}` — process environment (see xref:advanced_configuration/config_envs.adoc#using-env-in-template-replacements[Using .Env in template replacements])
* `{{ .OliveTin.Build.Version }}` and related build/runtime fields * `{{ .OliveTin.Build.Version }}` and related build/runtime fields
IMPORTANT: Unlike argument values, `.CurrentEntity` and `.Env` are **not** sanitized for shell safety when used in `shell` or `shellAfterCompleted`. They are treated as server-controlled data; the config author is responsible for that trust. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
In OliveTin 2k, argument and execution-request placeholders used the shorter form (for example, `{{ message }}` instead of `{{ .Arguments.message }}`). In OliveTin 2k, argument and execution-request placeholders used the shorter form (for example, `{{ message }}` instead of `{{ .Arguments.message }}`).
[#json-encoding] [#json-encoding]

View File

@ -7,29 +7,45 @@ A full list of argument types are below;
[%header,cols="1,0,2"] [%header,cols="1,0,2"]
|=== |===
| Type | Rendered as | Allowed values | Type | Rendered as | Allowed values
| (default) | xref:args/input.adoc[Textbox] | If a `type:` is not set, and `choices:` is empty, then ascii will be used, and a warning will be logged. It is recommended that you set the type explicitly, rather than relying on defaults. | (default) | xref:args/input.adoc[Textbox] | If a `type:` is not set, and `choices:` is empty, then `ascii` will be used, and a config warning is reported. It is recommended that you set the type explicitly, rather than relying on defaults.
| ascii | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, but no spaces or punctuation | ascii | xref:args/input.adoc[Textbox] | `a-z`, `A-Z`, `0-9` only. No spaces or punctuation.
| ascii_identifier | xref:args/input.adoc[Textbox] | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`. | ascii_identifier | xref:args/input.adoc[Textbox] | `a-z`, `A-Z`, `0-9`, `-`, `.`, and `_`.
| dnsname | xref:args/input.adoc[Textbox] | A DNS hostname (RFC 1123). Short names (e.g. `webserver`) and FQDNs (e.g. `webserver.example.com`). Letters/digits/hyphens only, no underscores. Optional trailing dot allowed. | dnsname | xref:args/input.adoc[Textbox] | A DNS hostname (RFC 1123). Short names (e.g. `webserver`) and FQDNs (e.g. `webserver.example.com`). Letters/digits/hyphens only, no underscores. Optional trailing dot allowed.
| shell_safe_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers. | shell_safe_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but also allows `@` and `+`. Useful for shell-safe usernames and email-style identifiers.
| ascii_sentence | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, with spaces, `.` and `,`. | ascii_sentence | xref:args/input.adoc[Textbox] | `a-z`, `A-Z`, `0-9`, spaces, `.`, `,`, `-`, and `_`.
| unicode_identifier | xref:args/input.adoc[Textbox] | Like an ascii identifier, but allows unicode characters. This is useful for languages that use non-ascii characters, such as Chinese, Japanese, etc. | unicode_identifier | xref:args/input.adoc[Textbox] | Same character class as Go's `\w` plus `-` and `.` (ASCII letters, digits, and `_`, plus `-` and `.`). Despite the name, non-ASCII letters are **not** accepted by the current server check.
| email | xref:args/input.adoc[Textbox] | An email address. | email | xref:args/input.adoc[Textbox] | An email address (parsed with Go's `mail.ParseAddress`).
| password | xref:args/password.adoc[Password] | A password, which is hidden when typed. | password | xref:args/password.adoc[Password] | Any string (not type-checked). Hidden in the UI. **Not allowed with `shell:`** — use `exec:`.
| very_dangerous_raw_string | xref:args/input.adoc[Textbox] | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely. | very_dangerous_raw_string | xref:args/input.adoc[Textbox] | Anything. This is **incredibly dangerous**, as effectively people can type anything they like, including executing additional commands beyond what you specify. Absolutely should not be used unless your OliveTin instance can only be used by people you trust entirely. **Not allowed with `shell:`** — use `exec:`.
| regex:... | xref:args/input.adoc[Textbox] | Version 2024.03.081 and above support custom regex patterns. See xref:args/regex.adoc[Custom regex arguments]. | regex:... | xref:args/input.adoc[Textbox] | Custom regex patterns. See xref:args/regex.adoc[Custom regex arguments]. **Not allowed with `shell:`** — use `exec:`.
| int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. Negative numbers are not supported. | int | xref:args/input.adoc[Textbox] | Digits `0-9` only. Negative numbers are not supported.
| url | xref:args/input.adoc[Textbox] | A URL (e.g. https://example.com). Accepts any scheme, including `file://` and `ftp://`. See warning below. | url | xref:args/input.adoc[Textbox] | A URL with scheme `http` or `https` only (e.g. `https://example.com`). **Not allowed with `shell:`** — use `exec:`.
| datetime | xref:args/input_datetime.adoc[Date & Time] | A local datetime in the form `YYYY-MM-DDTHH:MM:SS` (seconds may be mangled to `:00` when browsers omit them).
| confirmation | xref:args/input_confirmation.adoc[Confirmation] | A UI gate that requires a checkbox before starting. Usually unnamed (nothing is substituted). If named, the value is only `0` or `1`. | confirmation | xref:args/input_confirmation.adoc[Confirmation] | A UI gate that requires a checkbox before starting. Usually unnamed (nothing is substituted). If named, the value is only `0` or `1`.
| checklist | xref:args/input_checklist.adoc[Checklist] | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string. | checkbox | xref:args/input_checkbox.adoc[Checkbox] | Typically used with `choices` for on/off flag values. A checkbox **without** choices is **not allowed with `shell:`** — use `exec:` or define choices.
| n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching. | checklist | xref:args/input_checklist.adoc[Checklist] | Multiple checkboxes from predefined choices. Selected values are passed as a JSON array string (e.g. `["documents","photos"]`).
| raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like | n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | Predefined choices shown as a dropdown. The submitted value must match one of the choice values (or an entity-expanded choice).
| raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything (not type-checked). **Dangerous**, and **not allowed with `shell:`** — use `exec:`.
|=== |===
[WARNING] [#shell-blocked-arg-types]
.Security risk: URL argument type == Types that cannot be used with `shell:`
====
The `url` argument type does not restrict the URL scheme. Users can enter `file://` (local filesystem) URLs, `ftp://`, or other schemes. If the argument value is passed directly to curl, wget, or similar tools, a malicious or mistaken input could read local files, access internal services, or trigger unwanted network requests.
If your action might be used by untrusted users, validate or filter the URL in your script (e.g. allow only `https://`) before using the value. When an action uses `shell:` (including with arguments substituted into the command string), OliveTin rejects these argument types and asks you to use `exec:` instead:
* `url`
* `email`
* `password`
* `raw_string_multiline`
* `very_dangerous_raw_string`
* `html` (internal/display-oriented; skips normal type checks)
* any custom `regex:...` type
* `checkbox` when it has **no** `choices`
See xref:action_execution/shellvsexec.adoc[Shell vs Exec].
[NOTE]
.URL schemes
====
The `url` type accepts only `http` and `https`. Schemes such as `file://`, `ftp://`, and `gopher://` are rejected by the server.
==== ====

View File

@ -82,6 +82,39 @@ dashboards:
- title: '{{ server.name }} Power Off' - title: '{{ server.name }} Power Off'
---- ----
[#categorize-dashboards-in-the-sidebar]
== Categorize dashboards in the sidebar
When you have several root dashboards, you can group them under collapsible category headings in the sidebar with an optional `category` field on each root dashboard:
[source,yaml]
.`config.yaml`
----
dashboards:
- title: My Servers
category: Infrastructure
contents:
- title: Ping All Servers
- title: My Containers
category: Infrastructure
contents:
- title: Restart nginx
- title: Status Board
category: Monitoring
contents:
- title: Show uptime
- title: Misc Tools
contents:
- title: Hello World
----
Dashboards that share the same `category` value are grouped together. Category order follows the first time each category appears in the config among dashboards the user can see. Dashboards without a `category` appear above the category sections. Nested items under `contents` do not use `category`.
Categories are shown when `sectionNavigationStyle` is `sidebar` (the default). See xref:advanced_configuration/webui.adoc#section-navgiation-style[Section Navigation Style].
== What's Next? == What's Next?
Now that you understand dashboards, explore these related features: Now that you understand dashboards, explore these related features:

View File

@ -11,6 +11,8 @@ Entities are just loaded from files on disk, OliveTin will also watch these file
Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`. Entity data files can contain any fields you need. Those values are available in action templates as `{{ .CurrentEntity.field }}` — for example, `{{ .CurrentEntity.status }}` or `{{ .CurrentEntity.hostname }}`.
Entity field values are **not** sanitized for shell safety. If you substitute them into `shell` or `shellAfterCompleted`, OliveTin assumes the entity files are server-controlled and that you accept responsibility for that data. See xref:action_execution/shellvsexec.adoc#shell-entity-env-trust[Entity and .Env values are not shell-sanitized].
To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details. To control which fields appear in the Entities page table and entity details view, configure `properties` on the entity definition in `config.yaml`. See xref:entities/properties.adoc[Entity properties] for details.
[source,yaml] [source,yaml]

View File

@ -12,13 +12,13 @@ This is the easiest way to run multiple OliveTin instances. Follow the xref:inst
3. When creating the container, pass in the 2nd instance's config, eg; `-v /opt/OliveTin_two/:/config/` 3. When creating the container, pass in the 2nd instance's config, eg; `-v /opt/OliveTin_two/:/config/`
4. When creating the container, set the external port, eg: `2337:1337` - 2337 is the external port) 4. When creating the container, set the external port, eg: `2337:1337` - 2337 is the external port)
You do not need to change the listenAddresses / ports for the other 3 ports that OliveTin uses, when you are running inside a container. You do not need to change the listenAddresses / ports for the other 3 ports that OliveTin uses, when you are running inside a container.
== Without containers - using a package (.tar.gz) == Without containers - using a package (.tar.gz)
If you are not using containers, then it is probably best not to use a `.deb/.rpm` installation, as those packages can only be installed for one instance. If you are not using containers, then it is probably best not to use a `.deb/.rpm` installation, as those packages can only be installed for one instance.
Instead, follow the instructions for xref:install/targz.adoc[installing from a .tar.gz] archive. Instead, follow the instructions for xref:install/targz.adoc[installing from a .tar.gz] archive.
When you come to create the config.yaml file, OliveTin will look for this in it's own startup directory. Therefore it is probably best to extract the .tar.gz file like this and change the paths; When you come to create the config.yaml file, OliveTin will look for this in it's own startup directory. Therefore it is probably best to extract the .tar.gz file like this and change the paths;
@ -28,7 +28,7 @@ When you come to create the config.yaml file, OliveTin will look for this in it'
Because you are running outside of a container, you will also need to change the "internal" ports used by OliveTin so they are separate for all instances. OliveTin listens on 4 addresses (1 external, 3 internal) and needs 4 ports. You can read about these in the xref:reference/network-ports.adoc[network ports documentation]. Because you are running outside of a container, you will also need to change the "internal" ports used by OliveTin so they are separate for all instances. OliveTin listens on 4 addresses (1 external, 3 internal) and needs 4 ports. You can read about these in the xref:reference/network-ports.adoc[network ports documentation].
NOTE: OliveTin also supports reading the PORT environment variable, and will use this as a base port for the simgle frontend, will add 1 to start extra servers. For example of PORT is 2000, then the simgle frontend will start on port 2000, the REST API on 2001, and so on. NOTE: If the `PORT` environment variable is set, OliveTin listens on that port for the single HTTP frontend only (overriding `listenAddressSingleHTTPFrontend` in the config, keeping the host from the config). When `PORT` is unset, the config value is used, or `0.0.0.0:1337` if that setting is omitted. `PORT` does not change the other internal listen addresses — set those explicitly in each instance's config as shown below.
You could end up with a setup that looks like this; You could end up with a setup that looks like this;
@ -40,7 +40,7 @@ You could end up with a setup that looks like this;
| OliveTin_three | `/opt/OliveTin_three` | `/opt/OliveTin_three/config.yaml` | `0.0.0.0:3337` | `localhost:3338` | `localhost:3339` | `localhost:3340` | OliveTin_three | `/opt/OliveTin_three` | `/opt/OliveTin_three/config.yaml` | `0.0.0.0:3337` | `localhost:3338` | `localhost:3339` | `localhost:3340`
|=== |===
Note that you will also need to adjust the default systemd service file to point to your install directory, if using that. Here is an example for `OliveTin_two`; Note that you will also need to adjust the default systemd service file to point to your install directory, if using that. Here is an example for `OliveTin_two`;
.A modified systemd service file for a 2nd instance .A modified systemd service file for a 2nd instance
---- ----
@ -55,5 +55,3 @@ Restart=always
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
---- ----

View File

@ -4,7 +4,7 @@
OliveTin might surprise some people when they see it is listening on several OliveTin might surprise some people when they see it is listening on several
ports when it starts up. Most of these ports are internal and localhost-only by ports when it starts up. Most of these ports are internal and localhost-only by
default. It keeps the architecture of OliveTin clean and simple, and allows for default. It keeps the architecture of OliveTin clean and simple, and allows for
a lot of flexibility if needed. a lot of flexibility if needed.
== Network flow diagram == Network flow diagram
@ -38,18 +38,24 @@ server.
Below is a detailed reference table. Below is a detailed reference table.
== Port Reference Table == Port Reference Table
.Port reference table .Port reference table
[%header,cols="1,2"] [%header,cols="1,2"]
|=== |===
| Config file reference (and Default Address:Port) | Purpose | Config file reference (and Default Address:Port) | Purpose
| `listenAddressSingleHTTPFrontend: 0.0.0.0:1337` (listen on all available addresses) | This is a "micro reverse proxy" built into OliveTin. It's only purpose is to serve /ui and / (the web interface) from a single endpoint. This means that problems like CORSs and setting "external addresses" is not necessary. It does not do any caching or anything else. It can be disabled, but it makes life a lot easier for you. It's common to put your own reverse proxy like haproxy, traefik, etc in front of this single micro reverse proxy. | `listenAddressSingleHTTPFrontend: 0.0.0.0:1337` (listen on all available addresses) | This is a "micro reverse proxy" built into OliveTin. It's only purpose is to serve /ui and / (the web interface) from a single endpoint. This means that problems like CORSs and setting "external addresses" is not necessary. It does not do any caching or anything else. It can be disabled, but it makes life a lot easier for you. It's common to put your own reverse proxy like haproxy, traefik, etc in front of this single micro reverse proxy.
| `listenAddressRestActions: localhost:1338` | REST - the protocol used by web pages to talk to web APIs. In the case of OliveTin, the API is used to get actions, and start actions. | `listenAddressRestActions: localhost:1338` | REST - the protocol used by web pages to talk to web APIs. In the case of OliveTin, the API is used to get actions, and start actions.
| `listenAddressGrpcActions:localhost:1339` | gRPC - a very popular method of service-to-service API communication. This provides the "real" API for OliveTin. | `listenAddressGrpcActions:localhost:1339` | gRPC - a very popular method of service-to-service API communication. This provides the "real" API for OliveTin.
| `listenAddressWebUI: localhost:1340` | Hosts a simple static web server with some HTML, stylesheets, Javascript etc for the web interface. | `listenAddressWebUI: localhost:1340` | Hosts a simple static web server with some HTML, stylesheets, Javascript etc for the web interface.
| `listenAddressPrometheus: localhost:1341` | Hosts a prometheus endpoint, which is disabled by default. See xref:advanced_configuration/prometheus.adoc[Prometheus] to learn more. | `listenAddressPrometheus: localhost:1341` | Hosts a prometheus endpoint, which is disabled by default. See xref:advanced_configuration/prometheus.adoc[Prometheus] to learn more.
|=== |===
== PORT environment variable
If the `PORT` environment variable is set at startup, OliveTin uses it as the listen port for `listenAddressSingleHTTPFrontend` only, keeping the host from the config (default host `0.0.0.0`). This overrides an explicit port in `config.yaml`, which is useful on platforms that assign a port via `PORT` (for example Heroku or Cloud Run). Internal listen addresses (`listenAddressRestActions`, `listenAddressWebUI`, and so on) are not derived from `PORT`; configure those separately when needed.
When `PORT` is not set, OliveTin uses `listenAddressSingleHTTPFrontend` from the config, or `0.0.0.0:1337` if that setting is omitted.
== See also == See also

View File

@ -8,11 +8,11 @@ You can look for themes on the link:http://www.olivetin.app/themes/[OliveTin The
There are 3 ways to install a theme; There are 3 ways to install a theme;
If running inside a Docker container: If running inside a Docker container:
1. Use the `olivetin-get-theme` command to easily Git clone the theme into your `custom-webui/themes/` directory. 1. Use the `olivetin-get-theme` command to easily Git clone the theme into your `custom-webui/themes/` directory.
If running without using containers: If running without using containers:
1. Download the theme .zip and copy it across to your `custom-webui/themes/` directory. 1. Download the theme .zip and copy it across to your `custom-webui/themes/` directory.
2. Git Clone the theme into your `custom-webui/themes/` directory. 2. Git Clone the theme into your `custom-webui/themes/` directory.
@ -22,11 +22,14 @@ If running without using containers:
The default OliveTin configuration comes with an action to get new OliveTin themes. If you deleted it from your configuration, you can add it back in by adding the following to your `config.yaml` file; The default OliveTin configuration comes with an action to get new OliveTin themes. If you deleted it from your configuration, you can add it back in by adding the following to your `config.yaml` file;
[source,bash] [source,yaml]
---- ----
actions: actions:
- title: Get OliveTin Theme - title: Get OliveTin Theme
shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }} exec:
- olivetin-get-theme
- "{{ themeGitRepo }}"
- "{{ themeFolderName }}"
icon: theme icon: theme
arguments: arguments:
- name: themeGitRepo - name: themeGitRepo
@ -93,4 +96,3 @@ body {
Profit. Profit.
Check out xref:reference/reference_themes_for_developers.adoc[Themes for Developers] for more information on how to develop themes. Check out xref:reference/reference_themes_for_developers.adoc[Themes for Developers] for more information on how to develop themes.

View File

@ -7,6 +7,9 @@ codestyle:
npx eslint --fix main.js js/* resources/vue npx eslint --fix main.js js/* resources/vue
npx stylelint style.css npx stylelint style.css
unittests: deps
npm test
clean: clean:
$(call delete-files,dist) $(call delete-files,dist)
@ -18,4 +21,4 @@ build:
dist: deps clean build dist: deps clean build
.PHONY: codestyle .PHONY: codestyle unittests

View File

@ -18,12 +18,12 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"picocrank": "^1.21.1", "picocrank": "^1.21.2",
"standard": "^17.1.2", "standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5", "vite": "^8.1.5",
"vue": "^3.5.40", "vue": "^3.5.40",
"vue-i18n": "^11.4.7", "vue-i18n": "^11.4.8",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },
"devDependencies": { "devDependencies": {
@ -998,14 +998,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@intlify/core-base": { "node_modules/@intlify/core-base": {
"version": "11.4.7", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.4.8.tgz",
"integrity": "sha512-MSB/sBKwEWJTILvQIhg2rnIcwPpLayo3wGwvVA+dJTNeUBD9GoqQgAaSOLdI9iOPDHCm9YoVnLqpfzza98MpkQ==", "integrity": "sha512-A+Q7SKm5oEcy1E/cghqd7n/St4XjTqLhiiyDuieNcMrJcrHlkY5n0jp7Q9dD3txvVHzvsmBVV5M9wD5/s1zfzw==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/devtools-types": "11.4.7", "@intlify/devtools-types": "11.4.8",
"@intlify/message-compiler": "11.4.7", "@intlify/message-compiler": "11.4.8",
"@intlify/shared": "11.4.7" "@intlify/shared": "11.4.8"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@ -1015,13 +1015,13 @@
} }
}, },
"node_modules/@intlify/devtools-types": { "node_modules/@intlify/devtools-types": {
"version": "11.4.7", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/devtools-types/-/devtools-types-11.4.8.tgz",
"integrity": "sha512-GSz+J+hqH+AEpAHIYya6fSufS30OaMnG39HiZX7DmGKi3+aaLvassCfsXENEc4Wr4m68q2YP0QdMdB3D9UeAXg==", "integrity": "sha512-MGpID+rlfzGUbNcnC20bm5NMSBHPrvx0atLTfv9dftn3kjXw1hGKDcIcwrO99tSrZEc2i+hczRL7ks8qXsHPkQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.4.7", "@intlify/core-base": "11.4.8",
"@intlify/shared": "11.4.7" "@intlify/shared": "11.4.8"
}, },
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@ -1031,12 +1031,12 @@
} }
}, },
"node_modules/@intlify/message-compiler": { "node_modules/@intlify/message-compiler": {
"version": "11.4.7", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.4.8.tgz",
"integrity": "sha512-bHxmh7n94N4N1evADeb7XTkc3jTw6Ki5biMFZVSX6Jmk+iehy8/maeH2XUsBI27rtKIK+Hzc6QnVAKggUwylKw==", "integrity": "sha512-vbzk17dYwduYiv52EK61+FDCyhfVg1uPUtPmiD/d45W99uJIcXywrweOBcHv7n9/iEqmXiMGT52bgJbZDQqK3w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/shared": "11.4.7", "@intlify/shared": "11.4.8",
"source-map-js": "^1.0.2" "source-map-js": "^1.0.2"
}, },
"engines": { "engines": {
@ -1047,9 +1047,9 @@
} }
}, },
"node_modules/@intlify/shared": { "node_modules/@intlify/shared": {
"version": "11.4.7", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.7.tgz", "resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.4.8.tgz",
"integrity": "sha512-OtjPZan3No2OZZFnMUiCVsXC6+j+XRwEywaFDk0AoayAbLuPesyDloXhJZLl9JUl5vHZeQUkYSbEA8VX+CWMjg==", "integrity": "sha512-XbRgrv+XEuvDr7UCY55oibVrh+o4u+A0VB6nSL0F5Z8LcZxE/8j573LYG6bCrOigIcHdGpSNI7Rh5UpC5/B/eg==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 22" "node": ">= 22"
@ -5272,9 +5272,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picocrank": { "node_modules/picocrank": {
"version": "1.21.1", "version": "1.21.2",
"resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.1.tgz", "resolved": "https://registry.npmjs.org/picocrank/-/picocrank-1.21.2.tgz",
"integrity": "sha512-VnfNFok7BkpLKymAIbD2zUA0bf/AiNBS63Xk2mfi8uXOkdAmf06pQaI0ssGBv2/S9p1jbCATWj9OFz1uqa39NQ==", "integrity": "sha512-bUw6789jyYCTa9waINvYRHmnfgtoMquUidkJx2yhrDCMGbu2O65Qilkl++57yl5JnFeoZOhiPuQTKMPxMQku4A==",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@hugeicons/core-free-icons": "^4.2.2", "@hugeicons/core-free-icons": "^4.2.2",
@ -7118,14 +7118,14 @@
} }
}, },
"node_modules/vue-i18n": { "node_modules/vue-i18n": {
"version": "11.4.7", "version": "11.4.8",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.7.tgz", "resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.4.8.tgz",
"integrity": "sha512-j6RyshdPPzqLiMAUpnpvZGFPM+rRoWi14Sl5yTsquvoW0/56DWyvhAj2o9TO2YXGvb6teg8T0xrYO9jR3urvdw==", "integrity": "sha512-0ULeHP6Z9CGvAm67S77ZEp41cfGXIREGL8qfhos2BMgcQQewtQcDKuojt6jjasAD/S8GwfTp2ySPmDSpwvrCMQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@intlify/core-base": "11.4.7", "@intlify/core-base": "11.4.8",
"@intlify/devtools-types": "11.4.7", "@intlify/devtools-types": "11.4.8",
"@intlify/shared": "11.4.7", "@intlify/shared": "11.4.8",
"@vue/devtools-api": "^6.5.0" "@vue/devtools-api": "^6.5.0"
}, },
"engines": { "engines": {

View File

@ -32,12 +32,12 @@
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"iconify-icon": "^3.0.2", "iconify-icon": "^3.0.2",
"picocrank": "^1.21.1", "picocrank": "^1.21.2",
"standard": "^17.1.2", "standard": "^17.1.2",
"unplugin-vue-components": "^32.1.0", "unplugin-vue-components": "^32.1.0",
"vite": "^8.1.5", "vite": "^8.1.5",
"vue": "^3.5.40", "vue": "^3.5.40",
"vue-i18n": "^11.4.7", "vue-i18n": "^11.4.8",
"vue-router": "^5.2.0" "vue-router": "^5.2.0"
}, },
"engines": { "engines": {

View File

@ -1890,6 +1890,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
* @generated from field: int32 config_issue_count = 26; * @generated from field: int32 config_issue_count = 26;
*/ */
configIssueCount: number; configIssueCount: number;
/**
* @generated from field: repeated olivetin.api.v1.RootDashboard root_dashboard_entries = 27;
*/
rootDashboardEntries: RootDashboard[];
}; };
/** /**
@ -1898,6 +1903,27 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
*/ */
export declare const InitResponseSchema: GenMessage<InitResponse>; export declare const InitResponseSchema: GenMessage<InitResponse>;
/**
* @generated from message olivetin.api.v1.RootDashboard
*/
export declare type RootDashboard = Message<"olivetin.api.v1.RootDashboard"> & {
/**
* @generated from field: string title = 1;
*/
title: string;
/**
* @generated from field: string category = 2;
*/
category: string;
};
/**
* Describes the message olivetin.api.v1.RootDashboard.
* Use `create(RootDashboardSchema)` to create a new message.
*/
export declare const RootDashboardSchema: GenMessage<RootDashboard>;
/** /**
* @generated from message olivetin.api.v1.AdditionalLink * @generated from message olivetin.api.v1.AdditionalLink
*/ */

File diff suppressed because one or more lines are too long

View File

@ -338,40 +338,110 @@ function updateHeaderFromInit () {
connectEventStreamIfNeeded() connectEventStreamIfNeeded()
} }
function getRootDashboardEntries () {
const entries = window.initResponse?.rootDashboardEntries
if (entries && entries.length > 0) {
return entries.map((entry) => ({
title: entry.title,
category: entry.category || ''
}))
}
return (window.initResponse?.rootDashboards || []).map((title) => ({
title,
category: ''
}))
}
function addDashboardNavLink (title) {
navigation.value.addNavigationLink({
id: title,
name: title,
title,
path: title === 'Actions' ? '/' : `/dashboards/${title}`,
icon: DashboardSquare01Icon
})
}
function addCategorizedDashboardLinks (entries) {
const categoryOrder = []
const byCategory = new Map()
for (const entry of entries) {
const category = entry.category.trim()
if (!byCategory.has(category)) {
byCategory.set(category, [])
categoryOrder.push(category)
}
byCategory.get(category).push(entry.title)
}
for (const category of categoryOrder) {
navigation.value.addSection(category)
for (const title of byCategory.get(category)) {
addDashboardNavLink(title)
}
}
}
function renderNavigation () { function renderNavigation () {
if (!navigation.value) { if (!navigation.value) {
return return
} }
const rootDashboards = window.initResponse?.rootDashboards || []
if (typeof navigation.value.clearNavigationLinks === 'function') { if (typeof navigation.value.clearNavigationLinks === 'function') {
navigation.value.clearNavigationLinks() navigation.value.clearNavigationLinks()
} }
for (const rootDashboard of rootDashboards) { const entries = getRootDashboardEntries()
navigation.value.addNavigationLink({ const uncategorized = entries.filter((entry) => !entry.category.trim())
id: rootDashboard, const categorized = entries.filter((entry) => entry.category.trim())
name: rootDashboard,
title: rootDashboard, for (const entry of uncategorized) {
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`, addDashboardNavLink(entry.title)
icon: DashboardSquare01Icon
})
} }
navigation.value.addSeparator() addCategorizedDashboardLinks(categorized)
navigation.value.addRouterLink('Entities', t('nav.entities')) addSystemNavLinks()
}
function addSystemNavLinks () {
const systemLinks = []
systemLinks.push({
routeName: 'Entities',
title: t('nav.entities')
})
if (showLogs.value) { if (showLogs.value) {
navigation.value.addRouterLink('Logs', t('nav.logs')) systemLinks.push({
routeName: 'Logs',
title: t('nav.logs')
})
} }
if (showDiagnostics.value) { if (showDiagnostics.value) {
const issueCount = window.initResponse?.configIssueCount || 0 const issueCount = window.initResponse?.configIssueCount || 0
navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'), { systemLinks.push({
count: issueCount routeName: 'Diagnostics',
title: t('nav.diagnostics'),
options: { count: issueCount }
}) })
} }
if (systemLinks.length === 0) {
return
}
navigation.value.addSection(t('nav.system'))
for (const link of systemLinks) {
navigation.value.addRouterLink(link.routeName, link.title, link.options || {})
}
} }
function openLanguageDialog () { function openLanguageDialog () {

View File

@ -7,10 +7,12 @@
height="1em" height="1em"
class="action-icon-glyph-svg" class="action-icon-glyph-svg"
/> />
<!-- eslint-disable vue/no-v-html -- intentional: action icons may use HTML / custom-webui <img> -->
<span <span
v-else-if="decodedTextGlyphIsHtml" v-else-if="decodedTextGlyphIsHtml"
v-html="decodedTextGlyph" v-html="decodedTextGlyph"
/> />
<!-- eslint-enable vue/no-v-html -->
<span <span
v-else v-else
v-text="decodedTextGlyph" v-text="decodedTextGlyph"

View File

@ -4,10 +4,12 @@
:class="component.cssClass" :class="component.cssClass"
@click="navigateToDirectory" @click="navigateToDirectory"
> >
<!-- eslint-disable vue/no-v-html -- intentional: directory icons from config (HTML entities / markup) -->
<span <span
class="icon" class="icon"
v-html="unicodeIcon" v-html="unicodeIcon"
/> />
<!-- eslint-enable vue/no-v-html -->
<span class="title">{{ component.title }}</span> <span class="title">{{ component.title }}</span>
</button> </button>
</template> </template>

View File

@ -3,7 +3,9 @@
class="display" class="display"
:class="component.cssClass" :class="component.cssClass"
> >
<!-- eslint-disable vue/no-v-html -- intentional: type display titles are trusted config HTML (hackable dashboards) -->
<div v-html="component.title" /> <div v-html="component.title" />
<!-- eslint-enable vue/no-v-html -->
</div> </div>
</template> </template>

View File

@ -0,0 +1,21 @@
export const LOGS_FILTER_STORAGE_KEY = 'olivetin-logs-filter'
export function loadStoredLogsFilter () {
try {
return sessionStorage.getItem(LOGS_FILTER_STORAGE_KEY) || ''
} catch {
return ''
}
}
export function storeLogsFilter (value) {
try {
if (value) {
sessionStorage.setItem(LOGS_FILTER_STORAGE_KEY, value)
} else {
sessionStorage.removeItem(LOGS_FILTER_STORAGE_KEY)
}
} catch {
// Ignore storage failures (private mode, quota, etc.)
}
}

View File

@ -4,17 +4,12 @@
<h2> <h2>
<span class="section-title-with-icon"> <span class="section-title-with-icon">
Start action: Start action:
<router-link <ActionIconGlyph
:to="`/action/${bindingId}`" v-if="icon"
class="action-details-title-link" class="action-title-icon"
> :glyph="icon"
<ActionIconGlyph />
v-if="icon" {{ title }}
class="action-title-icon"
:glyph="icon"
/>
{{ title }}
</router-link>
</span> </span>
</h2> </h2>
</div> </div>
@ -94,10 +89,12 @@
@change="handleChange(arg, $event)" @change="handleChange(arg, $event)"
/> />
<!-- eslint-disable vue/no-v-html -- intentional: argument description is documented as raw HTML -->
<span <span
class="argument-description" class="argument-description"
v-html="arg.description" v-html="arg.description"
/> />
<!-- eslint-enable vue/no-v-html -->
</template> </template>
</template> </template>
@ -684,18 +681,6 @@ onUnmounted(() => {
font-size: 1.5rem; font-size: 1.5rem;
} }
.action-details-title-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: var(--link-color, #0066cc);
text-decoration: underline;
}
.action-details-title-link:hover {
color: var(--link-hover-color, #004499);
}
form { form {
grid-template-columns: max-content auto auto; grid-template-columns: max-content auto auto;
} }

View File

@ -125,11 +125,11 @@ const browserInfoCopied = ref(false)
const configIssueHeaders = computed(() => [ const configIssueHeaders = computed(() => [
{ key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' }, { key: 'severity', label: t('diagnostics.config-issue-severity'), sortable: true, width: '7rem' },
{ key: 'configFile', label: t('diagnostics.config-issue-config-file'), sortable: true, width: '14rem' },
{ key: 'code', label: t('diagnostics.config-issue-code'), sortable: true, width: '12rem' }, { key: 'code', label: t('diagnostics.config-issue-code'), sortable: true, width: '12rem' },
{ key: 'message', label: t('diagnostics.config-issue-message'), sortable: false }, { key: 'message', label: t('diagnostics.config-issue-message'), sortable: false },
{ key: 'actionTitle', label: t('diagnostics.config-issue-action'), sortable: true, width: '10rem' }, { key: 'actionTitle', label: t('diagnostics.config-issue-action'), sortable: true, width: '10rem' },
{ key: 'argumentName', label: t('diagnostics.config-issue-argument'), sortable: true, width: '8rem' }, { key: 'argumentName', label: t('diagnostics.config-issue-argument'), sortable: true, width: '8rem' },
{ key: 'configFile', label: t('diagnostics.config-issue-config-file'), sortable: true, width: '14rem' },
{ key: 'source', label: t('diagnostics.config-issue-source'), sortable: false, width: '12rem' } { key: 'source', label: t('diagnostics.config-issue-source'), sortable: false, width: '12rem' }
]) ])

View File

@ -58,7 +58,7 @@
> >
<dl class="fg1"> <dl class="fg1">
<dt>Duration</dt> <dt>Duration</dt>
<dd><span v-html="duration" /></dd> <dd>{{ duration }}</dd>
<dt>Status</dt> <dt>Status</dt>
<dd class="execution-dialog-status"> <dd class="execution-dialog-status">
@ -373,7 +373,7 @@ function updateDuration (logEntryParam) {
} catch (e) { } catch (e) {
console.warn('Failed to calculate delta', e) console.warn('Failed to calculate delta', e)
} }
duration.value = logEntry.value.datetimeStarted + ' &rarr; ' + logEntry.value.datetimeFinished duration.value = logEntry.value.datetimeStarted + ' → ' + logEntry.value.datetimeFinished
if (delta !== '') { if (delta !== '') {
duration.value += ' (' + delta + ')' duration.value += ' (' + delta + ')'
} }

View File

@ -5,7 +5,7 @@
> >
<template #toolbar> <template #toolbar>
<router-link <router-link
to="/logs" :to="logsListLocation"
class="button neutral" class="button neutral"
> >
<svg <svg
@ -44,6 +44,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import Calendar from 'picocrank/vue/components/Calendar.vue' import Calendar from 'picocrank/vue/components/Calendar.vue'
import Section from 'picocrank/vue/components/Section.vue' import Section from 'picocrank/vue/components/Section.vue'
import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
const router = useRouter() const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
@ -54,6 +55,11 @@ const error = ref(null)
const currentMonthIndex = ref(new Date().getMonth()) const currentMonthIndex = ref(new Date().getMonth())
const currentYear = ref(new Date().getFullYear()) const currentYear = ref(new Date().getFullYear())
const logsListLocation = computed(() => {
const filter = loadStoredLogsFilter()
return filter ? { path: '/logs', query: { filter } } : '/logs'
})
// Convert logs to calendar events format // Convert logs to calendar events format
const calendarEvents = computed(() => { const calendarEvents = computed(() => {
return logs.value return logs.value
@ -147,7 +153,13 @@ function handleDayClick (date) {
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0') const day = String(date.getDate()).padStart(2, '0')
const dateString = `${year}-${month}-${day}` const dateString = `${year}-${month}-${day}`
router.push({ path: '/logs', query: { date: dateString } }) const query = { date: dateString }
const storedFilter = loadStoredLogsFilter()
if (storedFilter) {
query.filter = storedFilter
}
router.push({ path: '/logs', query })
} }
function handleMonthChange (month, year) { function handleMonthChange (month, year) {

View File

@ -221,12 +221,24 @@ import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue' import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import LogActionTitle from '../components/LogActionTitle.vue' import LogActionTitle from '../components/LogActionTitle.vue'
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js' import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
function readInitialFilter () {
// Prefer ?filter= when present (e.g. calendar keeps it while adding ?date=).
// Otherwise restore from sessionStorage so sidebar / breadcrumb returns keep the filter.
if (typeof route.query.filter === 'string' && route.query.filter !== '') {
storeLogsFilter(route.query.filter)
return route.query.filter
}
return loadStoredLogsFilter()
}
const logs = ref([]) const logs = ref([])
const searchText = ref('') const searchText = ref(readInitialFilter())
const pageSize = ref(10) const pageSize = ref(10)
const currentPage = ref(1) const currentPage = ref(1)
const loading = ref(false) const loading = ref(false)
@ -260,11 +272,37 @@ watch(() => route.query.date, () => {
updateDateFromRoute() updateDateFromRoute()
}) })
watch(searchText, () => { watch(searchText, (value) => {
currentPage.value = 1 currentPage.value = 1
storeLogsFilter(value)
syncFilterToRoute(value)
scheduleFetchLogs() scheduleFetchLogs()
}) })
watch(() => route.query.filter, (filter) => {
const next = typeof filter === 'string' ? filter : ''
if (searchText.value === next) {
return
}
searchText.value = next
})
function syncFilterToRoute (value) {
const next = value || ''
const current = typeof route.query.filter === 'string' ? route.query.filter : ''
if (next === current) {
return
}
const query = { ...route.query }
if (next) {
query.filter = next
} else {
delete query.filter
}
router.replace({ path: route.path, query })
}
async function fetchLogs () { async function fetchLogs () {
loading.value = true loading.value = true
filterError.value = '' filterError.value = ''

View File

@ -167,7 +167,10 @@ export async function openSidebar() {
} }
export async function getNavigationLinks() { export async function getNavigationLinks() {
const navigationLinks = await webdriver.findElements(By.css('.navigation-links li')) // Exclude section headers (e.g. "System"); they are li.nav-section-header-item.
const navigationLinks = await webdriver.findElements(
By.css('.navigation-links li:not(.nav-section-header-item)')
)
return navigationLinks return navigationLinks
} }

View File

@ -82,6 +82,7 @@
"nav.diagnostics": "Diagnostik", "nav.diagnostics": "Diagnostik",
"nav.entities": "Entitäten", "nav.entities": "Entitäten",
"nav.logs": "Protokolle", "nav.logs": "Protokolle",
"nav.system": "System",
"raise-issue": "Ein Problem melden auf GitHub", "raise-issue": "Ein Problem melden auf GitHub",
"reconnecting": "Verbinde erneut…", "reconnecting": "Verbinde erneut…",
"return-to-index": "Zurück zur Startseite", "return-to-index": "Zurück zur Startseite",
@ -172,6 +173,7 @@
"nav.diagnostics": "Diagnostics", "nav.diagnostics": "Diagnostics",
"nav.entities": "Entities", "nav.entities": "Entities",
"nav.logs": "Logs", "nav.logs": "Logs",
"nav.system": "System",
"raise-issue": "Raise an issue on GitHub", "raise-issue": "Raise an issue on GitHub",
"reconnecting": "Reconnecting…", "reconnecting": "Reconnecting…",
"return-to-index": "Return to index", "return-to-index": "Return to index",
@ -262,6 +264,7 @@
"nav.diagnostics": "Diagnósticos", "nav.diagnostics": "Diagnósticos",
"nav.entities": "Entidades", "nav.entities": "Entidades",
"nav.logs": "Registros", "nav.logs": "Registros",
"nav.system": "Sistema",
"raise-issue": "Reportar un problema en GitHub", "raise-issue": "Reportar un problema en GitHub",
"reconnecting": "Reconectando…", "reconnecting": "Reconectando…",
"return-to-index": "Volver a la página principal", "return-to-index": "Volver a la página principal",
@ -352,6 +355,7 @@
"nav.diagnostics": "Diagnostica", "nav.diagnostics": "Diagnostica",
"nav.entities": "Entità", "nav.entities": "Entità",
"nav.logs": "Registri", "nav.logs": "Registri",
"nav.system": "Sistema",
"raise-issue": "Segnala un problema su GitHub", "raise-issue": "Segnala un problema su GitHub",
"reconnecting": "Riconnessione…", "reconnecting": "Riconnessione…",
"return-to-index": "Torna alla pagina principale", "return-to-index": "Torna alla pagina principale",
@ -442,6 +446,7 @@
"nav.diagnostics": "诊断", "nav.diagnostics": "诊断",
"nav.entities": "实体", "nav.entities": "实体",
"nav.logs": "日志", "nav.logs": "日志",
"nav.system": "系统",
"raise-issue": "在 GitHub 上报告问题", "raise-issue": "在 GitHub 上报告问题",
"reconnecting": "正在重新连接…", "reconnecting": "正在重新连接…",
"return-to-index": "返回首页", "return-to-index": "返回首页",
@ -532,6 +537,7 @@
"nav.diagnostics": "診斷", "nav.diagnostics": "診斷",
"nav.entities": "實體", "nav.entities": "實體",
"nav.logs": "日誌", "nav.logs": "日誌",
"nav.system": "系統",
"raise-issue": "在 GitHub 上建立 Issue", "raise-issue": "在 GitHub 上建立 Issue",
"reconnecting": "重新連線中…", "reconnecting": "重新連線中…",
"return-to-index": "返回首頁", "return-to-index": "返回首頁",

View File

@ -5,6 +5,7 @@ translations:
nav.logs: Protokolle nav.logs: Protokolle
nav.entities: Entitäten nav.entities: Entitäten
nav.diagnostics: Diagnostik nav.diagnostics: Diagnostik
nav.system: System
connected: Verbunden connected: Verbunden
disconnected: Getrennt disconnected: Getrennt
reconnecting: Verbinde erneut… reconnecting: Verbinde erneut…

View File

@ -7,6 +7,7 @@ translations:
nav.logs: Logs nav.logs: Logs
nav.entities: Entities nav.entities: Entities
nav.diagnostics: Diagnostics nav.diagnostics: Diagnostics
nav.system: System
connected: Connected connected: Connected
disconnected: Disconnected disconnected: Disconnected
reconnecting: Reconnecting… reconnecting: Reconnecting…

View File

@ -5,6 +5,7 @@ translations:
nav.logs: Registros nav.logs: Registros
nav.entities: Entidades nav.entities: Entidades
nav.diagnostics: Diagnósticos nav.diagnostics: Diagnósticos
nav.system: Sistema
connected: Conectado connected: Conectado
disconnected: Desconectado disconnected: Desconectado
reconnecting: Reconectando… reconnecting: Reconectando…

View File

@ -5,6 +5,7 @@ translations:
nav.logs: Registri nav.logs: Registri
nav.entities: Entità nav.entities: Entità
nav.diagnostics: Diagnostica nav.diagnostics: Diagnostica
nav.system: Sistema
docs: Documentazione docs: Documentazione
connected: Connesso connected: Connesso
disconnected: Disconnesso disconnected: Disconnesso

View File

@ -5,6 +5,7 @@ translations:
nav.logs: 日志 nav.logs: 日志
nav.entities: 实体 nav.entities: 实体
nav.diagnostics: 诊断 nav.diagnostics: 诊断
nav.system: 系统
connected: 已连接 connected: 已连接
disconnected: 已断开连接 disconnected: 已断开连接
reconnecting: 正在重新连接… reconnecting: 正在重新连接…

View File

@ -7,6 +7,7 @@ translations:
nav.logs: 日誌 nav.logs: 日誌
nav.entities: 實體 nav.entities: 實體
nav.diagnostics: 診斷 nav.diagnostics: 診斷
nav.system: 系統
connected: 已連線 connected: 已連線
disconnected: 已斷線 disconnected: 已斷線
reconnecting: 重新連線中… reconnecting: 重新連線中…

View File

@ -425,6 +425,12 @@ message InitResponse {
repeated string available_themes = 24; // List of available theme names repeated string available_themes = 24; // List of available theme names
bool show_navigate_on_start_icons = 25; bool show_navigate_on_start_icons = 25;
int32 config_issue_count = 26; int32 config_issue_count = 26;
repeated RootDashboard root_dashboard_entries = 27;
}
message RootDashboard {
string title = 1;
string category = 2;
} }
message AdditionalLink { message AdditionalLink {

View File

@ -7,17 +7,138 @@ run:
linters: linters:
default: none default: none
enable: enable:
- bidichk
- bodyclose
- copyloopvar
- durationcheck
- errcheck - errcheck
- errorlint
- gocritic - gocritic
- gocyclo - gocyclo
- gosec
- govet
- ineffassign - ineffassign
- misspell - misspell
# - modernize
- nilerr
- noctx
# - promlinter
- staticcheck - staticcheck
# - testifylint
- thelper
- unconvert - unconvert
# - unparam
- unused - unused
- usestdlibvars
settings: settings:
gocyclo: gocyclo:
min-complexity: 5 min-complexity: 5
gosec:
# Full gosec rule set (G101–G6xx), including Slowloris checks G112/G114.
enable-all-rules: true
govet:
enable-all: true
exclusions: exclusions:
paths: paths:
- gen - gen
rules:
# Noise / fixtures in tests and local tooling.
- path: _test\.go
linters:
- gosec
- path: _test\.go
text: "fieldalignment:"
linters:
- govet
- path: scripts/
linters:
- gosec
# Local config-tool CLI: operator-supplied path and 0644 config backups.
- path: cmd/config-tool/main\.go
text: "G304:"
linters:
- gosec
- path: cmd/config-tool/main\.go
text: "G306:"
linters:
- gosec
- path: cmd/config-tool/main\.go
text: "G703:"
linters:
- gosec
# OliveTin's purpose is controlled command execution from config.
- path: internal/executor/
text: "G204:"
linters:
- gosec
# Operator-configured filesystem paths (entity files, touch/write helpers, persisted logs).
- path: internal/entities/
text: "G304:"
linters:
- gosec
- path: internal/filehelper/
text: "G304:"
linters:
- gosec
- path: internal/configcheck/
text: "G304:"
linters:
- gosec
- path: internal/executor/
text: "G304:"
linters:
- gosec
- path: internal/auth/otjwt/
text: "G304:"
linters:
- gosec
- path: internal/httpservers/
text: "G304:"
linters:
- gosec
# Legacy GitHub webhook HMAC-SHA1 is still a supported authType.
- path: internal/webhooks/auth\.go
text: "G505:"
linters:
- gosec
# InsecureSkipVerify is an explicit OAuth2 provider config option.
- path: internal/auth/otoauth2/
text: "G402:"
linters:
- gosec
# Secure is set dynamically from TLS / ForceSecureCookies; gosec wants a literal true.
- path: internal/api/api\.go
text: "G124:"
linters:
- gosec
- path: internal/auth/otoauth2/
text: "G124:"
linters:
- gosec
# Protobuf / process exit codes and collection sizes mapped into int32 fields.
- path: internal/api/apiActions\.go
text: "G115:"
linters:
- gosec
- path: internal/api/api_entities_list\.go
text: "G115:"
linters:
- gosec
- path: internal/api/api_queue\.go
text: "G115:"
linters:
- gosec
- path: internal/api/config_issues\.go
text: "G115:"
linters:
- gosec
- path: internal/executor/executor\.go
text: "G115:"
linters:
- gosec

View File

@ -3,6 +3,7 @@ package main
import ( import (
"flag" "flag"
"fmt" "fmt"
"maps"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
@ -98,18 +99,18 @@ func userDisplayName(username string, index int) string {
return username return username
} }
func copyUserMapWithPassword(userMap map[string]interface{}, hashedPassword string) map[string]interface{} { func copyUserMapWithPassword(userMap map[string]any, hashedPassword string) map[string]any {
newUserMap := make(map[string]interface{}, len(userMap)+1) newUserMap := make(map[string]any, len(userMap)+1)
for key, value := range userMap {
newUserMap[key] = value maps.Copy(newUserMap, userMap)
}
newUserMap["password"] = hashedPassword newUserMap["password"] = hashedPassword
return newUserMap return newUserMap
} }
func resetPasswordInUserMap(userValue interface{}, index int, hashedPassword string) interface{} { func resetPasswordInUserMap(userValue any, index int, hashedPassword string) any {
userMap, ok := userValue.(map[string]interface{}) userMap, ok := userValue.(map[string]any)
if !ok { if !ok {
log.Warnf("User entry at index %d is not a map, skipping", index) log.Warnf("User entry at index %d is not a map, skipping", index)
return userValue return userValue
@ -122,8 +123,8 @@ func resetPasswordInUserMap(userValue interface{}, index int, hashedPassword str
return copyUserMapWithPassword(userMap, hashedPassword) return copyUserMapWithPassword(userMap, hashedPassword)
} }
func resetPasswordsFromSlice(k *koanf.Koanf, usersSliceTyped []interface{}, hashedPassword string) { func resetPasswordsFromSlice(k *koanf.Koanf, usersSliceTyped []any, hashedPassword string) {
newUsersSlice := make([]interface{}, len(usersSliceTyped)) newUsersSlice := make([]any, len(usersSliceTyped))
for index, userValue := range usersSliceTyped { for index, userValue := range usersSliceTyped {
newUsersSlice[index] = resetPasswordInUserMap(userValue, index, hashedPassword) newUsersSlice[index] = resetPasswordInUserMap(userValue, index, hashedPassword)
} }
@ -155,7 +156,7 @@ func hasLocalUsers(cfg *config.Config) bool {
} }
func applyPasswordResets(k *koanf.Koanf, cfg *config.Config, hashedPassword string) { func applyPasswordResets(k *koanf.Koanf, cfg *config.Config, hashedPassword string) {
usersSliceTyped, ok := k.Get("authLocalUsers.users").([]interface{}) usersSliceTyped, ok := k.Get("authLocalUsers.users").([]any)
if ok && len(usersSliceTyped) > 0 { if ok && len(usersSliceTyped) > 0 {
resetPasswordsFromSlice(k, usersSliceTyped, hashedPassword) resetPasswordsFromSlice(k, usersSliceTyped, hashedPassword)
return return

View File

@ -4095,6 +4095,7 @@ type InitResponse struct {
AvailableThemes []string `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names AvailableThemes []string `protobuf:"bytes,24,rep,name=available_themes,json=availableThemes,proto3" json:"available_themes,omitempty"` // List of available theme names
ShowNavigateOnStartIcons bool `protobuf:"varint,25,opt,name=show_navigate_on_start_icons,json=showNavigateOnStartIcons,proto3" json:"show_navigate_on_start_icons,omitempty"` ShowNavigateOnStartIcons bool `protobuf:"varint,25,opt,name=show_navigate_on_start_icons,json=showNavigateOnStartIcons,proto3" json:"show_navigate_on_start_icons,omitempty"`
ConfigIssueCount int32 `protobuf:"varint,26,opt,name=config_issue_count,json=configIssueCount,proto3" json:"config_issue_count,omitempty"` ConfigIssueCount int32 `protobuf:"varint,26,opt,name=config_issue_count,json=configIssueCount,proto3" json:"config_issue_count,omitempty"`
RootDashboardEntries []*RootDashboard `protobuf:"bytes,27,rep,name=root_dashboard_entries,json=rootDashboardEntries,proto3" json:"root_dashboard_entries,omitempty"`
unknownFields protoimpl.UnknownFields unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache sizeCache protoimpl.SizeCache
} }
@ -4311,6 +4312,65 @@ func (x *InitResponse) GetConfigIssueCount() int32 {
return 0 return 0
} }
func (x *InitResponse) GetRootDashboardEntries() []*RootDashboard {
if x != nil {
return x.RootDashboardEntries
}
return nil
}
type RootDashboard struct {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
Category string `protobuf:"bytes,2,opt,name=category,proto3" json:"category,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *RootDashboard) Reset() {
*x = RootDashboard{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *RootDashboard) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*RootDashboard) ProtoMessage() {}
func (x *RootDashboard) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use RootDashboard.ProtoReflect.Descriptor instead.
func (*RootDashboard) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69}
}
func (x *RootDashboard) GetTitle() string {
if x != nil {
return x.Title
}
return ""
}
func (x *RootDashboard) GetCategory() string {
if x != nil {
return x.Category
}
return ""
}
type AdditionalLink struct { type AdditionalLink struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"` Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@ -4321,7 +4381,7 @@ type AdditionalLink struct {
func (x *AdditionalLink) Reset() { func (x *AdditionalLink) Reset() {
*x = AdditionalLink{} *x = AdditionalLink{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4333,7 +4393,7 @@ func (x *AdditionalLink) String() string {
func (*AdditionalLink) ProtoMessage() {} func (*AdditionalLink) ProtoMessage() {}
func (x *AdditionalLink) ProtoReflect() protoreflect.Message { func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[69] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4346,7 +4406,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
// Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead. // Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
func (*AdditionalLink) Descriptor() ([]byte, []int) { func (*AdditionalLink) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{69} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70}
} }
func (x *AdditionalLink) GetTitle() string { func (x *AdditionalLink) GetTitle() string {
@ -4374,7 +4434,7 @@ type OAuth2Provider struct {
func (x *OAuth2Provider) Reset() { func (x *OAuth2Provider) Reset() {
*x = OAuth2Provider{} *x = OAuth2Provider{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4386,7 +4446,7 @@ func (x *OAuth2Provider) String() string {
func (*OAuth2Provider) ProtoMessage() {} func (*OAuth2Provider) ProtoMessage() {}
func (x *OAuth2Provider) ProtoReflect() protoreflect.Message { func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[70] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4399,7 +4459,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
// Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead. // Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
func (*OAuth2Provider) Descriptor() ([]byte, []int) { func (*OAuth2Provider) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{70} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71}
} }
func (x *OAuth2Provider) GetTitle() string { func (x *OAuth2Provider) GetTitle() string {
@ -4432,7 +4492,7 @@ type GetActionBindingRequest struct {
func (x *GetActionBindingRequest) Reset() { func (x *GetActionBindingRequest) Reset() {
*x = GetActionBindingRequest{} *x = GetActionBindingRequest{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4444,7 +4504,7 @@ func (x *GetActionBindingRequest) String() string {
func (*GetActionBindingRequest) ProtoMessage() {} func (*GetActionBindingRequest) ProtoMessage() {}
func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message { func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[71] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4457,7 +4517,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead. // Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
func (*GetActionBindingRequest) Descriptor() ([]byte, []int) { func (*GetActionBindingRequest) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{71} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72}
} }
func (x *GetActionBindingRequest) GetBindingId() string { func (x *GetActionBindingRequest) GetBindingId() string {
@ -4477,7 +4537,7 @@ type GetActionBindingResponse struct {
func (x *GetActionBindingResponse) Reset() { func (x *GetActionBindingResponse) Reset() {
*x = GetActionBindingResponse{} *x = GetActionBindingResponse{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4489,7 +4549,7 @@ func (x *GetActionBindingResponse) String() string {
func (*GetActionBindingResponse) ProtoMessage() {} func (*GetActionBindingResponse) ProtoMessage() {}
func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message { func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[72] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4502,7 +4562,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead. // Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
func (*GetActionBindingResponse) Descriptor() ([]byte, []int) { func (*GetActionBindingResponse) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{72} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73}
} }
func (x *GetActionBindingResponse) GetAction() *Action { func (x *GetActionBindingResponse) GetAction() *Action {
@ -4531,7 +4591,7 @@ type GetEntitiesRequest struct {
func (x *GetEntitiesRequest) Reset() { func (x *GetEntitiesRequest) Reset() {
*x = GetEntitiesRequest{} *x = GetEntitiesRequest{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4543,7 +4603,7 @@ func (x *GetEntitiesRequest) String() string {
func (*GetEntitiesRequest) ProtoMessage() {} func (*GetEntitiesRequest) ProtoMessage() {}
func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message { func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[73] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4556,7 +4616,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead. // Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
func (*GetEntitiesRequest) Descriptor() ([]byte, []int) { func (*GetEntitiesRequest) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{73} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74}
} }
func (x *GetEntitiesRequest) GetEntityType() string { func (x *GetEntitiesRequest) GetEntityType() string {
@ -4596,7 +4656,7 @@ type GetEntitiesResponse struct {
func (x *GetEntitiesResponse) Reset() { func (x *GetEntitiesResponse) Reset() {
*x = GetEntitiesResponse{} *x = GetEntitiesResponse{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4608,7 +4668,7 @@ func (x *GetEntitiesResponse) String() string {
func (*GetEntitiesResponse) ProtoMessage() {} func (*GetEntitiesResponse) ProtoMessage() {}
func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message { func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[74] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4621,7 +4681,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead. // Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
func (*GetEntitiesResponse) Descriptor() ([]byte, []int) { func (*GetEntitiesResponse) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{74} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75}
} }
func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition { func (x *GetEntitiesResponse) GetEntityDefinitions() []*EntityDefinition {
@ -4645,7 +4705,7 @@ type EntityDefinition struct {
func (x *EntityDefinition) Reset() { func (x *EntityDefinition) Reset() {
*x = EntityDefinition{} *x = EntityDefinition{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4657,7 +4717,7 @@ func (x *EntityDefinition) String() string {
func (*EntityDefinition) ProtoMessage() {} func (*EntityDefinition) ProtoMessage() {}
func (x *EntityDefinition) ProtoReflect() protoreflect.Message { func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[75] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4670,7 +4730,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
// Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead. // Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
func (*EntityDefinition) Descriptor() ([]byte, []int) { func (*EntityDefinition) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{75} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76}
} }
func (x *EntityDefinition) GetTitle() string { func (x *EntityDefinition) GetTitle() string {
@ -4725,7 +4785,7 @@ type EntityProperty struct {
func (x *EntityProperty) Reset() { func (x *EntityProperty) Reset() {
*x = EntityProperty{} *x = EntityProperty{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4737,7 +4797,7 @@ func (x *EntityProperty) String() string {
func (*EntityProperty) ProtoMessage() {} func (*EntityProperty) ProtoMessage() {}
func (x *EntityProperty) ProtoReflect() protoreflect.Message { func (x *EntityProperty) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[76] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4750,7 +4810,7 @@ func (x *EntityProperty) ProtoReflect() protoreflect.Message {
// Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead. // Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
func (*EntityProperty) Descriptor() ([]byte, []int) { func (*EntityProperty) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{76} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77}
} }
func (x *EntityProperty) GetName() string { func (x *EntityProperty) GetName() string {
@ -4777,7 +4837,7 @@ type GetEntityRequest struct {
func (x *GetEntityRequest) Reset() { func (x *GetEntityRequest) Reset() {
*x = GetEntityRequest{} *x = GetEntityRequest{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4789,7 +4849,7 @@ func (x *GetEntityRequest) String() string {
func (*GetEntityRequest) ProtoMessage() {} func (*GetEntityRequest) ProtoMessage() {}
func (x *GetEntityRequest) ProtoReflect() protoreflect.Message { func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[77] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4802,7 +4862,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead. // Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
func (*GetEntityRequest) Descriptor() ([]byte, []int) { func (*GetEntityRequest) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{77} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78}
} }
func (x *GetEntityRequest) GetUniqueKey() string { func (x *GetEntityRequest) GetUniqueKey() string {
@ -4828,7 +4888,7 @@ type RestartActionRequest struct {
func (x *RestartActionRequest) Reset() { func (x *RestartActionRequest) Reset() {
*x = RestartActionRequest{} *x = RestartActionRequest{}
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[79]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi) ms.StoreMessageInfo(mi)
} }
@ -4840,7 +4900,7 @@ func (x *RestartActionRequest) String() string {
func (*RestartActionRequest) ProtoMessage() {} func (*RestartActionRequest) ProtoMessage() {}
func (x *RestartActionRequest) ProtoReflect() protoreflect.Message { func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[78] mi := &file_olivetin_api_v1_olivetin_proto_msgTypes[79]
if x != nil { if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil { if ms.LoadMessageInfo() == nil {
@ -4853,7 +4913,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead. // Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
func (*RestartActionRequest) Descriptor() ([]byte, []int) { func (*RestartActionRequest) Descriptor() ([]byte, []int) {
return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{78} return file_olivetin_api_v1_olivetin_proto_rawDescGZIP(), []int{79}
} }
func (x *RestartActionRequest) GetExecutionTrackingId() string { func (x *RestartActionRequest) GetExecutionTrackingId() string {
@ -5175,7 +5235,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" + "\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\x12A\n" + "\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\x12A\n" +
"\rconfig_issues\x18\x03 \x03(\v2\x1c.olivetin.api.v1.ConfigIssueR\fconfigIssues\"\r\n" + "\rconfig_issues\x18\x03 \x03(\v2\x1c.olivetin.api.v1.ConfigIssueR\fconfigIssues\"\r\n" +
"\vInitRequest\"\xbb\t\n" + "\vInitRequest\"\x91\n" +
"\n" +
"\fInitResponse\x12\x1e\n" + "\fInitResponse\x12\x1e\n" +
"\n" + "\n" +
"showFooter\x18\x01 \x01(\bR\n" + "showFooter\x18\x01 \x01(\bR\n" +
@ -5206,7 +5267,11 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" + "\x0elogin_required\x18\x17 \x01(\bR\rloginRequired\x12)\n" +
"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\x12>\n" + "\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\x12>\n" +
"\x1cshow_navigate_on_start_icons\x18\x19 \x01(\bR\x18showNavigateOnStartIcons\x12,\n" + "\x1cshow_navigate_on_start_icons\x18\x19 \x01(\bR\x18showNavigateOnStartIcons\x12,\n" +
"\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\"8\n" + "\x12config_issue_count\x18\x1a \x01(\x05R\x10configIssueCount\x12T\n" +
"\x16root_dashboard_entries\x18\x1b \x03(\v2\x1e.olivetin.api.v1.RootDashboardR\x14rootDashboardEntries\"A\n" +
"\rRootDashboard\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x1a\n" +
"\bcategory\x18\x02 \x01(\tR\bcategory\"8\n" +
"\x0eAdditionalLink\x12\x14\n" + "\x0eAdditionalLink\x12\x14\n" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" + "\x05title\x18\x01 \x01(\tR\x05title\x12\x10\n" +
"\x03url\x18\x02 \x01(\tR\x03url\"L\n" + "\x03url\x18\x02 \x01(\tR\x03url\"L\n" +
@ -5287,7 +5352,7 @@ func file_olivetin_api_v1_olivetin_proto_rawDescGZIP() []byte {
return file_olivetin_api_v1_olivetin_proto_rawDescData return file_olivetin_api_v1_olivetin_proto_rawDescData
} }
var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 86) var file_olivetin_api_v1_olivetin_proto_msgTypes = make([]protoimpl.MessageInfo, 87)
var file_olivetin_api_v1_olivetin_proto_goTypes = []any{ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
(*Action)(nil), // 0: olivetin.api.v1.Action (*Action)(nil), // 0: olivetin.api.v1.Action
(*ActionGroupMembership)(nil), // 1: olivetin.api.v1.ActionGroupMembership (*ActionGroupMembership)(nil), // 1: olivetin.api.v1.ActionGroupMembership
@ -5358,35 +5423,36 @@ var file_olivetin_api_v1_olivetin_proto_goTypes = []any{
(*GetDiagnosticsResponse)(nil), // 66: olivetin.api.v1.GetDiagnosticsResponse (*GetDiagnosticsResponse)(nil), // 66: olivetin.api.v1.GetDiagnosticsResponse
(*InitRequest)(nil), // 67: olivetin.api.v1.InitRequest (*InitRequest)(nil), // 67: olivetin.api.v1.InitRequest
(*InitResponse)(nil), // 68: olivetin.api.v1.InitResponse (*InitResponse)(nil), // 68: olivetin.api.v1.InitResponse
(*AdditionalLink)(nil), // 69: olivetin.api.v1.AdditionalLink (*RootDashboard)(nil), // 69: olivetin.api.v1.RootDashboard
(*OAuth2Provider)(nil), // 70: olivetin.api.v1.OAuth2Provider (*AdditionalLink)(nil), // 70: olivetin.api.v1.AdditionalLink
(*GetActionBindingRequest)(nil), // 71: olivetin.api.v1.GetActionBindingRequest (*OAuth2Provider)(nil), // 71: olivetin.api.v1.OAuth2Provider
(*GetActionBindingResponse)(nil), // 72: olivetin.api.v1.GetActionBindingResponse (*GetActionBindingRequest)(nil), // 72: olivetin.api.v1.GetActionBindingRequest
(*GetEntitiesRequest)(nil), // 73: olivetin.api.v1.GetEntitiesRequest (*GetActionBindingResponse)(nil), // 73: olivetin.api.v1.GetActionBindingResponse
(*GetEntitiesResponse)(nil), // 74: olivetin.api.v1.GetEntitiesResponse (*GetEntitiesRequest)(nil), // 74: olivetin.api.v1.GetEntitiesRequest
(*EntityDefinition)(nil), // 75: olivetin.api.v1.EntityDefinition (*GetEntitiesResponse)(nil), // 75: olivetin.api.v1.GetEntitiesResponse
(*EntityProperty)(nil), // 76: olivetin.api.v1.EntityProperty (*EntityDefinition)(nil), // 76: olivetin.api.v1.EntityDefinition
(*GetEntityRequest)(nil), // 77: olivetin.api.v1.GetEntityRequest (*EntityProperty)(nil), // 77: olivetin.api.v1.EntityProperty
(*RestartActionRequest)(nil), // 78: olivetin.api.v1.RestartActionRequest (*GetEntityRequest)(nil), // 78: olivetin.api.v1.GetEntityRequest
nil, // 79: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry (*RestartActionRequest)(nil), // 79: olivetin.api.v1.RestartActionRequest
nil, // 80: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry nil, // 80: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
nil, // 81: olivetin.api.v1.ActionArgument.SuggestionsEntry nil, // 81: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
nil, // 82: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry nil, // 82: olivetin.api.v1.ActionArgument.SuggestionsEntry
nil, // 83: olivetin.api.v1.Entity.FieldsEntry nil, // 83: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
nil, // 84: olivetin.api.v1.DumpVarsResponse.ContentsEntry nil, // 84: olivetin.api.v1.Entity.FieldsEntry
nil, // 85: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry nil, // 85: olivetin.api.v1.DumpVarsResponse.ContentsEntry
nil, // 86: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
} }
var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
3, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument 3, // 0: olivetin.api.v1.Action.arguments:type_name -> olivetin.api.v1.ActionArgument
2, // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint 2, // 1: olivetin.api.v1.Action.exec_on_webhooks:type_name -> olivetin.api.v1.ActionWebhookExecHint
1, // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership 1, // 2: olivetin.api.v1.Action.groups:type_name -> olivetin.api.v1.ActionGroupMembership
79, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry 80, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
80, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry 81, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
4, // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice 4, // 5: olivetin.api.v1.ActionArgument.choices:type_name -> olivetin.api.v1.ActionArgumentChoice
81, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry 82, // 6: olivetin.api.v1.ActionArgument.suggestions:type_name -> olivetin.api.v1.ActionArgument.SuggestionsEntry
0, // 7: olivetin.api.v1.EntityRelatedAction.action:type_name -> olivetin.api.v1.Action 0, // 7: olivetin.api.v1.EntityRelatedAction.action:type_name -> olivetin.api.v1.Action
82, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry 83, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
83, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry 84, // 9: olivetin.api.v1.Entity.fields:type_name -> olivetin.api.v1.Entity.FieldsEntry
5, // 10: olivetin.api.v1.Entity.related_actions:type_name -> olivetin.api.v1.EntityRelatedAction 5, // 10: olivetin.api.v1.Entity.related_actions:type_name -> olivetin.api.v1.EntityRelatedAction
10, // 11: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard 10, // 11: olivetin.api.v1.GetDashboardResponse.dashboard:type_name -> olivetin.api.v1.Dashboard
11, // 12: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent 11, // 12: olivetin.api.v1.Dashboard.contents:type_name -> olivetin.api.v1.DashboardComponent
@ -5404,8 +5470,8 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
28, // 24: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup 28, // 24: olivetin.api.v1.GetExecutionQueueResponse.groups:type_name -> olivetin.api.v1.ExecutionQueueGroup
22, // 25: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry 22, // 25: olivetin.api.v1.ExecutionStatusResponse.log_entry:type_name -> olivetin.api.v1.LogEntry
35, // 26: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget 35, // 26: olivetin.api.v1.ExecutionStatusResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
84, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry 85, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.ContentsEntry
85, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry 86, // 28: olivetin.api.v1.DumpPublicIdActionMapResponse.contents:type_name -> olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
51, // 29: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged 51, // 29: olivetin.api.v1.EventStreamResponse.entity_changed:type_name -> olivetin.api.v1.EventEntityChanged
52, // 30: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged 52, // 30: olivetin.api.v1.EventStreamResponse.config_changed:type_name -> olivetin.api.v1.EventConfigChanged
54, // 31: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished 54, // 31: olivetin.api.v1.EventStreamResponse.execution_finished:type_name -> olivetin.api.v1.EventExecutionFinished
@ -5415,72 +5481,73 @@ var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
22, // 35: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry 22, // 35: olivetin.api.v1.EventExecutionFinished.log_entry:type_name -> olivetin.api.v1.LogEntry
22, // 36: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry 22, // 36: olivetin.api.v1.EventExecutionStarted.log_entry:type_name -> olivetin.api.v1.LogEntry
65, // 37: olivetin.api.v1.GetDiagnosticsResponse.config_issues:type_name -> olivetin.api.v1.ConfigIssue 65, // 37: olivetin.api.v1.GetDiagnosticsResponse.config_issues:type_name -> olivetin.api.v1.ConfigIssue
70, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider 71, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
69, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink 70, // 39: olivetin.api.v1.InitResponse.additionalLinks:type_name -> olivetin.api.v1.AdditionalLink
8, // 40: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy 8, // 40: olivetin.api.v1.InitResponse.effective_policy:type_name -> olivetin.api.v1.EffectivePolicy
0, // 41: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action 69, // 41: olivetin.api.v1.InitResponse.root_dashboard_entries:type_name -> olivetin.api.v1.RootDashboard
35, // 42: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget 0, // 42: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
75, // 43: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition 35, // 43: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
6, // 44: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity 76, // 44: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
76, // 45: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty 6, // 45: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
43, // 46: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding 77, // 46: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
9, // 47: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest 43, // 47: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
12, // 48: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest 9, // 48: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
15, // 49: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest 12, // 49: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
17, // 50: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest 15, // 50: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
19, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest 17, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
78, // 52: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest 19, // 52: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
56, // 53: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest 79, // 53: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
34, // 54: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest 56, // 54: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
21, // 55: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest 34, // 55: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
24, // 56: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest 21, // 56: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
26, // 57: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest 24, // 57: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
30, // 58: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest 26, // 58: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
37, // 59: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest 30, // 59: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
39, // 60: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest 37, // 60: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
41, // 61: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest 39, // 61: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
44, // 62: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest 41, // 62: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
46, // 63: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest 44, // 63: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
58, // 64: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest 46, // 64: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
60, // 65: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest 58, // 65: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
62, // 66: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest 60, // 66: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
48, // 67: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest 62, // 67: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
64, // 68: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest 48, // 68: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
67, // 69: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest 64, // 69: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
71, // 70: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest 67, // 70: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
73, // 71: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest 72, // 71: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
77, // 72: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest 74, // 72: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
7, // 73: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse 78, // 73: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
14, // 74: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse 7, // 74: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
16, // 75: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse 14, // 75: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
18, // 76: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse 16, // 76: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
20, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse 18, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
14, // 78: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse 20, // 78: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
57, // 79: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse 14, // 79: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
36, // 80: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse 57, // 80: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
23, // 81: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse 36, // 81: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
25, // 82: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse 23, // 82: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
29, // 83: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse 25, // 83: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
31, // 84: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse 29, // 84: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
38, // 85: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse 31, // 85: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
40, // 86: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse 38, // 86: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
42, // 87: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse 40, // 87: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
45, // 88: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse 42, // 88: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
47, // 89: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse 45, // 89: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
59, // 90: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse 47, // 90: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
61, // 91: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse 59, // 91: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
63, // 92: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse 61, // 92: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
49, // 93: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse 63, // 93: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
66, // 94: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse 49, // 94: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
68, // 95: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse 66, // 95: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
72, // 96: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse 68, // 96: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
74, // 97: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse 73, // 97: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
6, // 98: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity 75, // 98: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
73, // [73:99] is the sub-list for method output_type 6, // 99: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
47, // [47:73] is the sub-list for method input_type 74, // [74:100] is the sub-list for method output_type
47, // [47:47] is the sub-list for extension type_name 48, // [48:74] is the sub-list for method input_type
47, // [47:47] is the sub-list for extension extendee 48, // [48:48] is the sub-list for extension type_name
0, // [0:47] is the sub-list for field type_name 48, // [48:48] is the sub-list for extension extendee
0, // [0:48] is the sub-list for field type_name
} }
func init() { file_olivetin_api_v1_olivetin_proto_init() } func init() { file_olivetin_api_v1_olivetin_proto_init() }
@ -5502,7 +5569,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_olivetin_api_v1_olivetin_proto_rawDesc), len(file_olivetin_api_v1_olivetin_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 86, NumMessages: 87,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },

View File

@ -28,7 +28,6 @@ require (
github.com/sirupsen/logrus v1.9.4 github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.akshayshah.org/connectproto v0.6.0 go.akshayshah.org/connectproto v0.6.0
golang.org/x/exp v0.0.0-20260718201538-764159d718ef
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
golang.org/x/sys v0.47.0 golang.org/x/sys v0.47.0
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.36.11
@ -290,6 +289,7 @@ require (
go.uber.org/zap v1.28.0 // indirect go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.54.0 // indirect golang.org/x/crypto v0.54.0 // indirect
golang.org/x/exp v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/exp/typeparams v0.0.0-20260718201538-764159d718ef // indirect golang.org/x/exp/typeparams v0.0.0-20260718201538-764159d718ef // indirect
golang.org/x/mod v0.38.0 // indirect golang.org/x/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect golang.org/x/net v0.57.0 // indirect

View File

@ -5,7 +5,7 @@ import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices" "slices"
) )
type PermissionBits int type PermissionBits int

View File

@ -9,10 +9,10 @@ import (
func Test_hasGroupsMatch(t *testing.T) { func Test_hasGroupsMatch(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
aclMatchUsergroups []string
usergroupLine string usergroupLine string
matches bool
sep string sep string
aclMatchUsergroups []string
matches bool
}{ }{
{ {
name: "No groups match", name: "No groups match",

View File

@ -43,6 +43,10 @@ type oliveTinAPI struct {
streamingClientsMutex sync.RWMutex streamingClientsMutex sync.RWMutex
} }
const maxEventStreamClients = 16
var errEventStreamClientLimit = errors.New("too many concurrent event stream clients")
// This is used to avoid race conditions when iterating over the connectedClients map. // This is used to avoid race conditions when iterating over the connectedClients map.
// and holds the lock for as minimal time as possible to avoid blocking the API for too long. // and holds the lock for as minimal time as possible to avoid blocking the API for too long.
func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient { func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
@ -58,9 +62,9 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
type streamingClient struct { type streamingClient struct {
channel chan *apiv1.EventStreamResponse channel chan *apiv1.EventStreamResponse
AuthenticatedUser *authpublic.AuthenticatedUser AuthenticatedUser *authpublic.AuthenticatedUser
heartbeatStopOnce sync.Once
heartbeatStop chan struct{} heartbeatStop chan struct{}
heartbeatDone chan struct{} heartbeatDone chan struct{}
heartbeatStopOnce sync.Once
} }
func (c *streamingClient) stopHeartbeat() { func (c *streamingClient) stopHeartbeat() {
@ -306,7 +310,8 @@ func (api *oliveTinAPI) StartActionAndWait(ctx ctx.Context, req *connect.Request
user := auth.UserFromApiCall(ctx, req, api.cfg) user := auth.UserFromApiCall(ctx, req, api.cfg)
args := startActionArgumentsFromProto(req.Msg.Arguments) args := startActionArgumentsFromProto(req.Msg.Arguments)
justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args) justification := resolveStartJustification(binding.Action, binding, req.Msg.Justification, args)
if err := validateJustificationRequired(binding.Action, justification, user); err != nil {
if err = validateJustificationRequired(binding.Action, justification, user); err != nil {
return nil, connectInvalidJustification(err) return nil, connectInvalidJustification(err)
} }
@ -801,13 +806,13 @@ func paginate(total int64, size int64, start int64) pageInfo {
if start < 0 { if start < 0 {
start = 0 start = 0
} }
if start >= total { if start >= total {
return pageInfo{total: total, size: size, start: start, end: start, empty: true} return pageInfo{total: total, size: size, start: start, end: start, empty: true}
} }
end := start + size
if end > total { end := min(start+size, total)
end = total
}
return pageInfo{total: total, size: size, start: start, end: end, empty: false} return pageInfo{total: total, size: size, start: start, end: end, empty: false}
} }
@ -1028,14 +1033,14 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
heartbeatDone: make(chan struct{}), heartbeatDone: make(chan struct{}),
} }
if err := api.registerStreamingClient(client); err != nil {
return connect.NewError(connect.CodeResourceExhausted, err)
}
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"authenticatedUser": user.Username, "authenticatedUser": user.Username,
}).Debugf("EventStream: client connected") }).Debugf("EventStream: client connected")
api.streamingClientsMutex.Lock()
api.streamingClients[client] = struct{}{}
api.streamingClientsMutex.Unlock()
go api.sendEventStreamHeartbeats(client) go api.sendEventStreamHeartbeats(client)
// loop over client channel and send events to connectedClient // loop over client channel and send events to connectedClient
@ -1054,6 +1059,21 @@ func (api *oliveTinAPI) EventStream(ctx ctx.Context, req *connect.Request[apiv1.
return nil return nil
} }
func (api *oliveTinAPI) registerStreamingClient(client *streamingClient) error {
api.streamingClientsMutex.Lock()
defer api.streamingClientsMutex.Unlock()
if len(api.streamingClients) >= maxEventStreamClients {
log.WithFields(log.Fields{
"limit": maxEventStreamClients,
}).Warn("EventStream: rejecting client; concurrent client limit reached")
return errEventStreamClientLimit
}
api.streamingClients[client] = struct{}{}
return nil
}
func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) { func (api *oliveTinAPI) sendEventStreamHeartbeats(client *streamingClient) {
defer close(client.heartbeatDone) defer close(client.heartbeatDone)
@ -1217,6 +1237,9 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
currentVersion = installationinfo.Build.Version currentVersion = installationinfo.Build.Version
availableVersion = installationinfo.Runtime.AvailableVersion availableVersion = installationinfo.Runtime.AvailableVersion
} }
rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
res := &apiv1.InitResponse{ res := &apiv1.InitResponse{
ShowFooter: api.cfg.ShowFooter, ShowFooter: api.cfg.ShowFooter,
ShowNavigation: api.cfg.ShowNavigation, ShowNavigation: api.cfg.ShowNavigation,
@ -1232,7 +1255,8 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg), OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks), AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
StyleMods: api.cfg.StyleMods, StyleMods: api.cfg.StyleMods,
RootDashboards: api.buildRootDashboards(user, api.cfg.Dashboards), RootDashboards: rootDashboardTitles(rootDashboardEntries),
RootDashboardEntries: rootDashboardEntries,
AuthenticatedUser: user.Username, AuthenticatedUser: user.Username,
AuthenticatedUserProvider: user.Provider, AuthenticatedUserProvider: user.Provider,
EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy), EffectivePolicy: buildEffectivePolicy(user.EffectivePolicy),
@ -1300,30 +1324,47 @@ func getValidThemeName(themesDir string, entry os.DirEntry) string {
} }
func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string { func (api *oliveTinAPI) buildRootDashboards(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []string {
var rootDashboards []string return rootDashboardTitles(api.buildRootDashboardEntries(user, dashboards))
dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
return rootDashboards
} }
func (api *oliveTinAPI) addDefaultDashboardIfNeeded(rootDashboards *[]string, rr *DashboardRenderRequest) { func rootDashboardTitles(entries []*apiv1.RootDashboard) []string {
titles := make([]string, 0, len(entries))
for _, entry := range entries {
titles = append(titles, entry.Title)
}
return titles
}
func (api *oliveTinAPI) buildRootDashboardEntries(user *authpublic.AuthenticatedUser, dashboards []*config.DashboardComponent) []*apiv1.RootDashboard {
var entries []*apiv1.RootDashboard
dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
api.addDefaultDashboardEntryIfNeeded(&entries, dashboardRenderRequest)
api.addCustomDashboardEntries(&entries, dashboards, dashboardRenderRequest)
return entries
}
func (api *oliveTinAPI) addDefaultDashboardEntryIfNeeded(entries *[]*apiv1.RootDashboard, rr *DashboardRenderRequest) {
defaultDashboard := buildDefaultDashboard(rr) defaultDashboard := buildDefaultDashboard(rr)
if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 { if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents) log.Tracef("defaultDashboard: %+v", defaultDashboard.Contents)
*rootDashboards = append(*rootDashboards, "Actions") *entries = append(*entries, &apiv1.RootDashboard{Title: "Actions"})
} }
} }
func (api *oliveTinAPI) addCustomDashboards(rootDashboards *[]string, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) { func (api *oliveTinAPI) addCustomDashboardEntries(entries *[]*apiv1.RootDashboard, dashboards []*config.DashboardComponent, rr *DashboardRenderRequest) {
for _, dashboard := range dashboards { for _, dashboard := range dashboards {
// We have to build the dashboard response instead of just looping over config.dashboards, // We have to build the dashboard response instead of just looping over config.dashboards,
// because we need to check if the user has access to the dashboard // because we need to check if the user has access to the dashboard
db := renderDashboard(rr, dashboard.Title) renderedDashboard := renderDashboard(rr, dashboard.Title)
if db != nil { if renderedDashboard != nil {
*rootDashboards = append(*rootDashboards, dashboard.Title) *entries = append(*entries, &apiv1.RootDashboard{
Title: dashboard.Title,
Category: dashboard.Category,
})
} }
} }
} }

View File

@ -25,9 +25,9 @@ type DashboardRenderRequest struct {
AuthenticatedUser *authpublic.AuthenticatedUser AuthenticatedUser *authpublic.AuthenticatedUser
cfg *config.Config cfg *config.Config
ex *executor.Executor ex *executor.Executor
activeBindingStates map[string]bindingActiveState
EntityType string EntityType string
EntityKey string EntityKey string
activeBindingStates map[string]bindingActiveState
} }
func activeBindingID(entry *executor.InternalLogEntry) string { func activeBindingID(entry *executor.InternalLogEntry) string {

View File

@ -2,6 +2,7 @@ package api
import ( import (
"fmt" "fmt"
"maps"
"sort" "sort"
"strings" "strings"
@ -35,9 +36,8 @@ func logEntryArgumentsToProto(args map[string]string) []*apiv1.StartActionArgume
func copyStringMap(source map[string]string) map[string]string { func copyStringMap(source map[string]string) map[string]string {
copied := make(map[string]string, len(source)) copied := make(map[string]string, len(source))
for key, value := range source {
copied[key] = value maps.Copy(copied, source)
}
return copied return copied
} }

View File

@ -75,7 +75,7 @@ func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, ex
func TestExecutionStatusIncludesStoredArguments(t *testing.T) { func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{ cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{ argumentAction("Ping host with stored args", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"}, {Name: "host", Type: "ascii_identifier"},
}), }),
} }
@ -236,7 +236,7 @@ func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) { func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
cfg := config.DefaultConfig() cfg := config.DefaultConfig()
cfg.Actions = []*config.Action{ cfg.Actions = []*config.Action{
argumentAction("Ping host", "echo {{ host }}", []config.ActionArgument{ argumentAction("Ping host - reject missing required stored arg", "echo {{ host }}", []config.ActionArgument{
{Name: "host", Type: "ascii_identifier"}, {Name: "host", Type: "ascii_identifier"},
}), }),
} }

View File

@ -29,6 +29,8 @@ func getNewTestServerAndClient(injectedConfig *config.Config) (*httptest.Server,
} }
func getNewTestServerAndClientWithExecutor(injectedConfig *config.Config, ex *executor.Executor) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) { func getNewTestServerAndClientWithExecutor(injectedConfig *config.Config, ex *executor.Executor) (*httptest.Server, apiv1connect.OliveTinApiServiceClient) {
ex.Cfg = injectedConfig
apiPath, apiHandler := GetNewHandler(ex) apiPath, apiHandler := GetNewHandler(ex)
mux := http.NewServeMux() mux := http.NewServeMux()
@ -102,8 +104,6 @@ func TestGetActionsAndStart(t *testing.T) {
log.Infof("GetReadyz response: %v", respGetReady.Msg) log.Infof("GetReadyz response: %v", respGetReady.Msg)
assert.Equal(t, true, true, "sayHello Failed")
// assert.Equal(t, 1, len(respGb.Msg.Actions), "Got 1 action button back") // assert.Equal(t, 1, len(respGb.Msg.Actions), "Got 1 action button back")
log.Printf("Response: %+v", respInit) log.Printf("Response: %+v", respInit)
@ -112,7 +112,7 @@ func TestGetActionsAndStart(t *testing.T) {
// ActionId: "blat" // ActionId: "blat"
})) }))
assert.NotNil(t, err, "Error 404 after start action") require.Error(t, err, "Error 404 after start action")
assert.Nil(t, respSa, "Nil response for non existing action") assert.Nil(t, respSa, "Nil response for non existing action")
defer conn.Close() defer conn.Close()
@ -137,12 +137,12 @@ func TestGetEntities(t *testing.T) {
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{})) resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
assert.NoError(t, err, "GetEntities should not return an error") require.NoError(t, err, "GetEntities should not return an error")
assert.NotNil(t, resp, "GetEntities response should not be nil") require.NotNil(t, resp, "GetEntities response should not be nil")
assert.NotNil(t, resp.Msg, "GetEntities response message should not be nil") require.NotNil(t, resp.Msg, "GetEntities response message should not be nil")
entityDefinitions := resp.Msg.EntityDefinitions entityDefinitions := resp.Msg.EntityDefinitions
assert.Equal(t, 3, len(entityDefinitions), "Should return 3 entity definitions") require.Len(t, entityDefinitions, 3, "Should return 3 entity definitions")
validateEntityOrderAndStructure(t, entityDefinitions) validateEntityOrderAndStructure(t, entityDefinitions)
validateNoDuplicates(t, entityDefinitions) validateNoDuplicates(t, entityDefinitions)
@ -151,6 +151,8 @@ func TestGetEntities(t *testing.T) {
} }
func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) { func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
t.Helper()
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{ resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server", EntityType: "server",
Page: 1, Page: 1,
@ -185,21 +187,27 @@ func setupTestEntities() {
} }
func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) { func validateEntityOrderAndStructure(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
t.Helper()
require.GreaterOrEqual(t, len(entityDefinitions), 3, "Need at least three entity definitions before indexing")
assert.Equal(t, "application", entityDefinitions[0].Title, "First entity should be 'application' (alphabetically first)") assert.Equal(t, "application", entityDefinitions[0].Title, "First entity should be 'application' (alphabetically first)")
assert.Equal(t, 1, len(entityDefinitions[0].Instances), "Application should have 1 instance") assert.Len(t, entityDefinitions[0].Instances, 1, "Application should have 1 instance")
assert.Equal(t, "webapp", entityDefinitions[0].Instances[0].UniqueKey, "Application instance should be 'webapp'") assert.Equal(t, "webapp", entityDefinitions[0].Instances[0].UniqueKey, "Application instance should be 'webapp'")
assert.Equal(t, "database", entityDefinitions[1].Title, "Second entity should be 'database' (alphabetically second)") assert.Equal(t, "database", entityDefinitions[1].Title, "Second entity should be 'database' (alphabetically second)")
assert.Equal(t, 2, len(entityDefinitions[1].Instances), "Database should have 2 instances") assert.Len(t, entityDefinitions[1].Instances, 2, "Database should have 2 instances")
assert.Equal(t, "mysql", entityDefinitions[1].Instances[0].UniqueKey, "First database instance should be 'mysql' (alphabetically first)") assert.Equal(t, "mysql", entityDefinitions[1].Instances[0].UniqueKey, "First database instance should be 'mysql' (alphabetically first)")
assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)") assert.Equal(t, "postgres", entityDefinitions[1].Instances[1].UniqueKey, "Second database instance should be 'postgres' (alphabetically second)")
assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)") assert.Equal(t, "server", entityDefinitions[2].Title, "Third entity should be 'server' (alphabetically third)")
assert.Equal(t, 0, len(entityDefinitions[2].Instances), "Server instances should not be included in bulk list response") assert.Empty(t, entityDefinitions[2].Instances, "Server instances should not be included in bulk list response")
assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count") assert.Equal(t, int32(3), entityDefinitions[2].TotalInstances, "Server should report total instance count")
} }
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) { func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
t.Helper()
instanceKeys := make(map[string]map[string]bool) instanceKeys := make(map[string]map[string]bool)
for _, def := range entityDefinitions { for _, def := range entityDefinitions {
instanceKeys[def.Title] = make(map[string]bool) instanceKeys[def.Title] = make(map[string]bool)
@ -211,13 +219,16 @@ func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinit
} }
func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceClient, entityDefinitions []*apiv1.EntityDefinition) { func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceClient, entityDefinitions []*apiv1.EntityDefinition) {
t.Helper()
resp2, err2 := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{})) resp2, err2 := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
assert.NoError(t, err2, "Second GetEntities call should not return an error") require.NoError(t, err2, "Second GetEntities call should not return an error")
assert.Equal(t, len(entityDefinitions), len(resp2.Msg.EntityDefinitions), "Second call should return same number of entity definitions") require.NotNil(t, resp2.Msg)
require.Len(t, resp2.Msg.EntityDefinitions, len(entityDefinitions), "Second call should return same number of entity definitions")
for i, def := range entityDefinitions { for i, def := range entityDefinitions {
assert.Equal(t, def.Title, resp2.Msg.EntityDefinitions[i].Title, "Entity order should be consistent across calls") assert.Equal(t, def.Title, resp2.Msg.EntityDefinitions[i].Title, "Entity order should be consistent across calls")
assert.Equal(t, len(def.Instances), len(resp2.Msg.EntityDefinitions[i].Instances), "Instance count should be consistent") require.Len(t, resp2.Msg.EntityDefinitions[i].Instances, len(def.Instances), "Instance count should be consistent")
for j, inst := range def.Instances { for j, inst := range def.Instances {
assert.Equal(t, inst.UniqueKey, resp2.Msg.EntityDefinitions[i].Instances[j].UniqueKey, "Instance order should be consistent across calls") assert.Equal(t, inst.UniqueKey, resp2.Msg.EntityDefinitions[i].Instances[j].UniqueKey, "Instance order should be consistent across calls")
} }
@ -226,9 +237,9 @@ func validateConsistency(t *testing.T, client apiv1connect.OliveTinApiServiceCli
func TestEvaluateEnabledExpression(t *testing.T) { func TestEvaluateEnabledExpression(t *testing.T) {
tests := []struct { tests := []struct {
entity *entities.Entity
name string name string
expression string expression string
entity *entities.Entity
expectedResult bool expectedResult bool
}{ }{
{ {
@ -376,6 +387,8 @@ func findBindingByTitle(ex *executor.Executor, title string) *executor.ActionBin
} }
func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *DashboardRenderRequest, enabled bool, expectedCanExec bool, message string) { func testWithEntity(t *testing.T, binding *executor.ActionBinding, rr *DashboardRenderRequest, enabled bool, expectedCanExec bool, message string) {
t.Helper()
binding.Entity = &entities.Entity{ binding.Entity = &entities.Entity{
UniqueKey: "test-entity", UniqueKey: "test-entity",
Data: map[string]any{"enabled": enabled}, Data: map[string]any{"enabled": enabled},
@ -782,6 +795,46 @@ func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) {
assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents) assertEventStreamAdminReceivesSecretActionEvents(t, adminEvents)
} }
func TestRegisterStreamingClientEnforcesLimit(t *testing.T) {
cfg := config.DefaultConfig()
ex := executor.DefaultExecutor(cfg)
api := newServer(ex)
user := &authpublic.AuthenticatedUser{Username: "limit-test"}
clients := make([]*streamingClient, 0, maxEventStreamClients)
for i := 0; i < maxEventStreamClients; i++ {
client := &streamingClient{
channel: make(chan *apiv1.EventStreamResponse, 1),
AuthenticatedUser: user,
heartbeatStop: make(chan struct{}),
heartbeatDone: make(chan struct{}),
}
close(client.heartbeatDone)
require.NoError(t, api.registerStreamingClient(client))
clients = append(clients, client)
}
overflow := &streamingClient{
channel: make(chan *apiv1.EventStreamResponse, 1),
AuthenticatedUser: user,
heartbeatStop: make(chan struct{}),
heartbeatDone: make(chan struct{}),
}
close(overflow.heartbeatDone)
err := api.registerStreamingClient(overflow)
require.ErrorIs(t, err, errEventStreamClientLimit)
assert.Len(t, api.streamingClients, maxEventStreamClients)
api.removeClient(clients[0])
require.NoError(t, api.registerStreamingClient(overflow))
assert.Len(t, api.streamingClients, maxEventStreamClients)
for _, client := range clients[1:] {
api.removeClient(client)
}
api.removeClient(overflow)
}
func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) { func addEventStreamTestClients(t *testing.T, api *oliveTinAPI, lowUser, adminUser *authpublic.AuthenticatedUser) (*streamingClient, *streamingClient) {
t.Helper() t.Helper()
clientLow := &streamingClient{ clientLow := &streamingClient{

View File

@ -68,6 +68,61 @@ func TestDashboardAclsRootNavAndGetDashboard(t *testing.T) {
assert.Equal(t, "Services", db.Title) assert.Equal(t, "Services", db.Title)
} }
func TestRootDashboardEntriesIncludeCategory(t *testing.T) {
cfg := config.DefaultConfig()
cfg.Dashboards = []*config.DashboardComponent{
{
Title: "Misc Tools",
Contents: []*config.DashboardComponent{{Title: "Hello", Type: "display"}},
},
{
Title: "My Servers",
Category: "Infrastructure",
Contents: []*config.DashboardComponent{{Title: "Ping", Type: "display"}},
},
{
Title: "Status Board",
Category: "Monitoring",
Contents: []*config.DashboardComponent{{Title: "Uptime", Type: "display"}},
},
{
Title: "My Containers",
Category: "Infrastructure",
Contents: []*config.DashboardComponent{{Title: "Restart", Type: "display"}},
},
}
ex := executor.DefaultExecutor(cfg)
api := newServer(ex)
user := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
user.BuildUserAcls(cfg)
entries := api.buildRootDashboardEntries(user, cfg.Dashboards)
require.Len(t, entries, 4)
assert.Equal(t, []string{"Misc Tools", "My Servers", "Status Board", "My Containers"}, rootDashboardTitles(entries))
assert.Equal(t, "", entries[0].Category)
assert.Equal(t, "Infrastructure", entries[1].Category)
assert.Equal(t, "Monitoring", entries[2].Category)
assert.Equal(t, "Infrastructure", entries[3].Category)
}
func TestRootDashboardEntriesOmitAclHiddenCategories(t *testing.T) {
cfg := buildDashboardAclTestConfig()
cfg.Dashboards[0].Category = "Public"
cfg.Dashboards[1].Category = "Admin only"
ex := executor.DefaultExecutor(cfg)
api := newServer(ex)
guest := &authpublic.AuthenticatedUser{Username: "guest", Provider: "system"}
guest.BuildUserAcls(cfg)
entries := api.buildRootDashboardEntries(guest, cfg.Dashboards)
require.Len(t, entries, 1)
assert.Equal(t, "Public tools", entries[0].Title)
assert.Equal(t, "Public", entries[0].Category)
}
func TestDashboardAclsNestedDirectoryDeepLink(t *testing.T) { func TestDashboardAclsNestedDirectoryDeepLink(t *testing.T) {
cfg := buildDashboardAclTestConfig() cfg := buildDashboardAclTestConfig()
cfg.Dashboards = []*config.DashboardComponent{ cfg.Dashboards = []*config.DashboardComponent{

View File

@ -10,7 +10,7 @@ import (
entities "github.com/OliveTin/OliveTin/internal/entities" entities "github.com/OliveTin/OliveTin/internal/entities"
"github.com/OliveTin/OliveTin/internal/tpl" "github.com/OliveTin/OliveTin/internal/tpl"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"golang.org/x/exp/slices" "slices"
) )
func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard { func renderDashboard(rr *DashboardRenderRequest, dashboardTitle string) *apiv1.Dashboard {

View File

@ -10,15 +10,12 @@ import (
// User represents a person. // User represents a person.
type AuthenticatedUser struct { type AuthenticatedUser struct {
Username string
UsergroupLine string
Provider string
SID string
Acls []string
EffectivePolicy *config.ConfigurationPolicy EffectivePolicy *config.ConfigurationPolicy
Username string
UsergroupLine string
Provider string
SID string
Acls []string
} }
func (u *AuthenticatedUser) IsGuest() bool { func (u *AuthenticatedUser) IsGuest() bool {

View File

@ -10,8 +10,8 @@ func Test_parseUsergroupLine(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
usergroupLine string usergroupLine string
expectedGroups []string
sep string sep string
expectedGroups []string
}{ }{
{ {
name: "Default separator (space)", name: "Default separator (space)",

View File

@ -21,7 +21,7 @@ func TestCheckUserFromLocalBearerApiKey_Match_LowercaseBearerScheme(t *testing.T
ApiKey: "secret-api-key", ApiKey: "secret-api-key",
}} }}
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "bearer secret-api-key") req.Header.Set("Authorization", "bearer secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -43,7 +43,7 @@ func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) {
ApiKey: "secret-api-key", ApiKey: "secret-api-key",
}} }}
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "Bearer secret-api-key") req.Header.Set("Authorization", "Bearer secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -64,7 +64,7 @@ func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) {
ApiKey: "secret-api-key", ApiKey: "secret-api-key",
}} }}
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "Bearer wrong") req.Header.Set("Authorization", "Bearer wrong")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -81,7 +81,7 @@ func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) {
ApiKey: "secret-api-key", ApiKey: "secret-api-key",
}} }}
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "Bearer secret-api-key") req.Header.Set("Authorization", "Bearer secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -98,7 +98,7 @@ func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) {
ApiKey: "secret-api-key", ApiKey: "secret-api-key",
}} }}
req := httptest.NewRequest("POST", "/", nil) req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "secret-api-key") req.Header.Set("Authorization", "secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg} ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}

View File

@ -156,7 +156,7 @@ func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token
return nil, err return nil, err
} }
keyFunc := func(token *jwt.Token) (interface{}, error) { keyFunc := func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok { if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("parseJwt expected token algorithm RSA but got: %v", token.Header["alg"]) return nil, fmt.Errorf("parseJwt expected token algorithm RSA but got: %v", token.Header["alg"])
} }
@ -170,7 +170,7 @@ func parseJwtTokenWithLocalKey(cfg *config.Config, jwtString string) (*jwt.Token
// Hash-based Message Authentication Code // Hash-based Message Authentication Code
func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, error) { func parseJwtTokenWithHMAC(cfg *config.Config, jwtString string) (*jwt.Token, error) {
keyFunc := func(token *jwt.Token) (interface{}, error) { keyFunc := func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("parseJwt expected token algorithm HMAC but got: %v", token.Header["alg"]) return nil, fmt.Errorf("parseJwt expected token algorithm HMAC but got: %v", token.Header["alg"])
} }
@ -237,7 +237,7 @@ func parseJwt(cfg *config.Config, token string) *authTypes.AuthenticatedUser {
func parseGroupClaim(groupClaim string, claims jwt.MapClaims) string { func parseGroupClaim(groupClaim string, claims jwt.MapClaims) string {
usergroup := "" usergroup := ""
if val, ok := claims[groupClaim]; ok { if val, ok := claims[groupClaim]; ok {
if array, ok := val.([]interface{}); ok { if array, ok := val.([]any); ok {
groups := make([]string, len(array)) groups := make([]string, len(array))
for i, v := range array { for i, v := range array {
groups[i] = fmt.Sprintf("%s", v) groups[i] = fmt.Sprintf("%s", v)

View File

@ -17,9 +17,12 @@ import (
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) { func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048) privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil { if err != nil {
t.Fatalf("failed to generate RSA key: %v", err) t.Fatalf("failed to generate RSA key: %v", err)
@ -42,6 +45,8 @@ func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
} }
func createKeys(t *testing.T) (*rsa.PrivateKey, string) { func createKeys(t *testing.T) (*rsa.PrivateKey, string) {
t.Helper()
tmpFile, err := os.CreateTemp(os.TempDir(), "olivetin-jwt-") tmpFile, err := os.CreateTemp(os.TempDir(), "olivetin-jwt-")
if err != nil { if err != nil {
t.Fatalf("failed to create temp file: %v", err) t.Fatalf("failed to create temp file: %v", err)
@ -66,6 +71,8 @@ func newMux() *http.ServeMux {
} }
func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.PrivateKey, expire int64, audience string) string { func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.PrivateKey, expire int64, audience string) string {
t.Helper()
token := jwt.New(jwt.SigningMethodRS256) token := jwt.New(jwt.SigningMethodRS256)
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
claims["nbf"] = time.Now().Unix() - 1000 claims["nbf"] = time.Now().Unix() - 1000
@ -84,6 +91,8 @@ func createJWTTokenWithExpirationAndAudience(t *testing.T, privateKey *rsa.Priva
} }
func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler { func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
t.Helper()
mux := newMux() mux := newMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
context := &authpublic.AuthCheckingContext{ context := &authpublic.AuthCheckingContext{
@ -93,7 +102,7 @@ func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
user := CheckUserFromJwtHeader(context) user := CheckUserFromJwtHeader(context)
if user == nil { if user == nil {
w.WriteHeader(403) w.WriteHeader(http.StatusForbidden)
return return
} }
@ -104,17 +113,24 @@ func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
} }
func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) { func verifyJWTResponse(t *testing.T, res *http.Response, expectCode int) {
t.Helper()
defer func() { _ = res.Body.Close() }() defer func() { _ = res.Body.Close() }()
assert.Equal(t, expectCode, res.StatusCode) assert.Equal(t, expectCode, res.StatusCode)
body, _ := io.ReadAll(res.Body) body, err := io.ReadAll(res.Body)
require.NoError(t, err, "reading JWT response body")
t.Logf("Response body: %s", string(body)) t.Logf("Response body: %s", string(body))
} }
func testJwkValidation(t *testing.T, expire int64, expectCode int) { func testJwkValidation(t *testing.T, expire int64, expectCode int) {
t.Helper()
testJwkValidationWithAudience(t, expire, expectCode, "", "") testJwkValidationWithAudience(t, expire, expectCode, "", "")
} }
func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, configAudience, tokenAudience string) { func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, configAudience, tokenAudience string) {
t.Helper()
privateKey, publicKeyPath := createKeys(t) privateKey, publicKeyPath := createKeys(t)
defer func() { _ = os.Remove(publicKeyPath) }() defer func() { _ = os.Remove(publicKeyPath) }()
@ -131,27 +147,30 @@ func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, c
srv := httptest.NewServer(handler) srv := httptest.NewServer(handler)
defer srv.Close() defer srv.Close()
res := makeJWTRequest(t, srv, tokenStr) res := makeJWTRequest(t, srv, tokenStr) //nolint:bodyclose // closed by verifyJWTResponse
verifyJWTResponse(t, res, expectCode) verifyJWTResponse(t, res, expectCode)
} }
func TestJWTSignatureVerificationSucceeds(t *testing.T) { func TestJWTSignatureVerificationSucceeds(t *testing.T) {
testJwkValidation(t, 1000, 200) testJwkValidation(t, 1000, http.StatusOK)
} }
func TestJWTSignatureVerificationFails(t *testing.T) { func TestJWTSignatureVerificationFails(t *testing.T) {
testJwkValidation(t, -500, 403) testJwkValidation(t, -500, http.StatusForbidden)
} }
func TestJWTAudienceValidationRejectsWrongAudience(t *testing.T) { func TestJWTAudienceValidationRejectsWrongAudience(t *testing.T) {
testJwkValidationWithAudience(t, 1000, 403, "expected-audience", "wrong-audience") testJwkValidationWithAudience(t, 1000, http.StatusForbidden, "expected-audience", "wrong-audience")
} }
func TestJWTAudienceValidationAcceptsCorrectAudience(t *testing.T) { func TestJWTAudienceValidationAcceptsCorrectAudience(t *testing.T) {
testJwkValidationWithAudience(t, 1000, 200, "expected-audience", "expected-audience") testJwkValidationWithAudience(t, 1000, http.StatusOK, "expected-audience", "expected-audience")
} }
func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups interface{}) string { func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups any) string {
t.Helper()
token := jwt.New(jwt.SigningMethodRS256) token := jwt.New(jwt.SigningMethodRS256)
claims := token.Claims.(jwt.MapClaims) claims := token.Claims.(jwt.MapClaims)
claims["nbf"] = time.Now().Unix() - 1000 claims["nbf"] = time.Now().Unix() - 1000
@ -167,7 +186,9 @@ func createJWTTokenWithGroups(t *testing.T, privateKey *rsa.PrivateKey, groups i
} }
func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.Response { func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.Response {
req, err := http.NewRequest("GET", srv.URL, nil) t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
if err != nil { if err != nil {
t.Fatalf("failed to create request: %v", err) t.Fatalf("failed to create request: %v", err)
} }
@ -177,6 +198,7 @@ func makeJWTRequest(t *testing.T, srv *httptest.Server, tokenStr string) *http.R
if err != nil { if err != nil {
t.Fatalf("Client err: %+v", err) t.Fatalf("Client err: %+v", err)
} }
return res return res
} }
@ -201,7 +223,7 @@ func TestJWTHeader(t *testing.T) {
user := CheckUserFromJwtHeader(context) user := CheckUserFromJwtHeader(context)
if user == nil { if user == nil {
w.WriteHeader(403) w.WriteHeader(http.StatusForbidden)
return return
} }
@ -212,10 +234,6 @@ func TestJWTHeader(t *testing.T) {
srv := httptest.NewServer(mux) srv := httptest.NewServer(mux)
defer srv.Close() defer srv.Close()
res := makeJWTRequest(t, srv, tokenStr) res := makeJWTRequest(t, srv, tokenStr) //nolint:bodyclose // closed by verifyJWTResponse
defer func() { _ = res.Body.Close() }() verifyJWTResponse(t, res, http.StatusOK)
assert.Equal(t, 200, res.StatusCode)
body, _ := io.ReadAll(res.Body)
t.Logf("Response body: %s", string(body))
} }

View File

@ -22,9 +22,9 @@ import (
type OAuth2Handler struct { type OAuth2Handler struct {
cfg *config.Config cfg *config.Config
mu sync.RWMutex
registeredStates map[string]*oauth2State registeredStates map[string]*oauth2State
registeredProviders map[string]*oauth2.Config registeredProviders map[string]*oauth2.Config
mu sync.RWMutex
} }
func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler { func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
@ -58,11 +58,11 @@ func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
} }
type oauth2State struct { type oauth2State struct {
createdAt time.Time
providerConfig *oauth2.Config providerConfig *oauth2.Config
providerName string providerName string
Username string Username string
Usergroup string Usergroup string
createdAt time.Time
} }
const ( const (
@ -342,7 +342,18 @@ type UserInfo struct {
func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth2Provider) *UserInfo { func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth2Provider) *UserInfo {
ret := &UserInfo{} ret := &UserInfo{}
res, err := client.Get(provider.WhoamiUrl) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, provider.WhoamiUrl, nil)
if err != nil {
log.Error("Could not construct user data request", err)
return ret
}
res, err := client.Do(req)
if err != nil { if err != nil {
log.Errorf("Failed to get user data: %v", err) log.Errorf("Failed to get user data: %v", err)

View File

@ -56,7 +56,7 @@ func TestHandleOAuthLoginRejectsWhenStateMapFull(t *testing.T) {
} }
} }
req := httptest.NewRequest(http.MethodGet, "/oauth/login?provider=test", nil) req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/oauth/login?provider=test", nil)
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
h.HandleOAuthLogin(rec, req) h.HandleOAuthLogin(rec, req)

View File

@ -13,34 +13,33 @@ const JustificationRequiredNoTemplate = " "
// Action represents the core functionality of OliveTin - commands that show up // Action represents the core functionality of OliveTin - commands that show up
// as buttons in the UI. // as buttons in the UI.
type Action struct { type Action struct {
ID string `koanf:"id"` SaveLogs SaveLogsConfig `koanf:"saveLogs"`
Title string `koanf:"title"`
Icon string `koanf:"icon"`
Shell string `koanf:"shell"` Shell string `koanf:"shell"`
Exec []string `koanf:"exec"` Icon string `koanf:"icon"`
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"` ExecOnCalendarFile string `koanf:"execOnCalendarFile"`
SourceFile string `koanf:"-"`
ShellAfterCompleted string `koanf:"shellAfterCompleted"`
Justification string `koanf:"justification"`
EnabledExpression string `koanf:"enabledExpression"`
Entity string `koanf:"entity"`
Title string `koanf:"title"`
PopupOnStart string `koanf:"popupOnStart"`
OnClick string `koanf:"onclick"`
ID string `koanf:"id"`
MaxRate []RateSpec `koanf:"maxRate"`
Acls []string `koanf:"acls"`
ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"` ExecOnWebhook []WebhookConfig `koanf:"execOnWebhook"`
Triggers []string `koanf:"triggers"` Triggers []string `koanf:"triggers"`
MaxConcurrent int `koanf:"maxConcurrent"` Exec []string `koanf:"exec"`
MaxRate []RateSpec `koanf:"maxRate"` ExecOnFileCreatedInDir []string `koanf:"execOnFileCreatedInDir"`
Arguments []ActionArgument `koanf:"arguments"` Arguments []ActionArgument `koanf:"arguments"`
OnClick string `koanf:"onclick"` ExecOnCron []string `koanf:"execOnCron"`
PopupOnStart string `koanf:"popupOnStart"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"`
EnabledExpression string `koanf:"enabledExpression"`
Groups []string `koanf:"groups"` Groups []string `koanf:"groups"`
Justification string `koanf:"justification"` ExecOnFileChangedInDir []string `koanf:"execOnFileChangedInDir"`
// SourceFile is set by OliveTin when loading config (not user YAML). Timeout int `koanf:"timeout"`
SourceFile string `koanf:"-"` MaxConcurrent int `koanf:"maxConcurrent"`
Hidden bool `koanf:"hidden"`
ExecOnStartup bool `koanf:"execOnStartup"`
} }
func (action *Action) RequiresJustification() bool { func (action *Action) RequiresJustification() bool {
@ -61,23 +60,23 @@ func (action *Action) JustificationTemplateText() string {
// ActionGroup defines shared limits and metadata for a set of actions. // ActionGroup defines shared limits and metadata for a set of actions.
type ActionGroup struct { type ActionGroup struct {
Icon string `koanf:"icon"`
MaxConcurrent int `koanf:"maxConcurrent"` MaxConcurrent int `koanf:"maxConcurrent"`
QueueSize int `koanf:"queueSize"` QueueSize int `koanf:"queueSize"`
Icon string `koanf:"icon"`
} }
// ActionArgument objects appear on Actions. // ActionArgument objects appear on Actions.
type ActionArgument struct { type ActionArgument struct {
Suggestions map[string]string `koanf:"suggestions"`
Name string `koanf:"name"` Name string `koanf:"name"`
Title string `koanf:"title"` Title string `koanf:"title"`
Description string `koanf:"description"` Description string `koanf:"description"`
Type string `koanf:"type"` Type string `koanf:"type"`
Default string `koanf:"default"` Default string `koanf:"default"`
Choices []ActionArgumentChoice `koanf:"choices"`
Entity string `koanf:"entity"` Entity string `koanf:"entity"`
RejectNull bool `koanf:"rejectNull"`
Suggestions map[string]string `koanf:"suggestions"`
SuggestionsBrowserKey string `koanf:"suggestionsBrowserKey"` SuggestionsBrowserKey string `koanf:"suggestionsBrowserKey"`
Choices []ActionArgumentChoice `koanf:"choices"`
RejectNull bool `koanf:"rejectNull"`
} }
// ActionArgumentChoice represents a predefined choice for an argument. // ActionArgumentChoice represents a predefined choice for an argument.
@ -88,8 +87,8 @@ type ActionArgumentChoice struct {
// RateSpec allows you to set a max frequency for an action. // RateSpec allows you to set a max frequency for an action.
type RateSpec struct { type RateSpec struct {
Limit int `koanf:"limit"`
Duration string `koanf:"duration"` Duration string `koanf:"duration"`
Limit int `koanf:"limit"`
} }
// WebhookConfig defines configuration for generic webhook triggers. // WebhookConfig defines configuration for generic webhook triggers.
@ -111,9 +110,8 @@ type EntityFile struct {
File string `koanf:"file"` File string `koanf:"file"`
Name string `koanf:"name"` Name string `koanf:"name"`
Icon string `koanf:"icon"` Icon string `koanf:"icon"`
SourceFile string `koanf:"-"`
Properties []EntityProperty `koanf:"properties"` Properties []EntityProperty `koanf:"properties"`
// SourceFile is set by OliveTin when loading config (not user YAML).
SourceFile string `koanf:"-"`
} }
// EntityProperty defines a column shown when listing entity instances in the UI. // EntityProperty defines a column shown when listing entity instances in the UI.
@ -133,11 +131,11 @@ type PermissionsList struct {
// AccessControlList defines what permissions apply to a user or user group. // AccessControlList defines what permissions apply to a user or user group.
type AccessControlList struct { type AccessControlList struct {
Name string `koanf:"name"` Name string `koanf:"name"`
AddToEveryAction bool `koanf:"addToEveryAction"`
MatchUsergroups []string `koanf:"matchUsergroups"` MatchUsergroups []string `koanf:"matchUsergroups"`
MatchUsernames []string `koanf:"matchUsernames"` MatchUsernames []string `koanf:"matchUsernames"`
Permissions PermissionsList `koanf:"permissions"` Permissions PermissionsList `koanf:"permissions"`
Policy ConfigurationPolicy `koanf:"policy"` Policy ConfigurationPolicy `koanf:"policy"`
AddToEveryAction bool `koanf:"addToEveryAction"`
} }
// ConfigurationPolicy defines global settings which are overridden with an ACL. // ConfigurationPolicy defines global settings which are overridden with an ACL.
@ -154,88 +152,87 @@ type PrometheusConfig struct {
// SecurityConfig allows users to fine tune the security related HTTP headers and cookie options. // SecurityConfig allows users to fine tune the security related HTTP headers and cookie options.
type SecurityConfig struct { type SecurityConfig struct {
HeaderContentSecurityPolicy bool `koanf:"headerContentSecurityPolicy"`
ContentSecurityPolicy string `koanf:"contentSecurityPolicy"` ContentSecurityPolicy string `koanf:"contentSecurityPolicy"`
XFrameOptions string `koanf:"xFrameOptions"`
HeaderContentSecurityPolicy bool `koanf:"headerContentSecurityPolicy"`
HeaderXContentTypeOptions bool `koanf:"headerXContentTypeOptions"` HeaderXContentTypeOptions bool `koanf:"headerXContentTypeOptions"`
HeaderXFrameOptions bool `koanf:"headerXFrameOptions"` HeaderXFrameOptions bool `koanf:"headerXFrameOptions"`
XFrameOptions string `koanf:"xFrameOptions"`
ForceSecureCookies bool `koanf:"forceSecureCookies"` ForceSecureCookies bool `koanf:"forceSecureCookies"`
} }
// Config is the global config used through the whole app. // Config is the global config used through the whole app.
type Config struct { type Config struct {
UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"` ActionGroups map[string]*ActionGroup `koanf:"actionGroups"`
ThemeName string `koanf:"themeName"` AuthOAuth2Providers map[string]*OAuth2Provider `koanf:"authOAuth2Providers"`
ThemeCacheDisabled bool `koanf:"themeCacheDisabled"` SaveLogs SaveLogsConfig `koanf:"saveLogs"`
ListenAddressSingleHTTPFrontend string `koanf:"listenAddressSingleHTTPFrontend"` DefaultIconForBack string `koanf:"defaultIconForBack"`
ListenAddressWebUI string `koanf:"listenAddressWebUI"` AuthOAuth2RedirectURL string `koanf:"authOAuth2RedirectUrl"`
ListenAddressRestActions string `koanf:"listenAddressRestActions"` ListenAddressRestActions string `koanf:"listenAddressRestActions"`
ListenAddressPrometheus string `koanf:"listenAddressPrometheus"` ListenAddressPrometheus string `koanf:"listenAddressPrometheus"`
ExternalRestAddress string `koanf:"externalRestAddress"` ExternalRestAddress string `koanf:"externalRestAddress"`
LogLevel string `koanf:"logLevel"` LogLevel string `koanf:"logLevel"`
LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"` ThemeName string `koanf:"themeName"`
LogHistoryPageSize int64 `koanf:"logHistoryPageSize"` ServiceLogs ServiceLogsConfig `koanf:"serviceLogs"`
ActionGroups map[string]*ActionGroup `koanf:"actionGroups"` ListenAddressSingleHTTPFrontend string `koanf:"listenAddressSingleHTTPFrontend"`
Actions []*Action `koanf:"actions"` AuthJwtHmacSecret string `koanf:"authJwtHmacSecret"`
Entities []*EntityFile `koanf:"entities"` AuthJwtCertsURL string `koanf:"authJwtCertsUrl"`
Dashboards []*DashboardComponent `koanf:"dashboards"` DefaultIconForActions string `koanf:"defaultIconForActions"`
CheckForUpdates bool `koanf:"checkForUpdates"` Include string `koanf:"include"`
PageTitle string `koanf:"pageTitle"` PageTitle string `koanf:"pageTitle"`
ShowFooter bool `koanf:"showFooter"` BannerCSS string `koanf:"bannerCss"`
ShowNavigation bool `koanf:"showNavigation"` BannerMessage string `koanf:"bannerMessage"`
ShowNewVersions bool `koanf:"showNewVersions"` DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
ShowNavigateOnStartIcons bool `koanf:"showNavigateOnStartIcons"` ServiceHostMode string `koanf:"serviceHostMode"`
EnableCustomJs bool `koanf:"enableCustomJs"` DefaultOnClick string `koanf:"defaultOnClick"`
AuthJwtCookieName string `koanf:"authJwtCookieName"` AuthJwtCookieName string `koanf:"authJwtCookieName"`
AuthJwtHeader string `koanf:"authJwtHeader"` AuthJwtHeader string `koanf:"authJwtHeader"`
AuthJwtAud string `koanf:"authJwtAud"` AuthJwtAud string `koanf:"authJwtAud"`
AuthJwtDomain string `koanf:"authJwtDomain"` ListenAddressWebUI string `koanf:"listenAddressWebUI"`
AuthJwtCertsURL string `koanf:"authJwtCertsUrl"` SectionNavigationStyle string `koanf:"sectionNavigationStyle"`
AuthJwtHmacSecret string `koanf:"authJwtHmacSecret"` // mutually exclusive with pub key config fields DefaultIconForDirectories string `koanf:"defaultIconForDirectories"`
AuthJwtClaimUsername string `koanf:"authJwtClaimUsername"` AuthJwtClaimUsername string `koanf:"authJwtClaimUsername"`
AuthJwtClaimUserGroup string `koanf:"authJwtClaimUserGroup"` AuthJwtClaimUserGroup string `koanf:"authJwtClaimUserGroup"`
AuthJwtPubKeyPath string `koanf:"authJwtPubKeyPath"` // will read pub key from file on disk AuthJwtPubKeyPath string `koanf:"authJwtPubKeyPath"`
AuthHttpHeaderUsername string `koanf:"authHttpHeaderUsername"` AuthHttpHeaderUsername string `koanf:"authHttpHeaderUsername"`
AuthHttpHeaderUserGroup string `koanf:"authHttpHeaderUserGroup"` AuthHttpHeaderUserGroup string `koanf:"authHttpHeaderUserGroup"`
AuthHttpHeaderUserGroupSep string `koanf:"authHttpHeaderUserGroupSep"` 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"` WebUIDir string `koanf:"webUIDir"`
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"` AuthLoginUrl string `koanf:"authLoginUrl"`
SectionNavigationStyle string `koanf:"sectionNavigationStyle"` AuthJwtDomain string `koanf:"authJwtDomain"`
DefaultOnClick string `koanf:"defaultOnClick"`
DefaultPopupOnStart string `koanf:"defaultPopupOnStart"`
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
InsecureAllowDumpServerDiagnostics bool `koanf:"insecureAllowDumpServerDiagnostics"`
InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"`
InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"`
Prometheus PrometheusConfig `koanf:"prometheus"`
Security SecurityConfig `koanf:"security"` Security SecurityConfig `koanf:"security"`
SaveLogs SaveLogsConfig `koanf:"saveLogs"` Actions []*Action `koanf:"actions"`
ServiceLogs ServiceLogsConfig `koanf:"serviceLogs"` AccessControlLists []*AccessControlList `koanf:"accessControlLists"`
DefaultIconForActions string `koanf:"defaultIconForActions"`
DefaultIconForDirectories string `koanf:"defaultIconForDirectories"`
DefaultIconForBack string `koanf:"defaultIconForBack"`
AdditionalNavigationLinks []*NavigationLink `koanf:"additionalNavigationLinks"`
ServiceHostMode string `koanf:"serviceHostMode"`
StyleMods []string `koanf:"styleMods"` StyleMods []string `koanf:"styleMods"`
BannerMessage string `koanf:"bannerMessage"` AdditionalNavigationLinks []*NavigationLink `koanf:"additionalNavigationLinks"`
BannerCSS string `koanf:"bannerCss"` Entities []*EntityFile `koanf:"entities"`
Include string `koanf:"include"` Dashboards []*DashboardComponent `koanf:"dashboards"`
sourceFiles []string
sourceFiles []string AuthLocalUsers AuthLocalUsersConfig `koanf:"authLocalUsers"`
LogHistoryPageSize int64 `koanf:"logHistoryPageSize"`
LogDebugOptions LogDebugOptions `koanf:"logDebugOptions"`
DefaultPermissions PermissionsList `koanf:"defaultPermissions"`
DefaultPolicy ConfigurationPolicy `koanf:"defaultPolicy"`
Prometheus PrometheusConfig `koanf:"prometheus"`
CheckForUpdates bool `koanf:"checkForUpdates"`
InsecureAllowDumpJwtClaims bool `koanf:"insecureAllowDumpJwtClaims"`
InsecureAllowDumpActionMap bool `koanf:"insecureAllowDumpActionMap"`
InsecureAllowDumpServerDiagnostics bool `koanf:"insecureAllowDumpServerDiagnostics"`
InsecureAllowDumpVars bool `koanf:"insecureAllowDumpVars"`
InsecureAllowDumpOAuth2UserData bool `koanf:"insecureAllowDumpOAuth2UserData"`
CronSupportForSeconds bool `koanf:"cronSupportForSeconds"`
AuthRequireGuestsToLogin bool `koanf:"authRequireGuestsToLogin"`
EnableCustomJs bool `koanf:"enableCustomJs"`
ShowNavigateOnStartIcons bool `koanf:"showNavigateOnStartIcons"`
ShowNewVersions bool `koanf:"showNewVersions"`
ShowNavigation bool `koanf:"showNavigation"`
ShowFooter bool `koanf:"showFooter"`
UseSingleHTTPFrontend bool `koanf:"useSingleHTTPFrontend"`
ThemeCacheDisabled bool `koanf:"themeCacheDisabled"`
} }
type AuthLocalUsersConfig struct { type AuthLocalUsersConfig struct {
Enabled bool `koanf:"enabled"`
Users []*LocalUser `koanf:"users"` Users []*LocalUser `koanf:"users"`
Enabled bool `koanf:"enabled"`
} }
type LocalUser struct { type LocalUser struct {
@ -246,21 +243,21 @@ type LocalUser struct {
} }
type OAuth2Provider struct { type OAuth2Provider struct {
Name string `koanf:"name"` AuthUrl string `koanf:"authUrl"`
Title string `koanf:"title"` UserGroupField string `koanf:"userGroupField"`
ClientID string `koanf:"clientId"` ClientID string `koanf:"clientId"`
ClientSecret string `koanf:"clientSecret"` ClientSecret string `koanf:"clientSecret"`
Icon string `koanf:"icon"` 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"`
AddToUsergroup string `koanf:"addToUsergroup"` AddToUsergroup string `koanf:"addToUsergroup"`
Title string `koanf:"title"`
WhoamiUrl string `koanf:"whoamiUrl"`
Name string `koanf:"name"`
UsernameField string `koanf:"usernameField"`
TokenUrl string `koanf:"tokenUrl"`
CertBundlePath string `koanf:"certBundlePath"`
Scopes []string `koanf:"scopes"`
CallbackTimeout int `koanf:"callbackTimeout"`
InsecureSkipVerify bool `koanf:"insecureSkipVerify"`
} }
type NavigationLink struct { type NavigationLink struct {
@ -289,6 +286,7 @@ type LogDebugOptions struct {
type DashboardComponent struct { type DashboardComponent struct {
Title string `koanf:"title"` Title string `koanf:"title"`
Category string `koanf:"category"`
Type string `koanf:"type"` Type string `koanf:"type"`
Entity string `koanf:"entity"` Entity string `koanf:"entity"`
Icon string `koanf:"icon"` Icon string `koanf:"icon"`

View File

@ -2,11 +2,13 @@ package config
import ( import (
"fmt" "fmt"
"net"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"regexp" "regexp"
"sort" "sort"
"strconv"
"strings" "strings"
"github.com/OliveTin/OliveTin/internal/configissues" "github.com/OliveTin/OliveTin/internal/configissues"
@ -81,12 +83,72 @@ func afterLoadFinalize(cfg *Config, configPath string) {
cfg.SetDir(filepath.Dir(configPath)) cfg.SetDir(filepath.Dir(configPath))
cfg.Sanitize() cfg.Sanitize()
applyPortEnvironmentOverride(cfg)
for _, l := range listeners { for _, l := range listeners {
l() l()
} }
} }
// applyPortEnvironmentOverride lets the PORT environment variable take precedence
// over the configured HTTP frontend port.
func applyPortEnvironmentOverride(cfg *Config) {
envPort := strings.TrimSpace(os.Getenv("PORT"))
if envPort == "" {
return
}
port, ok := parseEnvPort(envPort)
if !ok {
return
}
host, ok := listenHostOrDefault(cfg.ListenAddressSingleHTTPFrontend)
if !ok {
log.WithFields(log.Fields{
"PORT": envPort,
"listenAddress": cfg.ListenAddressSingleHTTPFrontend,
}).Error("Ignoring PORT environment variable because listenAddressSingleHTTPFrontend is invalid")
return
}
cfg.ListenAddressSingleHTTPFrontend = net.JoinHostPort(host, strconv.Itoa(port))
log.WithFields(log.Fields{
"address": cfg.ListenAddressSingleHTTPFrontend,
}).Info("Using PORT environment variable for single HTTP frontend listen address")
}
func parseEnvPort(envPort string) (int, bool) {
port, err := strconv.Atoi(envPort)
if err != nil || port < 1 || port > 65535 {
log.WithFields(log.Fields{
"PORT": envPort,
"error": err,
}).Error("Ignoring invalid PORT environment variable")
return 0, false
}
return port, true
}
func listenHostOrDefault(listenAddress string) (string, bool) {
if strings.TrimSpace(listenAddress) == "" {
return "0.0.0.0", true
}
host, _, err := net.SplitHostPort(listenAddress)
if err != nil {
return "", false
}
if host == "" {
return "0.0.0.0", true
}
return host, true
}
// buildIncludePath constructs the full path to the include directory. // buildIncludePath constructs the full path to the include directory.
func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string { func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
relativeIncludePath := k.String("include") relativeIncludePath := k.String("include")
@ -187,17 +249,17 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
}).Info("Successfully loaded included config file") }).Info("Successfully loaded included config file")
} }
func mergeFuncForSource(sourceFile string) func(src, dest map[string]interface{}) error { func mergeFuncForSource(sourceFile string) func(src, dest map[string]any) error {
return func(src map[string]interface{}, dest map[string]interface{}) error { return func(src map[string]any, dest map[string]any) error {
return mergeFunc(src, dest, sourceFile) return mergeFunc(src, dest, sourceFile)
} }
} }
// mergeActionsWhenBothExist merges actions when both src and dest have actions. // mergeActionsWhenBothExist merges actions when both src and dest have actions.
func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{}, dest map[string]interface{}, sourceFile string) { func mergeActionsWhenBothExist(srcActions any, destActions any, dest map[string]any, sourceFile string) {
stampSourceOnMaps(srcActions, sourceFile) stampSourceOnMaps(srcActions, sourceFile)
srcSlice, ok1 := srcActions.([]interface{}) srcSlice, ok1 := srcActions.([]any)
destSlice, ok2 := destActions.([]interface{}) destSlice, ok2 := destActions.([]any)
if ok1 && ok2 { if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...) dest["actions"] = append(destSlice, srcSlice...)
} else { } else {
@ -206,7 +268,7 @@ func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{},
} }
// mergeActionsFromSource merges actions from source into destination. // mergeActionsFromSource merges actions from source into destination.
func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{}, sourceFile string) { func mergeActionsFromSource(srcActions any, dest map[string]any, sourceFile string) {
if destActions, ok := dest["actions"]; ok { if destActions, ok := dest["actions"]; ok {
mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile) mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile)
} else { } else {
@ -216,9 +278,9 @@ func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{},
} }
// mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards. // mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards.
func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards interface{}, dest map[string]interface{}) { func mergeDashboardsWhenBothExist(srcDashboards any, destDashboards any, dest map[string]any) {
srcSlice, ok1 := srcDashboards.([]interface{}) srcSlice, ok1 := srcDashboards.([]any)
destSlice, ok2 := destDashboards.([]interface{}) destSlice, ok2 := destDashboards.([]any)
if ok1 && ok2 { if ok1 && ok2 {
dest["dashboards"] = append(destSlice, srcSlice...) dest["dashboards"] = append(destSlice, srcSlice...)
} else { } else {
@ -227,7 +289,7 @@ func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards inte
} }
// mergeDashboardsFromSource merges dashboards from source into destination. // mergeDashboardsFromSource merges dashboards from source into destination.
func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interface{}) { func mergeDashboardsFromSource(srcDashboards any, dest map[string]any) {
if destDashboards, ok := dest["dashboards"]; ok { if destDashboards, ok := dest["dashboards"]; ok {
mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest) mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest)
} else { } else {
@ -236,10 +298,10 @@ func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interf
} }
// mergeEntitiesWhenBothExist merges entities when both src and dest have entities. // mergeEntitiesWhenBothExist merges entities when both src and dest have entities.
func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{}, dest map[string]interface{}, sourceFile string) { func mergeEntitiesWhenBothExist(srcEntities any, destEntities any, dest map[string]any, sourceFile string) {
stampSourceOnMaps(srcEntities, sourceFile) stampSourceOnMaps(srcEntities, sourceFile)
srcSlice, ok1 := srcEntities.([]interface{}) srcSlice, ok1 := srcEntities.([]any)
destSlice, ok2 := destEntities.([]interface{}) destSlice, ok2 := destEntities.([]any)
if ok1 && ok2 { if ok1 && ok2 {
dest["entities"] = append(destSlice, srcSlice...) dest["entities"] = append(destSlice, srcSlice...)
} else { } else {
@ -248,7 +310,7 @@ func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{
} }
// mergeEntitiesFromSource merges entities from source into destination. // mergeEntitiesFromSource merges entities from source into destination.
func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{}, sourceFile string) { func mergeEntitiesFromSource(srcEntities any, dest map[string]any, sourceFile string) {
if destEntities, ok := dest["entities"]; ok { if destEntities, ok := dest["entities"]; ok {
mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile) mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile)
} else { } else {
@ -257,7 +319,7 @@ func mergeEntitiesFromSource(srcEntities interface{}, dest map[string]interface{
} }
} }
func mergeFunc(src map[string]interface{}, dest map[string]interface{}, sourceFile string) error { func mergeFunc(src map[string]any, dest map[string]any, sourceFile string) error {
if srcActions, ok := src["actions"]; ok { if srcActions, ok := src["actions"]; ok {
mergeActionsFromSource(srcActions, dest, sourceFile) mergeActionsFromSource(srcActions, dest, sourceFile)
} }

View File

@ -0,0 +1,82 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestApplyPortEnvironmentOverride(t *testing.T) {
t.Setenv("PORT", "8080")
cfg := DefaultConfig()
cfg.ListenAddressSingleHTTPFrontend = "0.0.0.0:1337"
applyPortEnvironmentOverride(cfg)
assert.Equal(t, "0.0.0.0:8080", cfg.ListenAddressSingleHTTPFrontend)
}
func TestApplyPortEnvironmentOverridePreservesHost(t *testing.T) {
t.Setenv("PORT", "9000")
cfg := DefaultConfig()
cfg.ListenAddressSingleHTTPFrontend = "127.0.0.1:1337"
applyPortEnvironmentOverride(cfg)
assert.Equal(t, "127.0.0.1:9000", cfg.ListenAddressSingleHTTPFrontend)
}
func TestApplyPortEnvironmentOverrideUnsetLeavesConfig(t *testing.T) {
t.Setenv("PORT", "")
cfg := DefaultConfig()
cfg.ListenAddressSingleHTTPFrontend = "0.0.0.0:2337"
applyPortEnvironmentOverride(cfg)
assert.Equal(t, "0.0.0.0:2337", cfg.ListenAddressSingleHTTPFrontend)
}
func TestApplyPortEnvironmentOverrideIgnoresInvalid(t *testing.T) {
// parseEnvPort accepts only 1..65535; port 0 and out-of-range values are ignored.
cases := []struct {
name string
port string
}{
{name: "non-numeric", port: "not-a-port"},
{name: "above max", port: "65536"},
{name: "negative", port: "-1"},
{name: "zero ignored", port: "0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("PORT", tc.port)
cfg := DefaultConfig()
cfg.ListenAddressSingleHTTPFrontend = "0.0.0.0:1337"
applyPortEnvironmentOverride(cfg)
assert.Equal(t, "0.0.0.0:1337", cfg.ListenAddressSingleHTTPFrontend)
})
}
}
func TestApplyPortEnvironmentOverrideEmptyListenAddressDefaultsHost(t *testing.T) {
t.Setenv("PORT", "8080")
cfg := DefaultConfig()
cfg.ListenAddressSingleHTTPFrontend = ""
applyPortEnvironmentOverride(cfg)
assert.Equal(t, "0.0.0.0:8080", cfg.ListenAddressSingleHTTPFrontend)
}
func TestApplyPortEnvironmentOverrideRejectsMalformedListenAddress(t *testing.T) {
t.Setenv("PORT", "8080")
cfg := DefaultConfig()
cfg.ListenAddressSingleHTTPFrontend = "not-a-valid-address"
applyPortEnvironmentOverride(cfg)
assert.Equal(t, "not-a-valid-address", cfg.ListenAddressSingleHTTPFrontend)
}

View File

@ -2,6 +2,7 @@ package config
import ( import (
"fmt" "fmt"
"slices"
"strings" "strings"
"text/template" "text/template"
@ -179,13 +180,7 @@ func (cfg *Config) inlineActionExists(action *Action) bool {
} }
func (cfg *Config) inlineActionPointerExists(action *Action) bool { func (cfg *Config) inlineActionPointerExists(action *Action) bool {
for _, existingAction := range cfg.Actions { return slices.Contains(cfg.Actions, action)
if existingAction == action {
return true
}
}
return false
} }
func (cfg *Config) inlineActionIDExists(action *Action) bool { func (cfg *Config) inlineActionIDExists(action *Action) bool {
@ -400,7 +395,7 @@ func expandEnvTemplate(source string) string {
return source return source
} }
var b strings.Builder var b strings.Builder
if err := t.Execute(&b, map[string]interface{}{"Env": env.BuildEnvMap()}); err != nil { if err := t.Execute(&b, map[string]any{"Env": env.BuildEnvMap()}); err != nil {
log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal") log.WithFields(log.Fields{"error": err}).Debug("Env template execute failed, using literal")
return source return source
} }

View File

@ -15,7 +15,7 @@ func stampSourceOnMaps(raw any, sourceFile string) any {
return raw return raw
} }
items, ok := raw.([]interface{}) items, ok := raw.([]any)
if !ok { if !ok {
return raw return raw
} }
@ -27,7 +27,7 @@ func stampSourceOnMaps(raw any, sourceFile string) any {
} }
func stampSourceOnMap(item any, sourceFile string) { func stampSourceOnMap(item any, sourceFile string) {
m, ok := item.(map[string]interface{}) m, ok := item.(map[string]any)
if !ok { if !ok {
return return
} }
@ -74,7 +74,7 @@ func sourcePathAt(paths []string, index int) string {
} }
func stampedSourcePaths(raw any) []string { func stampedSourcePaths(raw any) []string {
items, ok := raw.([]interface{}) items, ok := raw.([]any)
if !ok { if !ok {
return nil return nil
} }
@ -87,7 +87,7 @@ func stampedSourcePaths(raw any) []string {
} }
func stampedSourceFromMap(item any) string { func stampedSourceFromMap(item any) string {
m, ok := item.(map[string]interface{}) m, ok := item.(map[string]any)
if !ok { if !ok {
return "" return ""
} }

View File

@ -69,7 +69,7 @@ func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
p = filepath.Join(baseDir, p) p = filepath.Join(baseDir, p)
log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path") log.WithFields(log.Fields{"entityFile": p}).Debugf("Adding config dir to entity file path")
} }
go filehelper.WatchFileWrite(p, func(filename string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{ go filehelper.WatchFileWrite(p, func(_ string) { loadEntityFile(p, ef.Name) }, filehelper.WatchMeta{
ConfigFile: ef.SourceFile, ConfigFile: ef.SourceFile,
}) })
loadEntityFile(p, ef.Name) loadEntityFile(p, ef.Name)

View File

@ -10,6 +10,7 @@ package entities
*/ */
import ( import (
"maps"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@ -39,9 +40,8 @@ func GetEntities() EntitiesByClass {
for entityName, entityInstances := range entities { for entityName, entityInstances := range entities {
copiedInstances := make(entityInstancesByKey, len(entityInstances)) copiedInstances := make(entityInstancesByKey, len(entityInstances))
for key, entity := range entityInstances { maps.Copy(copiedInstances, entityInstances)
copiedInstances[key] = entity
}
copiedEntities[entityName] = copiedInstances copiedEntities[entityName] = copiedInstances
} }
@ -57,9 +57,8 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
if entities, ok := entities[entityName]; ok { if entities, ok := entities[entityName]; ok {
copiedInstances := make(entityInstancesByKey, len(entities)) copiedInstances := make(entityInstancesByKey, len(entities))
for key, entity := range entities { maps.Copy(copiedInstances, entities)
copiedInstances[key] = entity
}
return copiedInstances return copiedInstances
} }

View File

@ -67,7 +67,7 @@ func parseExecSegment(arg string, values map[string]string, entity *entities.Ent
func validateArguments(values map[string]string, action *config.Action) error { func validateArguments(values map[string]string, action *config.Action) error {
for _, arg := range action.Arguments { for _, arg := range action.Arguments {
if err := typecheckActionArgument(&arg, values[arg.Name], action); err != nil { if err := typecheckActionArgument(&arg, values[arg.Name]); err != nil {
return err return err
} }
log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned") log.WithFields(log.Fields{"name": arg.Name, "value": values[arg.Name]}).Debugf("Arg assigned")
@ -90,7 +90,7 @@ func parseActionArguments(req *ExecutionRequest) (string, error) {
argName := arg.Name argName := arg.Name
argValue := req.Arguments[argName] argValue := req.Arguments[argName]
err := typecheckActionArgument(&arg, argValue, req.Binding.Action) err := typecheckActionArgument(&arg, argValue)
if err != nil { if err != nil {
return "", err return "", err
@ -153,7 +153,7 @@ func argumentSkipsValidation(arg *config.ActionArgument) bool {
return arg.Type == "html" return arg.Type == "html"
} }
func typecheckActionArgument(arg *config.ActionArgument, value string, action *config.Action) error { func typecheckActionArgument(arg *config.ActionArgument, value string) error {
if argumentSkipsValidation(arg) { if argumentSkipsValidation(arg) {
return nil return nil
} }
@ -199,7 +199,7 @@ func ValidateArgument(arg *config.ActionArgument, value string, action *config.A
mangledValue := MangleArgumentValue(arg, value, action.Title) mangledValue := MangleArgumentValue(arg, value, action.Title)
// Use the same validation path as the executor // Use the same validation path as the executor
return typecheckActionArgument(arg, mangledValue, action) return typecheckActionArgument(arg, mangledValue)
} }
func typecheckActionArgumentFound(value string, arg *config.ActionArgument) error { func typecheckActionArgumentFound(value string, arg *config.ActionArgument) error {

View File

@ -12,16 +12,17 @@ import (
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
) )
func TestSanitizeUnsafe(t *testing.T) { func TestSanitizeUnsafe(t *testing.T) {
assert.Nil(t, TypeSafetyCheck("", "_zomg_ c:/ haxxor ' bobby tables && rm -rf ", "very_dangerous_raw_string")) require.NoError(t, TypeSafetyCheck("", "_zomg_ c:/ haxxor ' bobby tables && rm -rf ", "very_dangerous_raw_string"))
} }
func TestSanitizeUnimplemented(t *testing.T) { func TestSanitizeUnimplemented(t *testing.T) {
err := TypeSafetyCheck("", "I am a happy little argument", "greeting_type") err := TypeSafetyCheck("", "I am a happy little argument", "greeting_type")
assert.NotNil(t, err, "Test an argument type that does not exist") require.Error(t, err, "Test an argument type that does not exist")
} }
func TestValidateArgumentCheckboxDefaultValues(t *testing.T) { func TestValidateArgumentCheckboxDefaultValues(t *testing.T) {
@ -35,10 +36,10 @@ func TestValidateArgumentCheckboxDefaultValues(t *testing.T) {
// Default checkbox values without choices should accept "1" and "0" // Default checkbox values without choices should accept "1" and "0"
err := ValidateArgument(&arg, "1", &action) err := ValidateArgument(&arg, "1", &action)
assert.Nil(t, err, "Expected checkbox value \"1\" to be accepted without choices") require.NoError(t, err, "Expected checkbox value \"1\" to be accepted without choices")
err = ValidateArgument(&arg, "0", &action) err = ValidateArgument(&arg, "0", &action)
assert.Nil(t, err, "Expected checkbox value \"0\" to be accepted without choices") require.NoError(t, err, "Expected checkbox value \"0\" to be accepted without choices")
} }
func TestMangleCheckboxValueWithChoices(t *testing.T) { func TestMangleCheckboxValueWithChoices(t *testing.T) {
@ -105,14 +106,14 @@ func TestValidateArgumentCheckboxWithChoices(t *testing.T) {
// Titles should be accepted once mangled to their values // Titles should be accepted once mangled to their values
err := ValidateArgument(&arg, "Enabled", &action) err := ValidateArgument(&arg, "Enabled", &action)
assert.Nil(t, err, "Expected checkbox title \"Enabled\" to be accepted after mangling to choice value") require.NoError(t, err, "Expected checkbox title \"Enabled\" to be accepted after mangling to choice value")
err = ValidateArgument(&arg, "Disabled", &action) err = ValidateArgument(&arg, "Disabled", &action)
assert.Nil(t, err, "Expected checkbox title \"Disabled\" to be accepted after mangling to choice value") require.NoError(t, err, "Expected checkbox title \"Disabled\" to be accepted after mangling to choice value")
// Unknown titles should be rejected because they do not match any choice value // Unknown titles should be rejected because they do not match any choice value
err = ValidateArgument(&arg, "Maybe", &action) err = ValidateArgument(&arg, "Maybe", &action)
assert.NotNil(t, err, "Expected unknown checkbox title to be rejected against choices") require.Error(t, err, "Expected unknown checkbox title to be rejected against choices")
} }
func checklistTestArg() config.ActionArgument { func checklistTestArg() config.ActionArgument {
@ -134,13 +135,13 @@ func TestValidateArgumentChecklistSelections(t *testing.T) {
action := config.Action{Title: "Test checklist"} action := config.Action{Title: "Test checklist"}
err := ValidateArgument(&arg, "documents", &action) err := ValidateArgument(&arg, "documents", &action)
assert.Nil(t, err) require.NoError(t, err)
err = ValidateArgument(&arg, `["documents","photos"]`, &action) err = ValidateArgument(&arg, `["documents","photos"]`, &action)
assert.Nil(t, err) require.NoError(t, err)
err = ValidateArgument(&arg, `["documents","unknown"]`, &action) err = ValidateArgument(&arg, `["documents","unknown"]`, &action)
assert.NotNil(t, err) require.Error(t, err)
} }
func TestValidateArgumentChecklistTitleMangling(t *testing.T) { func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
@ -150,7 +151,7 @@ func TestValidateArgumentChecklistTitleMangling(t *testing.T) {
action := config.Action{Title: "Test checklist title mangling"} action := config.Action{Title: "Test checklist title mangling"}
err := ValidateArgument(&arg, `["Documents","Photos"]`, &action) err := ValidateArgument(&arg, `["Documents","Photos"]`, &action)
assert.Nil(t, err) require.NoError(t, err)
} }
func TestValidateArgumentChecklistEmptySelection(t *testing.T) { func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
@ -160,11 +161,11 @@ func TestValidateArgumentChecklistEmptySelection(t *testing.T) {
action := config.Action{Title: "Test checklist empty"} action := config.Action{Title: "Test checklist empty"}
err := ValidateArgument(&arg, "", &action) err := ValidateArgument(&arg, "", &action)
assert.Nil(t, err) require.NoError(t, err)
arg.RejectNull = true arg.RejectNull = true
err = ValidateArgument(&arg, "", &action) err = ValidateArgument(&arg, "", &action)
assert.NotNil(t, err) require.Error(t, err)
} }
func TestValidateArgumentChecklistWithoutChoices(t *testing.T) { func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
@ -177,7 +178,7 @@ func TestValidateArgumentChecklistWithoutChoices(t *testing.T) {
action := config.Action{Title: "Test checklist without choices"} action := config.Action{Title: "Test checklist without choices"}
err := ValidateArgument(&arg, "documents", &action) err := ValidateArgument(&arg, "documents", &action)
assert.NotNil(t, err) require.Error(t, err)
} }
func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) { func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
@ -187,7 +188,7 @@ func TestValidateArgumentChecklistRejectsEmptySegment(t *testing.T) {
action := config.Action{Title: "Test checklist empty segment"} action := config.Action{Title: "Test checklist empty segment"}
err := ValidateArgument(&arg, `["documents","","photos"]`, &action) err := ValidateArgument(&arg, `["documents","","photos"]`, &action)
assert.NotNil(t, err) require.Error(t, err)
} }
func TestMangleArgumentValueChecklist(t *testing.T) { func TestMangleArgumentValueChecklist(t *testing.T) {
@ -223,13 +224,13 @@ func TestValidateArgumentChecklistEntitySelections(t *testing.T) {
action := config.Action{Title: "Test checklist entity"} action := config.Action{Title: "Test checklist entity"}
err := ValidateArgument(&arg, "attic", &action) err := ValidateArgument(&arg, "attic", &action)
assert.Nil(t, err) require.NoError(t, err)
err = ValidateArgument(&arg, `["attic","basement"]`, &action) err = ValidateArgument(&arg, `["attic","basement"]`, &action)
assert.Nil(t, err) require.NoError(t, err)
err = ValidateArgument(&arg, `["attic","unknown"]`, &action) err = ValidateArgument(&arg, `["attic","unknown"]`, &action)
assert.NotNil(t, err) require.Error(t, err)
} }
func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) { func TestMangleArgumentValueChecklistEntityTitles(t *testing.T) {
@ -274,7 +275,7 @@ func TestParseActionArgumentsChecklistEmptySelection(t *testing.T) {
mangleInvalidArgumentValues(req) mangleInvalidArgumentValues(req)
out, err := parseActionArguments(req) out, err := parseActionArguments(req)
assert.Nil(t, err) require.NoError(t, err)
assert.Equal(t, "echo 'Selected segments: '", out) assert.Equal(t, "echo 'Selected segments: '", out)
} }
@ -307,13 +308,13 @@ func TestArgumentValueNullable(t *testing.T) {
out, err := parseActionArguments(req) out, err := parseActionArguments(req)
assert.Equal(t, "echo 'Releasing hounds'", out) assert.Equal(t, "echo 'Releasing hounds'", out)
assert.Nil(t, err) require.NoError(t, err)
req.Binding.Action.Arguments[0].RejectNull = true req.Binding.Action.Arguments[0].RejectNull = true
_, err = parseActionArguments(req) _, err = parseActionArguments(req)
assert.NotNil(t, err) require.Error(t, err)
} }
func TestArgumentNameNumbers(t *testing.T) { func TestArgumentNameNumbers(t *testing.T) {
@ -336,7 +337,7 @@ func TestArgumentNameNumbers(t *testing.T) {
out, err := parseActionArguments(req) out, err := parseActionArguments(req)
assert.Equal(t, "echo 'Tickling Fred'", out) assert.Equal(t, "echo 'Tickling Fred'", out)
assert.Nil(t, err) require.NoError(t, err)
} }
func TestArgumentNotProvided(t *testing.T) { func TestArgumentNotProvided(t *testing.T) {
@ -356,8 +357,8 @@ func TestArgumentNotProvided(t *testing.T) {
out, err := parseActionArguments(req) out, err := parseActionArguments(req)
assert.Equal(t, "", out) assert.Empty(t, out)
assert.Equal(t, err.Error(), "required arg not provided: personName") require.EqualError(t, err, "required arg not provided: personName")
} }
func TestExecArrayParsing(t *testing.T) { func TestExecArrayParsing(t *testing.T) {
@ -372,7 +373,7 @@ func TestExecArrayParsing(t *testing.T) {
out, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity) out, err := parseActionExec(req.Arguments, req.Binding.Action, req.Binding.Entity)
assert.Nil(t, err) require.NoError(t, err)
assert.Equal(t, []string{"ls", "-alh"}, out) assert.Equal(t, []string{"ls", "-alh"}, out)
} }
@ -394,7 +395,7 @@ func TestExecArrayWithTemplateReplacement(t *testing.T) {
out, err := parseActionExec(values, &a1, nil) out, err := parseActionExec(values, &a1, nil)
assert.Nil(t, err) require.NoError(t, err)
assert.Equal(t, []string{"ls", "-alh", "tmp"}, out) assert.Equal(t, []string{"ls", "-alh", "tmp"}, out)
} }
@ -411,7 +412,7 @@ func TestCheckShellArgumentSafetyWithURL(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'url' cannot be used with Shell execution") assert.Contains(t, err.Error(), "unsafe argument type 'url' cannot be used with Shell execution")
assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html") assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html")
} }
@ -429,7 +430,7 @@ func TestCheckShellArgumentSafetyWithEmail(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'email' cannot be used with Shell execution") assert.Contains(t, err.Error(), "unsafe argument type 'email' cannot be used with Shell execution")
} }
@ -446,7 +447,7 @@ func TestCheckShellArgumentSafetyWithExec(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.Nil(t, err) require.NoError(t, err)
} }
func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) { func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) {
@ -462,7 +463,7 @@ func TestCheckShellArgumentSafetyWithSafeTypes(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.Nil(t, err) require.NoError(t, err)
} }
func TestCheckShellArgumentSafetyWithPassword(t *testing.T) { func TestCheckShellArgumentSafetyWithPassword(t *testing.T) {
@ -478,7 +479,7 @@ func TestCheckShellArgumentSafetyWithPassword(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'password' cannot be used with Shell execution") assert.Contains(t, err.Error(), "unsafe argument type 'password' cannot be used with Shell execution")
assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html") assert.Contains(t, err.Error(), "https://docs.olivetin.app/action_execution/shellvsexec.html")
} }
@ -496,7 +497,7 @@ func TestCheckShellArgumentSafetyWithPasswordAndExec(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.Nil(t, err) require.NoError(t, err)
} }
func TestCheckShellArgumentSafetyWithHTML(t *testing.T) { func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
@ -509,7 +510,7 @@ func TestCheckShellArgumentSafetyWithHTML(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'html'") assert.Contains(t, err.Error(), "unsafe argument type 'html'")
} }
@ -523,7 +524,7 @@ func TestCheckShellArgumentSafetyWithConfirmation(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.Nil(t, err, "confirmation is constrained to 0/1 and is safe with shell") require.NoError(t, err, "confirmation is constrained to 0/1 and is safe with shell")
} }
func TestCheckShellArgumentSafetyWithUnnamedConfirmation(t *testing.T) { func TestCheckShellArgumentSafetyWithUnnamedConfirmation(t *testing.T) {
@ -536,7 +537,7 @@ func TestCheckShellArgumentSafetyWithUnnamedConfirmation(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.Nil(t, err) require.NoError(t, err)
} }
func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) { func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
@ -549,7 +550,7 @@ func TestCheckShellArgumentSafetyWithChoicelessCheckbox(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'checkbox'") assert.Contains(t, err.Error(), "unsafe argument type 'checkbox'")
} }
@ -563,20 +564,20 @@ func TestCheckShellArgumentSafetyWithCustomRegex(t *testing.T) {
} }
err := checkShellArgumentSafety(&a1) err := checkShellArgumentSafety(&a1)
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "unsafe argument type 'regex:[a-zA-Z0-9.-]+'") assert.Contains(t, err.Error(), "unsafe argument type 'regex:[a-zA-Z0-9.-]+'")
} }
func TestTypeSafetyCheckUrl(t *testing.T) { func TestTypeSafetyCheckUrl(t *testing.T) {
assert.Nil(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com") require.NoError(t, TypeSafetyCheck("test1", "http://google.com", "url"), "Test URL: google.com")
assert.Nil(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments") require.NoError(t, TypeSafetyCheck("test2", "http://technowax.net:80?foo=bar", "url"), "Test URL: technowax.net with query arguments")
assert.Nil(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments") require.NoError(t, TypeSafetyCheck("test3", "http://localhost:80?foo=bar", "url"), "Test URL: localhost with query arguments")
assert.Nil(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme") require.NoError(t, TypeSafetyCheck("test7", "https://example.com/path", "url"), "Test URL: https scheme")
assert.NotNil(t, TypeSafetyCheck("test4", "http://lo host:80", "url"), "Test a badly formed URL") require.Error(t, TypeSafetyCheck("test4", "http://lo host:80", "url"), "Test a badly formed URL")
assert.NotNil(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL") require.Error(t, TypeSafetyCheck("test5", "12345", "url"), "Test a badly formed URL")
assert.NotNil(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL") require.Error(t, TypeSafetyCheck("test6", "_!23;", "url"), "Test a badly formed URL")
assert.NotNil(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected") require.Error(t, TypeSafetyCheck("test8", "file:///etc/passwd", "url"), "file:// scheme must be rejected")
assert.NotNil(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected") require.Error(t, TypeSafetyCheck("test9", "gopher://example.com", "url"), "gopher:// scheme must be rejected")
} }
func TestTypeSafetyCheckRegex(t *testing.T) { func TestTypeSafetyCheckRegex(t *testing.T) {
@ -622,9 +623,9 @@ func TestTypeSafetyCheckRegex(t *testing.T) {
err := typeSafetyCheckRegex(tt.field, tt.value, tt.pattern) err := typeSafetyCheckRegex(tt.field, tt.value, tt.pattern)
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value %s with pattern %s, but got no error", tt.value, tt.pattern) require.Error(t, err, "Expected error for value %s with pattern %s, but got no error", tt.value, tt.pattern)
} else { } else {
assert.Nil(t, err, "Expected no error for value %s with pattern %s, but got error: %v", tt.value, tt.pattern, err) require.NoError(t, err, "Expected no error for value %s with pattern %s, but got error: %v", tt.value, tt.pattern, err)
} }
}) })
} }
@ -687,9 +688,9 @@ func TestTypeSafetyCheckEmail(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck(tt.field, tt.value, "email") err := TypeSafetyCheck(tt.field, tt.value, "email")
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) require.Error(t, err, "Expected error for value '%s'", tt.value)
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
} }
}) })
} }
@ -716,9 +717,9 @@ func TestTypeSafetyCheckDatetime(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck(tt.field, tt.value, "datetime") err := TypeSafetyCheck(tt.field, tt.value, "datetime")
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) require.Error(t, err, "Expected error for value '%s'", tt.value)
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
} }
}) })
} }
@ -740,7 +741,7 @@ func TestTypeSafetyCheckRawStringMultiline(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck(tt.field, tt.value, "raw_string_multiline") err := TypeSafetyCheck(tt.field, tt.value, "raw_string_multiline")
assert.Nil(t, err, "raw_string_multiline should accept any value") require.NoError(t, err, "raw_string_multiline should accept any value")
}) })
} }
} }
@ -772,6 +773,8 @@ func TestTypeSafetyCheckUnicodeIdentifier(t *testing.T) {
} }
func validateTypeSafetyResult(t *testing.T, value string, expectsError bool, err error) { func validateTypeSafetyResult(t *testing.T, value string, expectsError bool, err error) {
t.Helper()
if expectsError { if expectsError {
assertErrorExpected(t, value, err) assertErrorExpected(t, value, err)
} else { } else {
@ -780,6 +783,8 @@ func validateTypeSafetyResult(t *testing.T, value string, expectsError bool, err
} }
func assertErrorExpected(t *testing.T, value string, err error) { func assertErrorExpected(t *testing.T, value string, err error) {
t.Helper()
if err == nil { if err == nil {
t.Errorf("Expected error for value '%s', but got none", value) t.Errorf("Expected error for value '%s', but got none", value)
} else { } else {
@ -788,6 +793,8 @@ func assertErrorExpected(t *testing.T, value string, err error) {
} }
func assertNoErrorExpected(t *testing.T, value string, err error) { func assertNoErrorExpected(t *testing.T, value string, err error) {
t.Helper()
if err != nil { if err != nil {
t.Errorf("Expected no error for value '%s', but got: %v", value, err) t.Errorf("Expected no error for value '%s', but got: %v", value, err)
} else { } else {
@ -816,9 +823,9 @@ func TestTypeSafetyCheckAsciiIdentifier(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck(tt.field, tt.value, "ascii_identifier") err := TypeSafetyCheck(tt.field, tt.value, "ascii_identifier")
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) require.Error(t, err, "Expected error for value '%s'", tt.value)
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
} }
}) })
} }
@ -854,9 +861,9 @@ func TestTypeSafetyCheckDnsName(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck("host", tt.value, "dnsname") err := TypeSafetyCheck("host", tt.value, "dnsname")
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) require.Error(t, err, "Expected error for value '%s'", tt.value)
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
} }
}) })
} }
@ -886,9 +893,9 @@ func TestTypeSafetyCheckShellSafeIdentifier(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck("username", tt.value, "shell_safe_identifier") err := TypeSafetyCheck("username", tt.value, "shell_safe_identifier")
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) require.Error(t, err, "Expected error for value '%s'", tt.value)
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
} }
}) })
} }
@ -915,9 +922,9 @@ func TestTypeSafetyCheckAsciiSentence(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := TypeSafetyCheck(tt.field, tt.value, "ascii_sentence") err := TypeSafetyCheck(tt.field, tt.value, "ascii_sentence")
if tt.hasError { if tt.hasError {
assert.NotNil(t, err, "Expected error for value '%s'", tt.value) require.Error(t, err, "Expected error for value '%s'", tt.value)
} else { } else {
assert.Nil(t, err, "Expected no error for value '%s', but got: %v", tt.value, err) require.NoError(t, err, "Expected no error for value '%s', but got: %v", tt.value, err)
} }
}) })
} }
@ -928,10 +935,9 @@ func TestTypecheckActionArgumentEmptyName(t *testing.T) {
Name: "", Name: "",
Type: "ascii", Type: "ascii",
} }
action := config.Action{Title: "Test"}
err := typecheckActionArgument(&arg, "test", &action) err := typecheckActionArgument(&arg, "test")
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "argument name cannot be empty") assert.Contains(t, err.Error(), "argument name cannot be empty")
} }
@ -940,17 +946,16 @@ func TestTypecheckActionArgumentConfirmation(t *testing.T) {
Name: "confirm", Name: "confirm",
Type: "confirmation", Type: "confirmation",
} }
action := config.Action{Title: "Test"}
assert.Nil(t, typecheckActionArgument(&arg, "0", &action)) require.NoError(t, typecheckActionArgument(&arg, "0"))
assert.Nil(t, typecheckActionArgument(&arg, "1", &action)) require.NoError(t, typecheckActionArgument(&arg, "1"))
err := typecheckActionArgument(&arg, "any_value", &action) err := typecheckActionArgument(&arg, "any_value")
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "must be \"0\" or \"1\"") assert.Contains(t, err.Error(), "must be \"0\" or \"1\"")
err = typecheckActionArgument(&arg, "", &action) err = typecheckActionArgument(&arg, "")
assert.NotNil(t, err) require.Error(t, err)
assert.Contains(t, err.Error(), "must be \"0\" or \"1\"") assert.Contains(t, err.Error(), "must be \"0\" or \"1\"")
} }
@ -959,10 +964,9 @@ func TestTypecheckActionArgumentUnnamedConfirmation(t *testing.T) {
Type: "confirmation", Type: "confirmation",
Title: "Are you sure?!", Title: "Are you sure?!",
} }
action := config.Action{Title: "Test"}
assert.Nil(t, typecheckActionArgument(&arg, "", &action)) require.NoError(t, typecheckActionArgument(&arg, ""))
assert.Nil(t, typecheckActionArgument(&arg, "ignored", &action)) require.NoError(t, typecheckActionArgument(&arg, "ignored"))
} }
func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) { func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) {
@ -976,17 +980,17 @@ func TestTypecheckActionArgumentHtmlWithoutName(t *testing.T) {
} }
err := validateArguments(map[string]string{}, &action) err := validateArguments(map[string]string{}, &action)
assert.NoError(t, err) require.NoError(t, err)
} }
func TestParseCommandForReplacements(t *testing.T) { func TestParseCommandForReplacements(t *testing.T) {
tests := []struct { tests := []struct {
values map[string]string
name string name string
shellCommand string shellCommand string
values map[string]string
expectedOutput string expectedOutput string
expectError bool
errorContains string errorContains string
expectError bool
}{ }{
{ {
name: "Simple replacement", name: "Simple replacement",
@ -1038,12 +1042,12 @@ func TestParseCommandForReplacements(t *testing.T) {
output, err := tpl.ParseTemplateWithActionContext(tt.shellCommand, nil, tt.values) output, err := tpl.ParseTemplateWithActionContext(tt.shellCommand, nil, tt.values)
if tt.expectError { if tt.expectError {
assert.NotNil(t, err, "Expected error but got none") require.Error(t, err, "Expected error but got none")
if tt.errorContains != "" { if tt.errorContains != "" {
assert.Contains(t, err.Error(), tt.errorContains) assert.Contains(t, err.Error(), tt.errorContains)
} }
} else { } else {
assert.Nil(t, err, "Expected no error but got: %v", err) require.NoError(t, err, "Expected no error but got: %v", err)
assert.Equal(t, tt.expectedOutput, output) assert.Equal(t, tt.expectedOutput, output)
} }
}) })
@ -1052,10 +1056,10 @@ func TestParseCommandForReplacements(t *testing.T) {
func TestArgumentChoicesValidation(t *testing.T) { func TestArgumentChoicesValidation(t *testing.T) {
tests := []struct { tests := []struct {
name string
req *ExecutionRequest req *ExecutionRequest
expectError bool name string
description string description string
expectError bool
}{ }{
{ {
name: "Valid choice", name: "Valid choice",
@ -1136,10 +1140,10 @@ func TestArgumentChoicesValidation(t *testing.T) {
_, err := parseActionArguments(tt.req) _, err := parseActionArguments(tt.req)
if tt.expectError { if tt.expectError {
assert.NotNil(t, err, tt.description) require.Error(t, err, tt.description)
assert.Contains(t, err.Error(), "predefined choices") assert.Contains(t, err.Error(), "predefined choices")
} else { } else {
assert.Nil(t, err, tt.description) require.NoError(t, err, tt.description)
} }
}) })
} }
@ -1161,7 +1165,7 @@ func TestTypeSafetyCheckVeryDangerousRawString(t *testing.T) {
for _, value := range tests { for _, value := range tests {
t.Run(fmt.Sprintf("Value: %s", value), func(t *testing.T) { t.Run(fmt.Sprintf("Value: %s", value), func(t *testing.T) {
err := TypeSafetyCheck("test", value, "very_dangerous_raw_string") err := TypeSafetyCheck("test", value, "very_dangerous_raw_string")
assert.Nil(t, err, "very_dangerous_raw_string should accept any value including: %s", value) require.NoError(t, err, "very_dangerous_raw_string should accept any value including: %s", value)
}) })
} }
} }
@ -1186,7 +1190,7 @@ func TestParseActionArgumentsWithEntityPrefix(t *testing.T) {
// Test with entity prefix // Test with entity prefix
output, err := parseActionArguments(req) output, err := parseActionArguments(req)
assert.Nil(t, err) require.NoError(t, err)
assert.Contains(t, output, "testuser") assert.Contains(t, output, "testuser")
} }
@ -1227,9 +1231,9 @@ func TestComplexRegexPatterns(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := typeSafetyCheckRegex("test", tt.value, tt.pattern) err := typeSafetyCheckRegex("test", tt.value, tt.pattern)
if tt.hasError { if tt.hasError {
assert.NotNil(t, err) require.Error(t, err)
} else { } else {
assert.Nil(t, err) require.NoError(t, err)
} }
}) })
} }

View File

@ -15,11 +15,14 @@ import (
"bytes" "bytes"
"context" "context"
"errors"
"fmt" "fmt"
"maps"
"os" "os"
"os/exec" "os/exec"
"path" "path"
"regexp" "regexp"
"slices"
"strings" "strings"
"sync" "sync"
"time" "time"
@ -39,53 +42,44 @@ func isValidTrackingID(id string) bool {
} }
type ActionBinding struct { type ActionBinding struct {
ID string
Action *config.Action Action *config.Action
Entity *entities.Entity Entity *entities.Entity
ConfigOrder int ID string
OnDashboards []DashboardNavigationTarget OnDashboards []DashboardNavigationTarget
ConfigOrder int
} }
// Executor represents a helper class for executing commands. It's main method
// is ExecRequest
type Executor struct { type Executor struct {
logs map[string]*InternalLogEntry logs map[string]*InternalLogEntry
logsTrackingIdsByDate []string
LogsByBindingId map[string][]*InternalLogEntry LogsByBindingId map[string][]*InternalLogEntry
logmutex sync.RWMutex
MapActionBindings map[string]*ActionBinding MapActionBindings map[string]*ActionBinding
Cfg *config.Config
logsTrackingIdsByDate []string
listeners []listener
chainOfCommand []executorStepFunc
groupQueue []*queuedExecution
logmutex sync.RWMutex
MapActionBindingsLock sync.RWMutex MapActionBindingsLock sync.RWMutex
listenersMu sync.RWMutex
Cfg *config.Config groupQueueMu sync.Mutex
listeners []listener
listenersMu sync.RWMutex
chainOfCommand []executorStepFunc
groupQueue []*queuedExecution
groupQueueMu sync.Mutex
} }
// ExecutionRequest is a request to execute an action. It's passed to an // ExecutionRequest is a request to execute an action. It's passed to an
// Executor. They're created from the api. // Executor. They're created from the api.
type ExecutionRequest struct { type ExecutionRequest struct {
Binding *ActionBinding Arguments map[string]string
Arguments map[string]string Binding *ActionBinding
TrackingID string Cfg *config.Config
Tags []string AuthenticatedUser *authpublic.AuthenticatedUser
Cfg *config.Config executor *Executor
AuthenticatedUser *authpublic.AuthenticatedUser
TriggerDepth int
Justification string
logEntry *InternalLogEntry logEntry *InternalLogEntry
finalParsedCommand string finalParsedCommand string
TrackingID string
Justification string
Tags []string
execArgs []string execArgs []string
TriggerDepth int
useDirectExec bool useDirectExec bool
executor *Executor
skipRequestRegistration bool skipRequestRegistration bool
} }
@ -103,12 +97,12 @@ func (req *ExecutionRequest) mutateLogEntry(mutator func(*InternalLogEntry)) {
// LogEntrySnapshot is a copy of selected log entry fields for race-safe reads. // LogEntrySnapshot is a copy of selected log entry fields for race-safe reads.
type LogEntrySnapshot struct { type LogEntrySnapshot struct {
Output string
ExitCode int32
Queued bool Queued bool
Blocked bool Blocked bool
ExecutionStarted bool ExecutionStarted bool
ExecutionFinished bool ExecutionFinished bool
ExitCode int32
Output string
} }
// SnapshotLog returns a copy of selected log entry fields under read lock. // SnapshotLog returns a copy of selected log entry fields under read lock.
@ -135,34 +129,28 @@ func (e *Executor) SnapshotLog(trackingID string) (LogEntrySnapshot, bool) {
// state of execution (even if the command is not executed). It's designed to be // state of execution (even if the command is not executed). It's designed to be
// easily serializable. // easily serializable.
type InternalLogEntry struct { type InternalLogEntry struct {
Binding *ActionBinding
DatetimeStarted time.Time DatetimeStarted time.Time
DatetimeFinished time.Time DatetimeFinished time.Time
Output string Binding *ActionBinding
TimedOut bool
Blocked bool
Queued bool
QueuedForGroup string
ExitCode int32
Tags []string
ExecutionStarted bool
ExecutionFinished bool
ExecutionTrackingID string
Process *os.Process Process *os.Process
Arguments map[string]string
ExecutionTrackingID string
Justification string
QueuedForGroup string
ActionIcon string
ActionTitle string
ActionConfigTitle string
Output string
Username string Username string
Index int64
EntityPrefix string EntityPrefix string
ActionConfigTitle string // This is the title of the action as defined in the config, not the final parsed title. Tags []string
Index int64
/* ExitCode int32
The following 3 properties are obviously on Action normally, but it's useful Blocked bool
that logs are lightweight (so we don't need to have an action associated to ExecutionFinished bool
logs, etc. Therefore, we duplicate those values here. ExecutionStarted bool
*/ Queued bool
ActionTitle string TimedOut bool
ActionIcon string
Justification string
Arguments map[string]string
} }
// .Binding can be nil, so we need to handle that. // .Binding can be nil, so we need to handle that.
@ -939,12 +927,7 @@ func keepArgument(name string, definedNames map[string]struct{}) bool {
} }
func hasWebhookTag(req *ExecutionRequest) bool { func hasWebhookTag(req *ExecutionRequest) bool {
for _, tag := range req.Tags { return slices.Contains(req.Tags, "webhook")
if tag == "webhook" {
return true
}
}
return false
} }
var systemArgumentDefinitions = []config.ActionArgument{ var systemArgumentDefinitions = []config.ActionArgument{
@ -958,9 +941,7 @@ func injectSystemArgs(req *ExecutionRequest) error {
return err return err
} }
for name, value := range args { maps.Copy(req.Arguments, args)
req.Arguments[name] = value
}
return nil return nil
} }
@ -1097,8 +1078,8 @@ func appendErrorToStderr(req *ExecutionRequest, err error) {
type OutputStreamer struct { type OutputStreamer struct {
Req *ExecutionRequest Req *ExecutionRequest
mu sync.Mutex
output bytes.Buffer output bytes.Buffer
mu sync.Mutex
} }
func (ost *OutputStreamer) Write(o []byte) (n int, err error) { func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
@ -1186,7 +1167,7 @@ func stepExec(req *ExecutionRequest) bool {
appendErrorToStderr(req, runerr) appendErrorToStderr(req, runerr)
appendErrorToStderr(req, waiterr) appendErrorToStderr(req, waiterr)
if ctx.Err() == context.DeadlineExceeded { if errors.Is(ctx.Err(), context.DeadlineExceeded) {
log.WithFields(log.Fields{ log.WithFields(log.Fields{
"actionTitle": req.logEntry.ActionTitle, "actionTitle": req.logEntry.ActionTitle,
}).Warnf("Action timed out") }).Warnf("Action timed out")
@ -1263,7 +1244,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
appendErrorToStderr(req, runerr) appendErrorToStderr(req, runerr)
appendErrorToStderr(req, waiterr) appendErrorToStderr(req, waiterr)
if ctx.Err() == context.DeadlineExceeded { if errors.Is(ctx.Err(), context.DeadlineExceeded) {
req.mutateLogEntry(func(entry *InternalLogEntry) { req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Output += "Your shellAfterCompleted command timed out." entry.Output += "Your shellAfterCompleted command timed out."
}) })
@ -1290,23 +1271,97 @@ func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
return req.Binding.Action, true return req.Binding.Action, true
} }
// Matches legacy and modern template forms for shellAfterCompleted output/exitCode,
// including optional .Arguments. prefix and flexible whitespace. These must become
// quoted env refs before template execution so command output cannot inject into sh -c.
var (
shellAfterOutputRef = regexp.MustCompile(`\{\{\s*(?:\.Arguments\.)?output\s*\}\}`)
shellAfterExitCodeRef = regexp.MustCompile(`\{\{\s*(?:\.Arguments\.)?exitCode\s*\}\}`)
)
func substituteShellAfterCompletedEnvRefs(command string) string { func substituteShellAfterCompletedEnvRefs(command string) string {
replacements := []struct{ old, new string }{ command = replaceShellAfterEnvRef(command, shellAfterOutputRef, "$OUTPUT")
{"{{ output }}", `"$OUTPUT"`}, command = replaceShellAfterEnvRef(command, shellAfterExitCodeRef, "$EXITCODE")
{"{{output}}", `"$OUTPUT"`},
{"{{ exitCode }}", `"$EXITCODE"`},
{"{{exitCode}}", `"$EXITCODE"`},
{"{{ exitCode}}", `"$EXITCODE"`},
{"{{exitCode }}", `"$EXITCODE"`},
}
for _, replacement := range replacements {
command = strings.ReplaceAll(command, replacement.old, replacement.new)
}
return command return command
} }
func replaceShellAfterEnvRef(command string, pattern *regexp.Regexp, envRef string) string {
matches := pattern.FindAllStringIndex(command, -1)
for i := len(matches) - 1; i >= 0; i-- {
start, end := matches[i][0], matches[i][1]
replacement := `"` + envRef + `"`
if shellPosInsideSingleQuotes(command, start) {
// Break out of single quotes so the env ref can expand at runtime.
replacement = `'` + replacement + `'`
}
command = command[:start] + replacement + command[end:]
}
return command
}
func shellPosInsideSingleQuotes(command string, pos int) bool {
inSingle := false
inDouble := false
i := 0
for i < pos {
inSingle, inDouble, i = advanceShellQuoteState(command, i, pos, inSingle, inDouble)
}
return inSingle
}
func advanceShellQuoteState(command string, i, pos int, inSingle, inDouble bool) (bool, bool, int) {
if inSingle {
return advanceInsideSingleQuote(command, i, inSingle, inDouble)
}
if inDouble {
return advanceInsideDoubleQuote(command, i, pos, inSingle, inDouble)
}
return advanceOutsideQuotes(command, i, inSingle, inDouble)
}
func advanceInsideSingleQuote(command string, i int, inSingle, inDouble bool) (bool, bool, int) {
if command[i] == '\'' {
return false, inDouble, i + 1
}
return inSingle, inDouble, i + 1
}
func advanceInsideDoubleQuote(command string, i, pos int, inSingle, inDouble bool) (bool, bool, int) {
if command[i] == '\\' && i+1 < pos {
return inSingle, inDouble, i + 2
}
if command[i] == '"' {
return inSingle, false, i + 1
}
return inSingle, inDouble, i + 1
}
func advanceOutsideQuotes(command string, i int, inSingle, inDouble bool) (bool, bool, int) {
switch command[i] {
case '\'':
return true, inDouble, i + 1
case '"':
return inSingle, true, i + 1
default:
return inSingle, inDouble, i + 1
}
}
// shellAfterTemplateArgs omits output/exitCode so templates cannot expand them
// raw. Those values are only provided as OUTPUT/EXITCODE process environment.
func shellAfterTemplateArgs(args map[string]string) map[string]string {
templateArgs := make(map[string]string, len(args))
for name, value := range args {
if name == "output" || name == "exitCode" {
continue
}
templateArgs[name] = value
}
return templateArgs
}
func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) { func parseShellAfterCompletedCommand(req *ExecutionRequest, commandTemplate string, args map[string]string) (string, error) {
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(commandTemplate, req.Binding.Entity, args) finalParsedCommand, err := tpl.ParseTemplateWithActionContext(commandTemplate, req.Binding.Entity, args)
if err != nil { if err != nil {
@ -1338,7 +1393,7 @@ func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout,
} }
commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted) commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args) finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, shellAfterTemplateArgs(args))
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }

View File

@ -449,6 +449,133 @@ func TestShellAfterCompletedUsesOutputEnvSafely(t *testing.T) {
assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output") assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands from output")
} }
func TestShellAfterCompletedExpandsQuotedPlaceholders(t *testing.T) {
cases := []struct {
name string
sac string
}{
{"legacy single-quoted", `printf '%s' '{{ output }}'`},
{"modern single-quoted", `printf '%s' '{{ .Arguments.output }}'`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := config.DefaultConfig()
executor := DefaultExecutor(cfg)
mainOutput := "quoted-output-ok"
action := &config.Action{
Title: "sac-quoted-" + tc.name,
Shell: "printf %s \"" + mainOutput + "\"",
ShellAfterCompleted: tc.sac,
}
cfg.Actions = append(cfg.Actions, action)
cfg.Sanitize()
executor.RebuildActionMap()
req := ExecutionRequest{
AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
Cfg: cfg,
Binding: executor.FindBindingWithNoEntity(action),
}
wg, _ := executor.ExecRequest(&req)
wg.Wait()
require.NotNil(t, req.logEntry)
assert.Equal(t, int32(0), req.logEntry.ExitCode)
assert.Contains(t, req.logEntry.Output, "OliveTin::shellAfterCompleted stdout\n"+mainOutput)
})
}
}
func TestShellAfterCompletedBlocksArgumentsOutputInjection(t *testing.T) {
payload := func(injectedPath string) string {
return "x; touch " + injectedPath + "; #"
}
cases := []struct {
name string
sac string
}{
{"legacy", "printf %s {{ output }}"},
{"legacy compact", "printf %s {{output}}"},
{"legacy extra spaces", "printf %s {{ output }}"},
{"modern Arguments", "printf %s {{ .Arguments.output }}"},
{"modern compact", "printf %s {{.Arguments.output}}"},
{"modern exitCode still env", "printf %s {{ .Arguments.exitCode }}"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
cfg := config.DefaultConfig()
executor := DefaultExecutor(cfg)
injectedPath := filepath.Join(t.TempDir(), "injected")
mainPayload := payload(injectedPath)
action := &config.Action{
Title: "sac-injection-" + tc.name,
Shell: "printf %s \"" + mainPayload + "\"",
ShellAfterCompleted: tc.sac,
}
cfg.Actions = append(cfg.Actions, action)
cfg.Sanitize()
executor.RebuildActionMap()
req := ExecutionRequest{
AuthenticatedUser: auth.UserFromSystem(cfg, "cron"),
Cfg: cfg,
Binding: executor.FindBindingWithNoEntity(action),
}
wg, _ := executor.ExecRequest(&req)
wg.Wait()
_, err := os.Stat(injectedPath)
assert.True(t, os.IsNotExist(err), "shellAfterCompleted must not execute injected commands via %q", tc.sac)
})
}
}
func TestSubstituteShellAfterCompletedEnvRefs(t *testing.T) {
cases := []struct {
in string
want string
}{
{`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
{`printf %s {{output}}`, `printf %s "$OUTPUT"`},
{`printf %s {{ output }}`, `printf %s "$OUTPUT"`},
{`printf %s {{ .Arguments.output }}`, `printf %s "$OUTPUT"`},
{`printf %s {{.Arguments.output}}`, `printf %s "$OUTPUT"`},
{`echo {{ exitCode }}`, `echo "$EXITCODE"`},
{`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
{`echo {{ .Arguments.exitCode }}`, `echo "$EXITCODE"`},
{`printf '%s' '{{ output }}'`, `printf '%s' ''"$OUTPUT"''`},
{`printf '%s' '{{ .Arguments.output }}'`, `printf '%s' ''"$OUTPUT"''`},
{`printf '%s' '{{ exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
{`printf '%s' '{{ .Arguments.exitCode }}'`, `printf '%s' ''"$EXITCODE"''`},
}
for _, tc := range cases {
assert.Equal(t, tc.want, substituteShellAfterCompletedEnvRefs(tc.in))
}
}
func TestShellAfterTemplateArgsOmitsOutputAndExitCode(t *testing.T) {
args := map[string]string{
"output": "evil; id",
"exitCode": "1",
"ot_username": "alice",
"ot_executionTrackingId": "track-1",
}
templateArgs := shellAfterTemplateArgs(args)
assert.NotContains(t, templateArgs, "output")
assert.NotContains(t, templateArgs, "exitCode")
assert.Equal(t, "alice", templateArgs["ot_username"])
assert.Equal(t, "track-1", templateArgs["ot_executionTrackingId"])
assert.Equal(t, "evil; id", args["output"], "env args map must keep output for OUTPUT=")
}
func TestFilterToDefinedArgumentsOnly(t *testing.T) { func TestFilterToDefinedArgumentsOnly(t *testing.T) {
req := newExecRequest() req := newExecRequest()
req.Binding.Action = &config.Action{ req.Binding.Action = &config.Action{

View File

@ -1,5 +1,4 @@
//go:build !windows //go:build !windows
// +build !windows
package executor package executor

View File

@ -2,6 +2,7 @@ package executor
import ( import (
"context" "context"
"errors"
"os" "os"
"sync" "sync"
"time" "time"
@ -55,7 +56,7 @@ func (tc *timeoutContext) setProcess(process *os.Process) {
tc.processMu.Unlock() tc.processMu.Unlock()
// If deadline already expired before process was set, kill now // If deadline already expired before process was set, kill now
if tc.Err() == context.DeadlineExceeded && process != nil { if errors.Is(tc.Err(), context.DeadlineExceeded) && process != nil {
logEntry := &InternalLogEntry{Process: process} logEntry := &InternalLogEntry{Process: process}
if err := tc.executor.Kill(logEntry); err != nil { if err := tc.executor.Kill(logEntry); err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@ -39,12 +39,12 @@ type WatchMeta struct {
} }
type watchContext struct { type watchContext struct {
filename string
filedir string
callback func(filename string) callback func(filename string)
interestedEvent fsnotify.Op
event *fsnotify.Event event *fsnotify.Event
meta WatchMeta meta WatchMeta
filename string
filedir string
interestedEvent fsnotify.Op
} }
func WatchDirectoryCreate(fullpath string, callback func(filename string), meta WatchMeta) { func WatchDirectoryCreate(fullpath string, callback func(filename string), meta WatchMeta) {

View File

@ -14,6 +14,7 @@ import (
"net/url" "net/url"
"path" "path"
"strings" "strings"
"time"
"github.com/OliveTin/OliveTin/internal/api" "github.com/OliveTin/OliveTin/internal/api"
"github.com/OliveTin/OliveTin/internal/auth" "github.com/OliveTin/OliveTin/internal/auth"
@ -153,8 +154,12 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
} }
srv := &http.Server{ srv := &http.Server{
Addr: cfg.ListenAddressSingleHTTPFrontend, Addr: cfg.ListenAddressSingleHTTPFrontend,
Handler: securityHeadersMiddleware(cfg, mux), Handler: securityHeadersMiddleware(cfg, mux),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
// WriteTimeout intentionally unset: EventStream and StartActionAndWait need long-lived writes.
} }
log.Fatal(srv.ListenAndServe()) log.Fatal(srv.ListenAndServe())

View File

@ -2,6 +2,7 @@ package httpservers
import ( import (
"net/http" "net/http"
"time"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
"github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus"
@ -19,8 +20,19 @@ func StartPrometheus(cfg *config.Config) {
prometheus.Unregister(collectors.NewGoCollector()) prometheus.Unregister(collectors.NewGoCollector())
} }
http.Handle("/", promhttp.Handler()) mux := http.NewServeMux()
err := http.ListenAndServe(cfg.ListenAddressPrometheus, nil) mux.Handle("/", promhttp.Handler())
srv := &http.Server{
Addr: cfg.ListenAddressPrometheus,
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
err := srv.ListenAndServe()
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@ -35,18 +35,16 @@ func NewWebUIServer(cfg *config.Config) *webUIServer {
} }
func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) { func (s *webUIServer) handleWebui(w http.ResponseWriter, r *http.Request) {
// dirName := path.Dir(r.URL.Path)
// Mangle requests for any path like /logs or /config to load the webui index.html // Mangle requests for any path like /logs or /config to load the webui index.html
if path.Ext(r.URL.Path) == "" && r.URL.Path != "/" { if path.Ext(r.URL.Path) == "" && r.URL.Path != "/" {
log.Debugf("Mangling request for %s to /index.html", r.URL.Path) log.Debugf("Mangling request for %s to /index.html", r.URL.Path)
http.ServeFile(w, r, path.Join(s.webuiDir, "index.html")) http.ServeFile(w, r, path.Join(s.webuiDir, "index.html"))
} else { return
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)
} }
log.Tracef("Serving webui from %s for %s", s.webuiDir, r.URL.Path)
// http.Dir rejects path traversal; do not Join raw URL paths into ServeFile.
http.FileServer(http.Dir(s.webuiDir)).ServeHTTP(w, r)
} }
func (s *webUIServer) findWebuiDir() string { func (s *webUIServer) findWebuiDir() string {
@ -84,7 +82,7 @@ func (s *webUIServer) findCustomWebuiDir() string {
func (s *webUIServer) setupCustomWebuiDir() { func (s *webUIServer) setupCustomWebuiDir() {
dir := s.findCustomWebuiDir() dir := s.findCustomWebuiDir()
err := os.MkdirAll(path.Join(dir, "themes/"), 0775) err := os.MkdirAll(path.Join(dir, "themes/"), 0o750)
if err != nil { if err != nil {
log.Warnf("Could not create themes directory: %v", err) log.Warnf("Could not create themes directory: %v", err)

View File

@ -16,7 +16,6 @@ type RuntimeInfo struct {
OS string OS string
OSReleasePrettyName string OSReleasePrettyName string
Arch string Arch string
InContainer bool
LastBrowserUserAgent string LastBrowserUserAgent string
User string User string
Uid string Uid string
@ -25,6 +24,7 @@ type RuntimeInfo struct {
AvailableVersion string AvailableVersion string
WebuiDirectory string WebuiDirectory string
ThemesDirectory string ThemesDirectory string
InContainer bool
} }
var Runtime = &RuntimeInfo{ var Runtime = &RuntimeInfo{

View File

@ -13,8 +13,6 @@ var (
) )
type serverDiagnosticsConfig struct { type serverDiagnosticsConfig struct {
CountOfActions int
CountOfDashboards int
LogLevel string LogLevel string
ListenAddressSingleHTTPFrontend string ListenAddressSingleHTTPFrontend string
ListenAddressWebUI string ListenAddressWebUI string
@ -23,6 +21,8 @@ type serverDiagnosticsConfig struct {
TimeNow string TimeNow string
ConfigDirectory string ConfigDirectory string
WebuiDirectory string WebuiDirectory string
CountOfActions int
CountOfDashboards int
} }
func configToServerDiagnostics(cfg *config.Config) *serverDiagnosticsConfig { func configToServerDiagnostics(cfg *config.Config) *serverDiagnosticsConfig {

View File

@ -5,12 +5,12 @@ type Record struct {
Status string Status string
Action string Action string
User string User string
Output string
Tags []string Tags []string
ExitCode int32
Blocked bool Blocked bool
TimedOut bool TimedOut bool
Running bool Running bool
ExitCode int32
Output string
} }
// StatusLabel matches the status text shown in the web UI. // StatusLabel matches the status text shown in the web UI.

View File

@ -13,7 +13,7 @@ func TestResolveLogDirectory(t *testing.T) {
baseDir := filepath.Join(t.TempDir(), "OliveTin") baseDir := filepath.Join(t.TempDir(), "OliveTin")
absoluteDir := t.TempDir() absoluteDir := t.TempDir()
assert.Equal(t, "", resolveLogDirectory("", baseDir)) assert.Empty(t, resolveLogDirectory("", baseDir))
assert.Equal(t, absoluteDir, resolveLogDirectory(absoluteDir, baseDir)) assert.Equal(t, absoluteDir, resolveLogDirectory(absoluteDir, baseDir))
assert.Equal(t, filepath.Join(baseDir, "logs", "service"), resolveLogDirectory("./logs/service", baseDir)) assert.Equal(t, filepath.Join(baseDir, "logs", "service"), resolveLogDirectory("./logs/service", baseDir))
assert.Equal(t, "logs/service", resolveLogDirectory("logs/service", "")) assert.Equal(t, "logs/service", resolveLogDirectory("logs/service", ""))

View File

@ -1,5 +1,4 @@
//go:build !windows //go:build !windows
// +build !windows
package servicehost package servicehost

View File

@ -44,7 +44,7 @@ type generalTemplateContext struct {
} }
type actionTemplateContext struct { type actionTemplateContext struct {
CurrentEntity interface{} CurrentEntity any
Arguments map[string]string Arguments map[string]string
// These are deliberately repeated because embedding structs // These are deliberately repeated because embedding structs

View File

@ -1,6 +1,7 @@
package updatecheck package updatecheck
import ( import (
"context"
"encoding/json" "encoding/json"
"github.com/Masterminds/semver" "github.com/Masterminds/semver"
config "github.com/OliveTin/OliveTin/internal/config" config "github.com/OliveTin/OliveTin/internal/config"
@ -10,12 +11,13 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"time"
) )
type versionMapType struct { type versionMapType struct {
ApiVersion int
Latest string
History map[string]string History map[string]string
Latest string
ApiVersion int
} }
// StartUpdateChecker will start a job that runs periodically, checking // StartUpdateChecker will start a job that runs periodically, checking
@ -84,7 +86,11 @@ func parseIfVersionIsLater(currentString string, latestString string) string {
} }
func doRequest() string { func doRequest() string {
req, err := http.NewRequest("GET", "http://update-check.olivetin.app/versions.json", nil) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://update-check.olivetin.app/versions.json", nil)
if err != nil { if err != nil {
log.Errorf("Update check failed %v", err) log.Errorf("Update check failed %v", err)

View File

@ -8,11 +8,11 @@ import (
) )
type JSONMatcher struct { type JSONMatcher struct {
payload interface{} payload any
} }
func NewJSONMatcher(payload []byte) (*JSONMatcher, error) { func NewJSONMatcher(payload []byte) (*JSONMatcher, error) {
var data interface{} var data any
if err := json.Unmarshal(payload, &data); err != nil { if err := json.Unmarshal(payload, &data); err != nil {
return nil, err return nil, err
} }
@ -60,6 +60,6 @@ func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
return string(jsonBytes), nil return string(jsonBytes), nil
} }
func (m *JSONMatcher) GetPayload() interface{} { func (m *JSONMatcher) GetPayload() any {
return m.payload return m.payload
} }

View File

@ -123,8 +123,7 @@ func (m *WebhookMatcher) matchPathValue(matcher *JSONMatcher, jsonPath, expected
} }
func (m *WebhookMatcher) compareValues(actual, expected string) bool { func (m *WebhookMatcher) compareValues(actual, expected string) bool {
if strings.HasPrefix(expected, "regex:") { if pattern, hasRegex := strings.CutPrefix(expected, "regex:"); hasRegex {
pattern := strings.TrimPrefix(expected, "regex:")
matched, err := regexp.MatchString(pattern, actual) matched, err := regexp.MatchString(pattern, actual)
if err != nil { if err != nil {
log.WithFields(log.Fields{ log.WithFields(log.Fields{

View File

@ -12,7 +12,7 @@ import (
func TestExtractJustificationFromWebhookBody(t *testing.T) { func TestExtractJustificationFromWebhookBody(t *testing.T) {
body := []byte(`{"message":"deploy production","repo":"my-app"}`) body := []byte(`{"message":"deploy production","repo":"my-app"}`)
req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil) req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "/webhooks/deploy", nil)
require.NoError(t, err) require.NoError(t, err)
matcher := NewWebhookMatcher(config.WebhookConfig{ matcher := NewWebhookMatcher(config.WebhookConfig{
@ -25,7 +25,7 @@ func TestExtractJustificationFromWebhookBody(t *testing.T) {
} }
func TestExtractJustificationEmptyWhenNotConfigured(t *testing.T) { func TestExtractJustificationEmptyWhenNotConfigured(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "/webhooks/deploy", nil) req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "/webhooks/deploy", nil)
require.NoError(t, err) require.NoError(t, err)
matcher := NewWebhookMatcher(config.WebhookConfig{}, req, []byte(`{}`)) matcher := NewWebhookMatcher(config.WebhookConfig{}, req, []byte(`{}`))

View File

@ -158,7 +158,7 @@ func configPathExists(configPath string) bool {
} }
func watchConfigFile(k *koanf.Koanf, f *file.File, configPath string) { func watchConfigFile(k *koanf.Koanf, f *file.File, configPath string) {
err := f.Watch(func(evt interface{}, err error) { err := f.Watch(func(evt any, err error) {
log.Infof("config file changed: %v", evt) log.Infof("config file changed: %v", evt)
errLoad := k.Load(f, yaml.Parser()) errLoad := k.Load(f, yaml.Parser())

View File

@ -2,7 +2,9 @@ package main
import ( import (
"bufio" "bufio"
"context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -41,20 +43,20 @@ type runSummary struct {
} }
type jsonlRecord struct { type jsonlRecord struct {
Run int `json:"run"`
Timestamp string `json:"timestamp"` Timestamp string `json:"timestamp"`
FailureDetails []testFailure `json:"failureDetails"`
Run int `json:"run"`
ExitCode int `json:"exitCode"` ExitCode int `json:"exitCode"`
DurationMs int64 `json:"durationMs"` DurationMs int64 `json:"durationMs"`
Passes int `json:"passes"` Passes int `json:"passes"`
Failures int `json:"failures"` Failures int `json:"failures"`
Skipped int `json:"skipped"` Skipped int `json:"skipped"`
FailureDetails []testFailure `json:"failureDetails"`
} }
type testRunState struct { type testRunState struct {
summary runSummary
failures []testFailure
failureOutput map[string]*strings.Builder failureOutput map[string]*strings.Builder
failures []testFailure
summary runSummary
} }
func initLog() { func initLog() {
@ -312,9 +314,9 @@ func scanTestEvents(stdout io.Reader, state *testRunState) error {
func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []testFailure, error) { func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []testFailure, error) {
if err := cmd.Wait(); err != nil { if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok { if errExit, ok := errors.AsType[*exec.ExitError](err); ok {
state.finalizeFailureOutputs() state.finalizeFailureOutputs()
return exitErr.ExitCode(), state.summary, state.failures, nil return errExit.ExitCode(), state.summary, state.failures, nil
} }
return 1, state.summary, state.failures, err return 1, state.summary, state.failures, err
} }
@ -322,7 +324,11 @@ func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []t
} }
func runTestsOnce(rootDir string) (int, runSummary, []testFailure, error) { func runTestsOnce(rootDir string) (int, runSummary, []testFailure, error) {
cmd := exec.Command("go", "test", "./...", "-count=1", "-json") ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, "go", "test", "./...", "-count=1", "-json")
cmd.Dir = rootDir cmd.Dir = rootDir
stdout, err := cmd.StdoutPipe() stdout, err := cmd.StdoutPipe()

50
specs/config-issues.md Normal file
View File

@ -0,0 +1,50 @@
# Configuration issues
This spec describes how OliveTin collects configuration warnings and errors and surfaces them in the web UI.
## Purpose
Operators should see configuration problems in Diagnostics instead of only in server logs. When any issues exist, the Diagnostics navigation link shows a count badge.
## When issues are rebuilt
The issue list is cleared and rebuilt when configuration is loaded or reloaded, and when entity data changes. Some findings that can only be detected while configuration is first being loaded (for example references to unset environment variables that are expanded away during that load) are kept across later rebuilds until the next configuration load begins.
## What is collected
Issues include:
- Unknown or unenforced action group references
- Checklist arguments with missing or invalid choice templates
- Arguments whose type was left unset (defaulted to a generic text type)
- Unset environment variables referenced from configuration
- Missing or invalid include directories
- Argument default or choice templates that fail to parse
- Literal argument defaults that fail type validation (templated defaults are not type-checked as raw text)
- Entity files that cannot be read or parsed, or are empty
- Entity-bound actions with no entity instances (after OliveTin has attempted to load that entity type, so startup does not report a false positive before entity files are read)
- Invalid cron schedules
- Entity-bound actions that also use scheduled cron execution (cron runs without an entity binding and will not execute)
- Filesystem watch paths that cannot be created (missing directories for file-in-dir triggers, calendar files, or entity files). Runtime watcher setup failures for action triggers include the related action so view permissions still apply; entity-file watchers without an action remain visible to anyone who may view Diagnostics.
Each issue has a severity of warning or error, a stable code, a human-readable message, and optional context such as action title, argument name, configuration source file, or detail value.
When the issue list is rebuilt, OliveTin logs only newly appeared issues so startup does not repeat the same warning for every rebuild.
When configuration is loaded from a base file and an include directory, OliveTin records which file defined each action and entity declaration. That path is shown as the configuration source file when available. Some issues (for example unset environment variables) may not have a specific file. Entity data file problems also show the entity data path in the detail column.
## Diagnostics page
Users who are allowed to view Diagnostics see a Configuration issues section listing the current issues in a table. When there are none, the section states that no configuration issues were detected.
Action-scoped issues are only included when the user is allowed to view that action. Issues that are not tied to an action (for example unset environment variables or missing include directories) remain visible to anyone who may view Diagnostics.
Users who are not allowed to view Diagnostics cannot retrieve the issue list.
## Navigation count
When Diagnostics is visible and at least one configuration issue exists that the user is allowed to see, the Diagnostics navigation link shows a count badge with the number of those issues. The badge clears when the visible issue count becomes zero after a configuration or entity refresh.
## Startup count
When the web UI starts, users who may view Diagnostics receive the same filtered configuration issue count used for the Diagnostics list and navigation badge. For other users the count is zero.

View File

@ -0,0 +1,35 @@
# Spec: Dashboard navigation categories
This spec describes how root dashboards can be grouped into categories in the sidebar navigation.
---
## 1. Configuration
Root dashboard entries in the configuration may include an optional category label.
- The category applies only to root dashboards (top-level items in the dashboards list). Nested dashboard contents ignore category.
- If category is omitted or empty, the dashboard is uncategorized.
- Dashboards that share the same category label are grouped together under that label in the sidebar.
## 2. Visibility
Only dashboards the current user is allowed to view appear in navigation.
- Access-denied dashboards are omitted from the list and do not create empty category sections.
- If every dashboard in a category is hidden, that category does not appear.
## 3. Sidebar ordering
When building the sidebar:
1. Uncategorized dashboards appear first, as a flat list above any category sections, in configuration order among visible uncategorized dashboards.
2. Category sections follow, in the order each category first appears among visible categorized dashboards.
3. Within a category, dashboards keep the order they appear in the configuration among visible dashboards in that category.
4. After all dashboard links, a **System** category lists Entities, Logs, and Diagnostics (each only when the user is allowed to see that item). If none of those links are visible, the System category is omitted.
The default Actions dashboard, when present, is always uncategorized.
## 4. Navigation style
Category sections apply when section navigation uses the sidebar. Top-bar navigation does not show category section headers; dashboard links still appear in the same relative order without collapsible category groups.