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 codestyle` 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.
- [ ] 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
run: make -w service-unittests
- name: frontend unit tests
run: make -w frontend-unittests
- name: build service
run: make -w service

View File

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

View File

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

View File

@ -23,6 +23,9 @@ service-codestyle:
frontend-codestyle:
$(MAKE) -wC frontend codestyle
frontend-unittests:
$(MAKE) -wC frontend unittests
it:
$(MAKE) -wC integration-tests
@ -76,4 +79,4 @@ config-tool:
devcheck:
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 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
# Choose from INFO (default), WARN and DEBUG
@ -73,7 +74,7 @@ actions:
icon: backup
onclick: execution-dialog
# https://docs.olivetin.app/action_execution/oncalendar.html
execOnCalendarFile: examples/demo-olivetin-calendar.yaml
# execOnCalendarFile: examples/demo-olivetin-calendar.yaml
- title: Verify backup archive
shell: sleep 3 && echo "Backup archive verified"
@ -145,7 +146,7 @@ actions:
title: Are you sure?!
# 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
- title: Backup selected directories
@ -165,7 +166,7 @@ actions:
value: music
- title: Videos
value: videos
default: documents,photos
default: '["documents","photos"]'
# 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.
@ -262,8 +263,8 @@ actions:
icon: ping
# https://docs.olivetin.app/action_execution/onfilecreated.html
# mkdir -p /tmp/olivetin-demo-file-created
execOnFileCreatedInDir:
- /tmp/olivetin-demo-file-created
# execOnFileCreatedInDir:
# - /tmp/olivetin-demo-file-created
- title: Start {{ .CurrentEntity.Names }}
icon: box
@ -352,8 +353,10 @@ actionGroups:
#
# Docs: https://docs.olivetin.app/dashboards/intro.html
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
category: Infrastructure
contents:
- title: All Servers
type: fieldset
@ -399,8 +402,10 @@ dashboards:
contents:
- 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
category: Infrastructure
contents:
- title: 'Container {{ .CurrentEntity.Names }} ({{ .CurrentEntity.Image }})'
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 }})\""
----
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.
* `{{ 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.
* `{{ 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 }}` / `{{ .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_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.
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
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]
.`config.yaml`
@ -15,7 +15,8 @@ To configure an action to run on a webhook, add the `execOnWebhook` property to
actions:
- title: Deploy Application
id: deploy
shell: /opt/scripts/deploy.sh
exec:
- /opt/scripts/deploy.sh
execOnWebhook:
- matchHeaders:
X-Event-Type: deploy
@ -51,7 +52,9 @@ Match webhooks based on HTTP header values:
----
actions:
- title: Process Event
shell: echo "Processing event"
exec:
- echo
- "Processing event"
execOnWebhook:
- matchHeaders:
X-Event-Type: my-event
@ -68,7 +71,9 @@ Match webhooks based on URL query parameters:
----
actions:
- title: Process Request
shell: echo "Processing request for {{ service }}"
exec:
- echo
- "Processing request for {{ service }}"
arguments:
- name: service
type: ascii
@ -76,9 +81,11 @@ actions:
- matchQuery:
action: deploy
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
@ -88,12 +95,16 @@ Match webhooks based on values in the JSON request body using JSONPath expressio
----
actions:
- title: Handle Push Event
shell: echo "Push to {{ branch }}"
exec:
- echo
- "Push to {{ branch }}"
arguments:
- name: branch
type: ascii
execOnWebhook:
- 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:
@ -112,7 +123,9 @@ Header and query parameter values can use regex patterns by prefixing with `rege
----
actions:
- title: Handle Multiple Events
shell: echo "Handling event"
exec:
- echo
- "Handling event"
execOnWebhook:
- matchHeaders:
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:
- title: Production Deploy
shell: /opt/scripts/deploy.sh production
exec:
- /opt/scripts/deploy.sh
- production
execOnWebhook:
- matchHeaders:
X-Event-Type: deploy
@ -143,9 +158,10 @@ You can extract values from the webhook payload and pass them as arguments to yo
----
actions:
- title: Deploy Version
shell: |
echo "Deploying version {{ version }} to {{ environment }}"
/opt/scripts/deploy.sh "{{ version }}" "{{ environment }}"
exec:
- /opt/scripts/deploy.sh
- "{{ version }}"
- "{{ environment }}"
arguments:
- name: version
type: ascii
@ -176,7 +192,9 @@ For example, to access the `X-Request-Id` header in your action:
----
actions:
- title: Log Request
shell: echo "Request ID: {{ webhook_header_x-request-id }}"
exec:
- echo
- 'Request ID: {{ index .Arguments "webhook_header_x-request-id" }}'
arguments:
- name: webhook_header_x-request-id
type: ascii
@ -185,6 +203,8 @@ actions:
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
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:
- title: Deploy
shell: /opt/scripts/deploy.sh
exec:
- /opt/scripts/deploy.sh
execOnWebhook:
- matchHeaders:
X-Event-Type: deploy-manual

View File

@ -1,13 +1,25 @@
= 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 "|").
* **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).

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` 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]).

View File

@ -67,6 +67,8 @@ showNavigateOnStartIcons: false
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
`sectionNavigationStyle: topbar` looks like this;

View File

@ -32,6 +32,8 @@ actions:
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;
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.
Define `choices` for the checked/unchecked values. A checkbox **without** choices is not allowed with `shell:` (use `exec:` instead).
[source,yaml]
----
actions:

View File

@ -1,7 +1,7 @@
[#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]
----
@ -53,7 +53,7 @@ arguments:
== 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.
@ -80,4 +80,4 @@ entities:
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.
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]
.`config.yaml`
----
actions:
- title: Save text to file
shell: echo "$CONTENT" > file
exec:
- /bin/sh
- -c
- echo "$CONTENT" > file
arguments:
- type: raw_string_multiline
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;
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)
* 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?
Now that you understand how arguments work, explore the different argument types and features:

View File

@ -3,7 +3,7 @@
Sometimes you want to mask the input you pass, and a password field is useful for this.
[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]
.`config.yaml`
@ -11,9 +11,10 @@ Passwords are passed to the OliveTin server in cleartext (unless you're using HT
actions:
- title: echo a message
icon: smile
shell: echo {{ my_password }}
exec:
- echo
- "{{ my_password }}"
arguments:
- name: my_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.
Custom `regex:...` argument types **cannot** be used with `shell:`. Use `exec:` instead (see xref:action_execution/shellvsexec.adoc[Shell vs Exec]).
[source,yaml]
.`config.yaml`
----
actions:
- title: echo a message
icon: smile
shell: echo "{{ message }}"
exec:
- echo
- "{{ message }}"
arguments:
- name: message
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 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.

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])
* `{{ .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 }}`).
[#json-encoding]

View File

@ -7,29 +7,45 @@ A full list of argument types are below;
[%header,cols="1,0,2"]
|===
| 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.
| ascii | xref:args/input.adoc[Textbox] | a-z (case insensitive), 0-9, but no spaces or punctuation
| ascii_identifier | xref:args/input.adoc[Textbox] | Like a DNS name, a-Z (case insensitive), 0-9, `-`, `.`, and `_`.
| (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`, `A-Z`, `0-9` only. No spaces or punctuation.
| 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.
| 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 `,`.
| 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.
| email | xref:args/input.adoc[Textbox] | An email address.
| password | xref:args/password.adoc[Password] | A password, which is hidden when typed.
| 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.
| regex:... | xref:args/input.adoc[Textbox] | Version 2024.03.081 and above support custom regex patterns. See xref:args/regex.adoc[Custom regex arguments].
| int | xref:args/input.adoc[Textbox] | Any number, made up of the characters 0 to 9. 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.
| ascii_sentence | xref:args/input.adoc[Textbox] | `a-z`, `A-Z`, `0-9`, spaces, `.`, `,`, `-`, and `_`.
| 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 (parsed with Go's `mail.ParseAddress`).
| 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. **Not allowed with `shell:`** — use `exec:`.
| 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] | Digits `0-9` only. Negative numbers are not supported.
| 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`.
| checklist | xref:args/input_checklist.adoc[Checklist] | Multiple checkboxes from predefined choices. Selected values are passed as a comma-separated string.
| n/a, but `choices` used | xref:args/input_dropdown.adoc[Dropdown] | A "hidden" argument that makes the action require a confirmation before launching.
| raw_string_multiline | xref:args/input_textarea.adoc[Textarea] | Anything. This is **dangerous**, as effectively people can type anything they like
| 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.
| 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"]`).
| 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]
.Security risk: URL argument type
====
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.
[#shell-blocked-arg-types]
== Types that cannot be used with `shell:`
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'
----
[#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?
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 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.
[source,yaml]

View File

@ -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].
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;
@ -55,5 +55,3 @@ Restart=always
[Install]
WantedBy=multi-user.target
----

View File

@ -51,6 +51,12 @@ Below is a detailed reference table.
| `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
* xref:reference/multiple_instances[Running Multiple instances of OliveTin on the same server]

View File

@ -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;
[source,bash]
[source,yaml]
----
actions:
- title: Get OliveTin Theme
shell: olivetin-get-theme {{ themeGitRepo }} {{ themeFolderName }}
exec:
- olivetin-get-theme
- "{{ themeGitRepo }}"
- "{{ themeFolderName }}"
icon: theme
arguments:
- name: themeGitRepo
@ -93,4 +96,3 @@ body {
Profit.
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 stylelint style.css
unittests: deps
npm test
clean:
$(call delete-files,dist)
@ -18,4 +21,4 @@ build:
dist: deps clean build
.PHONY: codestyle
.PHONY: codestyle unittests

View File

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

View File

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

View File

@ -1890,6 +1890,11 @@ export declare type InitResponse = Message<"olivetin.api.v1.InitResponse"> & {
* @generated from field: int32 config_issue_count = 26;
*/
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>;
/**
* @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
*/

File diff suppressed because one or more lines are too long

View File

@ -338,40 +338,110 @@ function updateHeaderFromInit () {
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 () {
if (!navigation.value) {
return
}
const rootDashboards = window.initResponse?.rootDashboards || []
if (typeof navigation.value.clearNavigationLinks === 'function') {
navigation.value.clearNavigationLinks()
}
for (const rootDashboard of rootDashboards) {
navigation.value.addNavigationLink({
id: rootDashboard,
name: rootDashboard,
title: rootDashboard,
path: rootDashboard === 'Actions' ? '/' : `/dashboards/${rootDashboard}`,
icon: DashboardSquare01Icon
})
const entries = getRootDashboardEntries()
const uncategorized = entries.filter((entry) => !entry.category.trim())
const categorized = entries.filter((entry) => entry.category.trim())
for (const entry of uncategorized) {
addDashboardNavLink(entry.title)
}
navigation.value.addSeparator()
navigation.value.addRouterLink('Entities', t('nav.entities'))
addCategorizedDashboardLinks(categorized)
addSystemNavLinks()
}
function addSystemNavLinks () {
const systemLinks = []
systemLinks.push({
routeName: 'Entities',
title: t('nav.entities')
})
if (showLogs.value) {
navigation.value.addRouterLink('Logs', t('nav.logs'))
systemLinks.push({
routeName: 'Logs',
title: t('nav.logs')
})
}
if (showDiagnostics.value) {
const issueCount = window.initResponse?.configIssueCount || 0
navigation.value.addRouterLink('Diagnostics', t('nav.diagnostics'), {
count: issueCount
systemLinks.push({
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 () {

View File

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

View File

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

View File

@ -3,7 +3,9 @@
class="display"
:class="component.cssClass"
>
<!-- eslint-disable vue/no-v-html -- intentional: type display titles are trusted config HTML (hackable dashboards) -->
<div v-html="component.title" />
<!-- eslint-enable vue/no-v-html -->
</div>
</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>
<span class="section-title-with-icon">
Start action:
<router-link
:to="`/action/${bindingId}`"
class="action-details-title-link"
>
<ActionIconGlyph
v-if="icon"
class="action-title-icon"
:glyph="icon"
/>
{{ title }}
</router-link>
<ActionIconGlyph
v-if="icon"
class="action-title-icon"
:glyph="icon"
/>
{{ title }}
</span>
</h2>
</div>
@ -94,10 +89,12 @@
@change="handleChange(arg, $event)"
/>
<!-- eslint-disable vue/no-v-html -- intentional: argument description is documented as raw HTML -->
<span
class="argument-description"
v-html="arg.description"
/>
<!-- eslint-enable vue/no-v-html -->
</template>
</template>
@ -684,18 +681,6 @@ onUnmounted(() => {
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 {
grid-template-columns: max-content auto auto;
}

View File

@ -125,11 +125,11 @@ const browserInfoCopied = ref(false)
const configIssueHeaders = computed(() => [
{ 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: 'message', label: t('diagnostics.config-issue-message'), sortable: false },
{ 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: 'configFile', label: t('diagnostics.config-issue-config-file'), sortable: true, width: '14rem' },
{ key: 'source', label: t('diagnostics.config-issue-source'), sortable: false, width: '12rem' }
])

View File

@ -58,7 +58,7 @@
>
<dl class="fg1">
<dt>Duration</dt>
<dd><span v-html="duration" /></dd>
<dd>{{ duration }}</dd>
<dt>Status</dt>
<dd class="execution-dialog-status">
@ -373,7 +373,7 @@ function updateDuration (logEntryParam) {
} catch (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 !== '') {
duration.value += ' (' + delta + ')'
}

View File

@ -5,7 +5,7 @@
>
<template #toolbar>
<router-link
to="/logs"
:to="logsListLocation"
class="button neutral"
>
<svg
@ -44,6 +44,7 @@ import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import Calendar from 'picocrank/vue/components/Calendar.vue'
import Section from 'picocrank/vue/components/Section.vue'
import { loadStoredLogsFilter } from '../utils/logsFilterStorage.js'
const router = useRouter()
const { t } = useI18n()
@ -54,6 +55,11 @@ const error = ref(null)
const currentMonthIndex = ref(new Date().getMonth())
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
const calendarEvents = computed(() => {
return logs.value
@ -147,7 +153,13 @@ function handleDayClick (date) {
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
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) {

View File

@ -221,12 +221,24 @@ import ActionStatusDisplay from '../components/ActionStatusDisplay.vue'
import ActionIconGlyph from '../components/ActionIconGlyph.vue'
import LogActionTitle from '../components/LogActionTitle.vue'
import { getExecutionLogEntry, updateLogEntryInList } from '../utils/executionLogEvents.js'
import { loadStoredLogsFilter, storeLogsFilter } from '../utils/logsFilterStorage.js'
const route = useRoute()
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 searchText = ref('')
const searchText = ref(readInitialFilter())
const pageSize = ref(10)
const currentPage = ref(1)
const loading = ref(false)
@ -260,11 +272,37 @@ watch(() => route.query.date, () => {
updateDateFromRoute()
})
watch(searchText, () => {
watch(searchText, (value) => {
currentPage.value = 1
storeLogsFilter(value)
syncFilterToRoute(value)
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 () {
loading.value = true
filterError.value = ''

View File

@ -167,7 +167,10 @@ export async function openSidebar() {
}
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -7,17 +7,138 @@ run:
linters:
default: none
enable:
- bidichk
- bodyclose
- copyloopvar
- durationcheck
- errcheck
- errorlint
- gocritic
- gocyclo
- gosec
- govet
- ineffassign
- misspell
# - modernize
- nilerr
- noctx
# - promlinter
- staticcheck
# - testifylint
- thelper
- unconvert
# - unparam
- unused
- usestdlibvars
settings:
gocyclo:
min-complexity: 5
gosec:
# Full gosec rule set (G101–G6xx), including Slowloris checks G112/G114.
enable-all-rules: true
govet:
enable-all: true
exclusions:
paths:
- 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 (
"flag"
"fmt"
"maps"
"os"
"path/filepath"
"strconv"
@ -98,18 +99,18 @@ func userDisplayName(username string, index int) string {
return username
}
func copyUserMapWithPassword(userMap map[string]interface{}, hashedPassword string) map[string]interface{} {
newUserMap := make(map[string]interface{}, len(userMap)+1)
for key, value := range userMap {
newUserMap[key] = value
}
func copyUserMapWithPassword(userMap map[string]any, hashedPassword string) map[string]any {
newUserMap := make(map[string]any, len(userMap)+1)
maps.Copy(newUserMap, userMap)
newUserMap["password"] = hashedPassword
return newUserMap
}
func resetPasswordInUserMap(userValue interface{}, index int, hashedPassword string) interface{} {
userMap, ok := userValue.(map[string]interface{})
func resetPasswordInUserMap(userValue any, index int, hashedPassword string) any {
userMap, ok := userValue.(map[string]any)
if !ok {
log.Warnf("User entry at index %d is not a map, skipping", index)
return userValue
@ -122,8 +123,8 @@ func resetPasswordInUserMap(userValue interface{}, index int, hashedPassword str
return copyUserMapWithPassword(userMap, hashedPassword)
}
func resetPasswordsFromSlice(k *koanf.Koanf, usersSliceTyped []interface{}, hashedPassword string) {
newUsersSlice := make([]interface{}, len(usersSliceTyped))
func resetPasswordsFromSlice(k *koanf.Koanf, usersSliceTyped []any, hashedPassword string) {
newUsersSlice := make([]any, len(usersSliceTyped))
for index, userValue := range usersSliceTyped {
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) {
usersSliceTyped, ok := k.Get("authLocalUsers.users").([]interface{})
usersSliceTyped, ok := k.Get("authLocalUsers.users").([]any)
if ok && len(usersSliceTyped) > 0 {
resetPasswordsFromSlice(k, usersSliceTyped, hashedPassword)
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
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"`
RootDashboardEntries []*RootDashboard `protobuf:"bytes,27,rep,name=root_dashboard_entries,json=rootDashboardEntries,proto3" json:"root_dashboard_entries,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@ -4311,6 +4312,65 @@ func (x *InitResponse) GetConfigIssueCount() int32 {
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 {
state protoimpl.MessageState `protogen:"open.v1"`
Title string `protobuf:"bytes,1,opt,name=title,proto3" json:"title,omitempty"`
@ -4321,7 +4381,7 @@ type AdditionalLink struct {
func (x *AdditionalLink) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4333,7 +4393,7 @@ func (x *AdditionalLink) String() string {
func (*AdditionalLink) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4346,7 +4406,7 @@ func (x *AdditionalLink) ProtoReflect() protoreflect.Message {
// Deprecated: Use AdditionalLink.ProtoReflect.Descriptor instead.
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 {
@ -4374,7 +4434,7 @@ type OAuth2Provider struct {
func (x *OAuth2Provider) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4386,7 +4446,7 @@ func (x *OAuth2Provider) String() string {
func (*OAuth2Provider) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4399,7 +4459,7 @@ func (x *OAuth2Provider) ProtoReflect() protoreflect.Message {
// Deprecated: Use OAuth2Provider.ProtoReflect.Descriptor instead.
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 {
@ -4432,7 +4492,7 @@ type GetActionBindingRequest struct {
func (x *GetActionBindingRequest) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4444,7 +4504,7 @@ func (x *GetActionBindingRequest) String() string {
func (*GetActionBindingRequest) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4457,7 +4517,7 @@ func (x *GetActionBindingRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActionBindingRequest.ProtoReflect.Descriptor instead.
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 {
@ -4477,7 +4537,7 @@ type GetActionBindingResponse struct {
func (x *GetActionBindingResponse) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4489,7 +4549,7 @@ func (x *GetActionBindingResponse) String() string {
func (*GetActionBindingResponse) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4502,7 +4562,7 @@ func (x *GetActionBindingResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetActionBindingResponse.ProtoReflect.Descriptor instead.
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 {
@ -4531,7 +4591,7 @@ type GetEntitiesRequest struct {
func (x *GetEntitiesRequest) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4543,7 +4603,7 @@ func (x *GetEntitiesRequest) String() string {
func (*GetEntitiesRequest) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4556,7 +4616,7 @@ func (x *GetEntitiesRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEntitiesRequest.ProtoReflect.Descriptor instead.
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 {
@ -4596,7 +4656,7 @@ type GetEntitiesResponse struct {
func (x *GetEntitiesResponse) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4608,7 +4668,7 @@ func (x *GetEntitiesResponse) String() string {
func (*GetEntitiesResponse) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4621,7 +4681,7 @@ func (x *GetEntitiesResponse) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEntitiesResponse.ProtoReflect.Descriptor instead.
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 {
@ -4645,7 +4705,7 @@ type EntityDefinition struct {
func (x *EntityDefinition) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4657,7 +4717,7 @@ func (x *EntityDefinition) String() string {
func (*EntityDefinition) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4670,7 +4730,7 @@ func (x *EntityDefinition) ProtoReflect() protoreflect.Message {
// Deprecated: Use EntityDefinition.ProtoReflect.Descriptor instead.
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 {
@ -4725,7 +4785,7 @@ type EntityProperty struct {
func (x *EntityProperty) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4737,7 +4797,7 @@ func (x *EntityProperty) String() string {
func (*EntityProperty) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4750,7 +4810,7 @@ func (x *EntityProperty) ProtoReflect() protoreflect.Message {
// Deprecated: Use EntityProperty.ProtoReflect.Descriptor instead.
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 {
@ -4777,7 +4837,7 @@ type GetEntityRequest struct {
func (x *GetEntityRequest) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4789,7 +4849,7 @@ func (x *GetEntityRequest) String() string {
func (*GetEntityRequest) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4802,7 +4862,7 @@ func (x *GetEntityRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use GetEntityRequest.ProtoReflect.Descriptor instead.
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 {
@ -4828,7 +4888,7 @@ type RestartActionRequest struct {
func (x *RestartActionRequest) Reset() {
*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.StoreMessageInfo(mi)
}
@ -4840,7 +4900,7 @@ func (x *RestartActionRequest) String() string {
func (*RestartActionRequest) ProtoMessage() {}
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 {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@ -4853,7 +4913,7 @@ func (x *RestartActionRequest) ProtoReflect() protoreflect.Message {
// Deprecated: Use RestartActionRequest.ProtoReflect.Descriptor instead.
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 {
@ -5175,7 +5235,8 @@ const file_olivetin_api_v1_olivetin_proto_rawDesc = "" +
"\vSshFoundKey\x18\x01 \x01(\tR\vSshFoundKey\x12&\n" +
"\x0eSshFoundConfig\x18\x02 \x01(\tR\x0eSshFoundConfig\x12A\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" +
"\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" +
"\x10available_themes\x18\x18 \x03(\tR\x0favailableThemes\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" +
"\x05title\x18\x01 \x01(\tR\x05title\x12\x10\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
}
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{
(*Action)(nil), // 0: olivetin.api.v1.Action
(*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
(*InitRequest)(nil), // 67: olivetin.api.v1.InitRequest
(*InitResponse)(nil), // 68: olivetin.api.v1.InitResponse
(*AdditionalLink)(nil), // 69: olivetin.api.v1.AdditionalLink
(*OAuth2Provider)(nil), // 70: olivetin.api.v1.OAuth2Provider
(*GetActionBindingRequest)(nil), // 71: olivetin.api.v1.GetActionBindingRequest
(*GetActionBindingResponse)(nil), // 72: olivetin.api.v1.GetActionBindingResponse
(*GetEntitiesRequest)(nil), // 73: olivetin.api.v1.GetEntitiesRequest
(*GetEntitiesResponse)(nil), // 74: olivetin.api.v1.GetEntitiesResponse
(*EntityDefinition)(nil), // 75: olivetin.api.v1.EntityDefinition
(*EntityProperty)(nil), // 76: olivetin.api.v1.EntityProperty
(*GetEntityRequest)(nil), // 77: olivetin.api.v1.GetEntityRequest
(*RestartActionRequest)(nil), // 78: olivetin.api.v1.RestartActionRequest
nil, // 79: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
nil, // 80: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
nil, // 81: olivetin.api.v1.ActionArgument.SuggestionsEntry
nil, // 82: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
nil, // 83: olivetin.api.v1.Entity.FieldsEntry
nil, // 84: olivetin.api.v1.DumpVarsResponse.ContentsEntry
nil, // 85: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
(*RootDashboard)(nil), // 69: olivetin.api.v1.RootDashboard
(*AdditionalLink)(nil), // 70: olivetin.api.v1.AdditionalLink
(*OAuth2Provider)(nil), // 71: olivetin.api.v1.OAuth2Provider
(*GetActionBindingRequest)(nil), // 72: olivetin.api.v1.GetActionBindingRequest
(*GetActionBindingResponse)(nil), // 73: olivetin.api.v1.GetActionBindingResponse
(*GetEntitiesRequest)(nil), // 74: olivetin.api.v1.GetEntitiesRequest
(*GetEntitiesResponse)(nil), // 75: olivetin.api.v1.GetEntitiesResponse
(*EntityDefinition)(nil), // 76: olivetin.api.v1.EntityDefinition
(*EntityProperty)(nil), // 77: olivetin.api.v1.EntityProperty
(*GetEntityRequest)(nil), // 78: olivetin.api.v1.GetEntityRequest
(*RestartActionRequest)(nil), // 79: olivetin.api.v1.RestartActionRequest
nil, // 80: olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
nil, // 81: olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
nil, // 82: olivetin.api.v1.ActionArgument.SuggestionsEntry
nil, // 83: olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
nil, // 84: olivetin.api.v1.Entity.FieldsEntry
nil, // 85: olivetin.api.v1.DumpVarsResponse.ContentsEntry
nil, // 86: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry
}
var file_olivetin_api_v1_olivetin_proto_depIdxs = []int32{
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
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, // 4: olivetin.api.v1.ActionWebhookExecHint.match_query:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchQueryEntry
80, // 3: olivetin.api.v1.ActionWebhookExecHint.match_headers:type_name -> olivetin.api.v1.ActionWebhookExecHint.MatchHeadersEntry
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
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
82, // 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
83, // 8: olivetin.api.v1.EntityRelatedAction.prefilled_arguments:type_name -> olivetin.api.v1.EntityRelatedAction.PrefilledArgumentsEntry
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
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
@ -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
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
84, // 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
85, // 27: olivetin.api.v1.DumpVarsResponse.contents:type_name -> olivetin.api.v1.DumpVarsResponse.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
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
@ -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, // 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
70, // 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
71, // 38: olivetin.api.v1.InitResponse.oAuth2Providers:type_name -> olivetin.api.v1.OAuth2Provider
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
0, // 41: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
35, // 42: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
75, // 43: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
6, // 44: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
76, // 45: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
43, // 46: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
9, // 47: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
12, // 48: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
15, // 49: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
17, // 50: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
19, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
78, // 52: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
56, // 53: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
34, // 54: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
21, // 55: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
24, // 56: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
26, // 57: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
30, // 58: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
37, // 59: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
39, // 60: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
41, // 61: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
44, // 62: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
46, // 63: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
58, // 64: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
60, // 65: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
62, // 66: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
48, // 67: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
64, // 68: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
67, // 69: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
71, // 70: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
73, // 71: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
77, // 72: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
7, // 73: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
14, // 74: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
16, // 75: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
18, // 76: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
20, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
14, // 78: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
57, // 79: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
36, // 80: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
23, // 81: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
25, // 82: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
29, // 83: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
31, // 84: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
38, // 85: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
40, // 86: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
42, // 87: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
45, // 88: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
47, // 89: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
59, // 90: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
61, // 91: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
63, // 92: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
49, // 93: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
66, // 94: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
68, // 95: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
72, // 96: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
74, // 97: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
6, // 98: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
73, // [73:99] is the sub-list for method output_type
47, // [47:73] is the sub-list for method input_type
47, // [47:47] is the sub-list for extension type_name
47, // [47:47] is the sub-list for extension extendee
0, // [0:47] is the sub-list for field type_name
69, // 41: olivetin.api.v1.InitResponse.root_dashboard_entries:type_name -> olivetin.api.v1.RootDashboard
0, // 42: olivetin.api.v1.GetActionBindingResponse.action:type_name -> olivetin.api.v1.Action
35, // 43: olivetin.api.v1.GetActionBindingResponse.back_to_dashboards:type_name -> olivetin.api.v1.DashboardNavigationTarget
76, // 44: olivetin.api.v1.GetEntitiesResponse.entity_definitions:type_name -> olivetin.api.v1.EntityDefinition
6, // 45: olivetin.api.v1.EntityDefinition.instances:type_name -> olivetin.api.v1.Entity
77, // 46: olivetin.api.v1.EntityDefinition.properties:type_name -> olivetin.api.v1.EntityProperty
43, // 47: olivetin.api.v1.DumpPublicIdActionMapResponse.ContentsEntry.value:type_name -> olivetin.api.v1.DebugBinding
9, // 48: olivetin.api.v1.OliveTinApiService.GetDashboard:input_type -> olivetin.api.v1.GetDashboardRequest
12, // 49: olivetin.api.v1.OliveTinApiService.StartAction:input_type -> olivetin.api.v1.StartActionRequest
15, // 50: olivetin.api.v1.OliveTinApiService.StartActionAndWait:input_type -> olivetin.api.v1.StartActionAndWaitRequest
17, // 51: olivetin.api.v1.OliveTinApiService.StartActionByGet:input_type -> olivetin.api.v1.StartActionByGetRequest
19, // 52: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:input_type -> olivetin.api.v1.StartActionByGetAndWaitRequest
79, // 53: olivetin.api.v1.OliveTinApiService.RestartAction:input_type -> olivetin.api.v1.RestartActionRequest
56, // 54: olivetin.api.v1.OliveTinApiService.KillAction:input_type -> olivetin.api.v1.KillActionRequest
34, // 55: olivetin.api.v1.OliveTinApiService.ExecutionStatus:input_type -> olivetin.api.v1.ExecutionStatusRequest
21, // 56: olivetin.api.v1.OliveTinApiService.GetLogs:input_type -> olivetin.api.v1.GetLogsRequest
24, // 57: olivetin.api.v1.OliveTinApiService.GetActionLogs:input_type -> olivetin.api.v1.GetActionLogsRequest
26, // 58: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:input_type -> olivetin.api.v1.GetExecutionQueueRequest
30, // 59: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:input_type -> olivetin.api.v1.ValidateArgumentTypeRequest
37, // 60: olivetin.api.v1.OliveTinApiService.WhoAmI:input_type -> olivetin.api.v1.WhoAmIRequest
39, // 61: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:input_type -> olivetin.api.v1.ServerDiagnosticsRequest
41, // 62: olivetin.api.v1.OliveTinApiService.DumpVars:input_type -> olivetin.api.v1.DumpVarsRequest
44, // 63: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:input_type -> olivetin.api.v1.DumpPublicIdActionMapRequest
46, // 64: olivetin.api.v1.OliveTinApiService.GetReadyz:input_type -> olivetin.api.v1.GetReadyzRequest
58, // 65: olivetin.api.v1.OliveTinApiService.LocalUserLogin:input_type -> olivetin.api.v1.LocalUserLoginRequest
60, // 66: olivetin.api.v1.OliveTinApiService.PasswordHash:input_type -> olivetin.api.v1.PasswordHashRequest
62, // 67: olivetin.api.v1.OliveTinApiService.Logout:input_type -> olivetin.api.v1.LogoutRequest
48, // 68: olivetin.api.v1.OliveTinApiService.EventStream:input_type -> olivetin.api.v1.EventStreamRequest
64, // 69: olivetin.api.v1.OliveTinApiService.GetDiagnostics:input_type -> olivetin.api.v1.GetDiagnosticsRequest
67, // 70: olivetin.api.v1.OliveTinApiService.Init:input_type -> olivetin.api.v1.InitRequest
72, // 71: olivetin.api.v1.OliveTinApiService.GetActionBinding:input_type -> olivetin.api.v1.GetActionBindingRequest
74, // 72: olivetin.api.v1.OliveTinApiService.GetEntities:input_type -> olivetin.api.v1.GetEntitiesRequest
78, // 73: olivetin.api.v1.OliveTinApiService.GetEntity:input_type -> olivetin.api.v1.GetEntityRequest
7, // 74: olivetin.api.v1.OliveTinApiService.GetDashboard:output_type -> olivetin.api.v1.GetDashboardResponse
14, // 75: olivetin.api.v1.OliveTinApiService.StartAction:output_type -> olivetin.api.v1.StartActionResponse
16, // 76: olivetin.api.v1.OliveTinApiService.StartActionAndWait:output_type -> olivetin.api.v1.StartActionAndWaitResponse
18, // 77: olivetin.api.v1.OliveTinApiService.StartActionByGet:output_type -> olivetin.api.v1.StartActionByGetResponse
20, // 78: olivetin.api.v1.OliveTinApiService.StartActionByGetAndWait:output_type -> olivetin.api.v1.StartActionByGetAndWaitResponse
14, // 79: olivetin.api.v1.OliveTinApiService.RestartAction:output_type -> olivetin.api.v1.StartActionResponse
57, // 80: olivetin.api.v1.OliveTinApiService.KillAction:output_type -> olivetin.api.v1.KillActionResponse
36, // 81: olivetin.api.v1.OliveTinApiService.ExecutionStatus:output_type -> olivetin.api.v1.ExecutionStatusResponse
23, // 82: olivetin.api.v1.OliveTinApiService.GetLogs:output_type -> olivetin.api.v1.GetLogsResponse
25, // 83: olivetin.api.v1.OliveTinApiService.GetActionLogs:output_type -> olivetin.api.v1.GetActionLogsResponse
29, // 84: olivetin.api.v1.OliveTinApiService.GetExecutionQueue:output_type -> olivetin.api.v1.GetExecutionQueueResponse
31, // 85: olivetin.api.v1.OliveTinApiService.ValidateArgumentType:output_type -> olivetin.api.v1.ValidateArgumentTypeResponse
38, // 86: olivetin.api.v1.OliveTinApiService.WhoAmI:output_type -> olivetin.api.v1.WhoAmIResponse
40, // 87: olivetin.api.v1.OliveTinApiService.ServerDiagnostics:output_type -> olivetin.api.v1.ServerDiagnosticsResponse
42, // 88: olivetin.api.v1.OliveTinApiService.DumpVars:output_type -> olivetin.api.v1.DumpVarsResponse
45, // 89: olivetin.api.v1.OliveTinApiService.DumpPublicIdActionMap:output_type -> olivetin.api.v1.DumpPublicIdActionMapResponse
47, // 90: olivetin.api.v1.OliveTinApiService.GetReadyz:output_type -> olivetin.api.v1.GetReadyzResponse
59, // 91: olivetin.api.v1.OliveTinApiService.LocalUserLogin:output_type -> olivetin.api.v1.LocalUserLoginResponse
61, // 92: olivetin.api.v1.OliveTinApiService.PasswordHash:output_type -> olivetin.api.v1.PasswordHashResponse
63, // 93: olivetin.api.v1.OliveTinApiService.Logout:output_type -> olivetin.api.v1.LogoutResponse
49, // 94: olivetin.api.v1.OliveTinApiService.EventStream:output_type -> olivetin.api.v1.EventStreamResponse
66, // 95: olivetin.api.v1.OliveTinApiService.GetDiagnostics:output_type -> olivetin.api.v1.GetDiagnosticsResponse
68, // 96: olivetin.api.v1.OliveTinApiService.Init:output_type -> olivetin.api.v1.InitResponse
73, // 97: olivetin.api.v1.OliveTinApiService.GetActionBinding:output_type -> olivetin.api.v1.GetActionBindingResponse
75, // 98: olivetin.api.v1.OliveTinApiService.GetEntities:output_type -> olivetin.api.v1.GetEntitiesResponse
6, // 99: olivetin.api.v1.OliveTinApiService.GetEntity:output_type -> olivetin.api.v1.Entity
74, // [74:100] is the sub-list for method output_type
48, // [48:74] is the sub-list for method input_type
48, // [48:48] is the sub-list for extension 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() }
@ -5502,7 +5569,7 @@ func file_olivetin_api_v1_olivetin_proto_init() {
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)),
NumEnums: 0,
NumMessages: 86,
NumMessages: 87,
NumExtensions: 0,
NumServices: 1,
},

View File

@ -28,7 +28,6 @@ require (
github.com/sirupsen/logrus v1.9.4
github.com/stretchr/testify v1.11.1
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/sys v0.47.0
google.golang.org/protobuf v1.36.11
@ -290,6 +289,7 @@ require (
go.uber.org/zap v1.28.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // 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/mod v0.38.0 // indirect
golang.org/x/net v0.57.0 // indirect

View File

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

View File

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

View File

@ -43,6 +43,10 @@ type oliveTinAPI struct {
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.
// and holds the lock for as minimal time as possible to avoid blocking the API for too long.
func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
@ -58,9 +62,9 @@ func (api *oliveTinAPI) copyOfStreamingClients() []*streamingClient {
type streamingClient struct {
channel chan *apiv1.EventStreamResponse
AuthenticatedUser *authpublic.AuthenticatedUser
heartbeatStopOnce sync.Once
heartbeatStop chan struct{}
heartbeatDone chan struct{}
heartbeatStopOnce sync.Once
}
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)
args := startActionArgumentsFromProto(req.Msg.Arguments)
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)
}
@ -801,13 +806,13 @@ func paginate(total int64, size int64, start int64) pageInfo {
if start < 0 {
start = 0
}
if start >= total {
return pageInfo{total: total, size: size, start: start, end: start, empty: true}
}
end := start + size
if end > total {
end = total
}
end := min(start+size, total)
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{}),
}
if err := api.registerStreamingClient(client); err != nil {
return connect.NewError(connect.CodeResourceExhausted, err)
}
log.WithFields(log.Fields{
"authenticatedUser": user.Username,
}).Debugf("EventStream: client connected")
api.streamingClientsMutex.Lock()
api.streamingClients[client] = struct{}{}
api.streamingClientsMutex.Unlock()
go api.sendEventStreamHeartbeats(client)
// 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
}
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) {
defer close(client.heartbeatDone)
@ -1217,6 +1237,9 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
currentVersion = installationinfo.Build.Version
availableVersion = installationinfo.Runtime.AvailableVersion
}
rootDashboardEntries := api.buildRootDashboardEntries(user, api.cfg.Dashboards)
res := &apiv1.InitResponse{
ShowFooter: api.cfg.ShowFooter,
ShowNavigation: api.cfg.ShowNavigation,
@ -1232,7 +1255,8 @@ func (api *oliveTinAPI) Init(ctx ctx.Context, req *connect.Request[apiv1.InitReq
OAuth2Providers: buildPublicOAuth2ProvidersList(api.cfg),
AdditionalLinks: buildAdditionalLinks(api.cfg.AdditionalNavigationLinks),
StyleMods: api.cfg.StyleMods,
RootDashboards: api.buildRootDashboards(user, api.cfg.Dashboards),
RootDashboards: rootDashboardTitles(rootDashboardEntries),
RootDashboardEntries: rootDashboardEntries,
AuthenticatedUser: user.Username,
AuthenticatedUserProvider: user.Provider,
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 {
var rootDashboards []string
dashboardRenderRequest := api.createDashboardRenderRequest(user, "", "")
api.addDefaultDashboardIfNeeded(&rootDashboards, dashboardRenderRequest)
api.addCustomDashboards(&rootDashboards, dashboards, dashboardRenderRequest)
return rootDashboards
return rootDashboardTitles(api.buildRootDashboardEntries(user, dashboards))
}
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)
if defaultDashboard != nil && len(defaultDashboard.Contents) > 0 {
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 {
// 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
db := renderDashboard(rr, dashboard.Title)
if db != nil {
*rootDashboards = append(*rootDashboards, dashboard.Title)
renderedDashboard := renderDashboard(rr, dashboard.Title)
if renderedDashboard != nil {
*entries = append(*entries, &apiv1.RootDashboard{
Title: dashboard.Title,
Category: dashboard.Category,
})
}
}
}

View File

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

View File

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

View File

@ -75,7 +75,7 @@ func waitForLogJustification(t *testing.T, ex *executor.Executor, trackingID, ex
func TestExecutionStatusIncludesStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
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"},
}),
}
@ -236,7 +236,7 @@ func TestRestartActionRejectsIncompleteStoredArguments(t *testing.T) {
func TestRestartActionRejectsMissingRequiredStoredArguments(t *testing.T) {
cfg := config.DefaultConfig()
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"},
}),
}

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) {
ex.Cfg = injectedConfig
apiPath, apiHandler := GetNewHandler(ex)
mux := http.NewServeMux()
@ -102,8 +104,6 @@ func TestGetActionsAndStart(t *testing.T) {
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")
log.Printf("Response: %+v", respInit)
@ -112,7 +112,7 @@ func TestGetActionsAndStart(t *testing.T) {
// 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")
defer conn.Close()
@ -137,12 +137,12 @@ func TestGetEntities(t *testing.T) {
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
assert.NoError(t, err, "GetEntities should not return an error")
assert.NotNil(t, resp, "GetEntities response should not be nil")
assert.NotNil(t, resp.Msg, "GetEntities response message should not be nil")
require.NoError(t, err, "GetEntities should not return an error")
require.NotNil(t, resp, "GetEntities response should not be nil")
require.NotNil(t, resp.Msg, "GetEntities response message should not be nil")
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)
validateNoDuplicates(t, entityDefinitions)
@ -151,6 +151,8 @@ func TestGetEntities(t *testing.T) {
}
func validateEntityListProperties(t *testing.T, client apiv1connect.OliveTinApiServiceClient) {
t.Helper()
resp, err := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{
EntityType: "server",
Page: 1,
@ -185,21 +187,27 @@ func setupTestEntities() {
}
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, 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, "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, "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, 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")
}
func validateNoDuplicates(t *testing.T, entityDefinitions []*apiv1.EntityDefinition) {
t.Helper()
instanceKeys := make(map[string]map[string]bool)
for _, def := range entityDefinitions {
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) {
t.Helper()
resp2, err2 := client.GetEntities(context.Background(), connect.NewRequest(&apiv1.GetEntitiesRequest{}))
assert.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.NoError(t, err2, "Second GetEntities call should not return an error")
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 {
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 {
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) {
tests := []struct {
entity *entities.Entity
name string
expression string
entity *entities.Entity
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) {
t.Helper()
binding.Entity = &entities.Entity{
UniqueKey: "test-entity",
Data: map[string]any{"enabled": enabled},
@ -782,6 +795,46 @@ func TestEventStreamACLNoLeakToUnauthorizedUser(t *testing.T) {
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) {
t.Helper()
clientLow := &streamingClient{

View File

@ -68,6 +68,61 @@ func TestDashboardAclsRootNavAndGetDashboard(t *testing.T) {
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) {
cfg := buildDashboardAclTestConfig()
cfg.Dashboards = []*config.DashboardComponent{

View File

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

View File

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

View File

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

View File

@ -21,7 +21,7 @@ func TestCheckUserFromLocalBearerApiKey_Match_LowercaseBearerScheme(t *testing.T
ApiKey: "secret-api-key",
}}
req := httptest.NewRequest("POST", "/", nil)
req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "bearer secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -43,7 +43,7 @@ func TestCheckUserFromLocalBearerApiKey_Match(t *testing.T) {
ApiKey: "secret-api-key",
}}
req := httptest.NewRequest("POST", "/", nil)
req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "Bearer secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -64,7 +64,7 @@ func TestCheckUserFromLocalBearerApiKey_WrongKey(t *testing.T) {
ApiKey: "secret-api-key",
}}
req := httptest.NewRequest("POST", "/", nil)
req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "Bearer wrong")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -81,7 +81,7 @@ func TestCheckUserFromLocalBearerApiKey_DisabledLocalUsers(t *testing.T) {
ApiKey: "secret-api-key",
}}
req := httptest.NewRequest("POST", "/", nil)
req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "Bearer secret-api-key")
ctx := &authpublic.AuthCheckingContext{Request: req, Config: cfg}
@ -98,7 +98,7 @@ func TestCheckUserFromLocalBearerApiKey_NoBearerPrefix(t *testing.T) {
ApiKey: "secret-api-key",
}}
req := httptest.NewRequest("POST", "/", nil)
req := httptest.NewRequestWithContext(t.Context(), "POST", "/", nil)
req.Header.Set("Authorization", "secret-api-key")
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
}
keyFunc := func(token *jwt.Token) (interface{}, error) {
keyFunc := func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
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
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 {
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 {
usergroup := ""
if val, ok := claims[groupClaim]; ok {
if array, ok := val.([]interface{}); ok {
if array, ok := val.([]any); ok {
groups := make([]string, len(array))
for i, v := range array {
groups[i] = fmt.Sprintf("%s", v)

View File

@ -17,9 +17,12 @@ import (
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func generateRSAKeyPair(t *testing.T) (*rsa.PrivateKey, []byte) {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
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) {
t.Helper()
tmpFile, err := os.CreateTemp(os.TempDir(), "olivetin-jwt-")
if err != nil {
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 {
t.Helper()
token := jwt.New(jwt.SigningMethodRS256)
claims := token.Claims.(jwt.MapClaims)
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 {
t.Helper()
mux := newMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
context := &authpublic.AuthCheckingContext{
@ -93,7 +102,7 @@ func setupJWTTestHandler(t *testing.T, cfg *config.Config) http.Handler {
user := CheckUserFromJwtHeader(context)
if user == nil {
w.WriteHeader(403)
w.WriteHeader(http.StatusForbidden)
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) {
t.Helper()
defer func() { _ = res.Body.Close() }()
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))
}
func testJwkValidation(t *testing.T, expire int64, expectCode int) {
t.Helper()
testJwkValidationWithAudience(t, expire, expectCode, "", "")
}
func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, configAudience, tokenAudience string) {
t.Helper()
privateKey, publicKeyPath := createKeys(t)
defer func() { _ = os.Remove(publicKeyPath) }()
@ -131,27 +147,30 @@ func testJwkValidationWithAudience(t *testing.T, expire int64, expectCode int, c
srv := httptest.NewServer(handler)
defer srv.Close()
res := makeJWTRequest(t, srv, tokenStr)
res := makeJWTRequest(t, srv, tokenStr) //nolint:bodyclose // closed by verifyJWTResponse
verifyJWTResponse(t, res, expectCode)
}
func TestJWTSignatureVerificationSucceeds(t *testing.T) {
testJwkValidation(t, 1000, 200)
testJwkValidation(t, 1000, http.StatusOK)
}
func TestJWTSignatureVerificationFails(t *testing.T) {
testJwkValidation(t, -500, 403)
testJwkValidation(t, -500, http.StatusForbidden)
}
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) {
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)
claims := token.Claims.(jwt.MapClaims)
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 {
req, err := http.NewRequest("GET", srv.URL, nil)
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, srv.URL, nil)
if err != nil {
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 {
t.Fatalf("Client err: %+v", err)
}
return res
}
@ -201,7 +223,7 @@ func TestJWTHeader(t *testing.T) {
user := CheckUserFromJwtHeader(context)
if user == nil {
w.WriteHeader(403)
w.WriteHeader(http.StatusForbidden)
return
}
@ -212,10 +234,6 @@ func TestJWTHeader(t *testing.T) {
srv := httptest.NewServer(mux)
defer srv.Close()
res := makeJWTRequest(t, srv, tokenStr)
defer func() { _ = res.Body.Close() }()
assert.Equal(t, 200, res.StatusCode)
body, _ := io.ReadAll(res.Body)
t.Logf("Response body: %s", string(body))
res := makeJWTRequest(t, srv, tokenStr) //nolint:bodyclose // closed by verifyJWTResponse
verifyJWTResponse(t, res, http.StatusOK)
}

View File

@ -22,9 +22,9 @@ import (
type OAuth2Handler struct {
cfg *config.Config
mu sync.RWMutex
registeredStates map[string]*oauth2State
registeredProviders map[string]*oauth2.Config
mu sync.RWMutex
}
func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
@ -58,11 +58,11 @@ func NewOAuth2Handler(cfg *config.Config) *OAuth2Handler {
}
type oauth2State struct {
createdAt time.Time
providerConfig *oauth2.Config
providerName string
Username string
Usergroup string
createdAt time.Time
}
const (
@ -342,7 +342,18 @@ type UserInfo struct {
func getUserInfo(cfg *config.Config, client *http.Client, provider *config.OAuth2Provider) *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 {
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()
h.HandleOAuthLogin(rec, req)

View File

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

View File

@ -2,11 +2,13 @@ package config
import (
"fmt"
"net"
"os"
"path/filepath"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"github.com/OliveTin/OliveTin/internal/configissues"
@ -81,12 +83,72 @@ func afterLoadFinalize(cfg *Config, configPath string) {
cfg.SetDir(filepath.Dir(configPath))
cfg.Sanitize()
applyPortEnvironmentOverride(cfg)
for _, l := range listeners {
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.
func buildIncludePath(k *koanf.Koanf, baseConfigPath string) string {
relativeIncludePath := k.String("include")
@ -187,17 +249,17 @@ func loadAndMergeIncludedFile(k *koanf.Koanf, includePath, filename string) {
}).Info("Successfully loaded included config file")
}
func mergeFuncForSource(sourceFile string) func(src, dest map[string]interface{}) error {
return func(src map[string]interface{}, dest map[string]interface{}) error {
func mergeFuncForSource(sourceFile string) func(src, dest map[string]any) error {
return func(src map[string]any, dest map[string]any) error {
return mergeFunc(src, dest, sourceFile)
}
}
// 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)
srcSlice, ok1 := srcActions.([]interface{})
destSlice, ok2 := destActions.([]interface{})
srcSlice, ok1 := srcActions.([]any)
destSlice, ok2 := destActions.([]any)
if ok1 && ok2 {
dest["actions"] = append(destSlice, srcSlice...)
} else {
@ -206,7 +268,7 @@ func mergeActionsWhenBothExist(srcActions interface{}, destActions interface{},
}
// 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 {
mergeActionsWhenBothExist(srcActions, destActions, dest, sourceFile)
} else {
@ -216,9 +278,9 @@ func mergeActionsFromSource(srcActions interface{}, dest map[string]interface{},
}
// mergeDashboardsWhenBothExist merges dashboards when both src and dest have dashboards.
func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards interface{}, dest map[string]interface{}) {
srcSlice, ok1 := srcDashboards.([]interface{})
destSlice, ok2 := destDashboards.([]interface{})
func mergeDashboardsWhenBothExist(srcDashboards any, destDashboards any, dest map[string]any) {
srcSlice, ok1 := srcDashboards.([]any)
destSlice, ok2 := destDashboards.([]any)
if ok1 && ok2 {
dest["dashboards"] = append(destSlice, srcSlice...)
} else {
@ -227,7 +289,7 @@ func mergeDashboardsWhenBothExist(srcDashboards interface{}, destDashboards inte
}
// 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 {
mergeDashboardsWhenBothExist(srcDashboards, destDashboards, dest)
} else {
@ -236,10 +298,10 @@ func mergeDashboardsFromSource(srcDashboards interface{}, dest map[string]interf
}
// 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)
srcSlice, ok1 := srcEntities.([]interface{})
destSlice, ok2 := destEntities.([]interface{})
srcSlice, ok1 := srcEntities.([]any)
destSlice, ok2 := destEntities.([]any)
if ok1 && ok2 {
dest["entities"] = append(destSlice, srcSlice...)
} else {
@ -248,7 +310,7 @@ func mergeEntitiesWhenBothExist(srcEntities interface{}, destEntities interface{
}
// 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 {
mergeEntitiesWhenBothExist(srcEntities, destEntities, dest, sourceFile)
} 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 {
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 (
"fmt"
"slices"
"strings"
"text/template"
@ -179,13 +180,7 @@ func (cfg *Config) inlineActionExists(action *Action) bool {
}
func (cfg *Config) inlineActionPointerExists(action *Action) bool {
for _, existingAction := range cfg.Actions {
if existingAction == action {
return true
}
}
return false
return slices.Contains(cfg.Actions, action)
}
func (cfg *Config) inlineActionIDExists(action *Action) bool {
@ -400,7 +395,7 @@ func expandEnvTemplate(source string) string {
return source
}
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")
return source
}

View File

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

View File

@ -69,7 +69,7 @@ func watchAndLoadEntity(baseDir string, ef *config.EntityFile) {
p = filepath.Join(baseDir, p)
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,
})
loadEntityFile(p, ef.Name)

View File

@ -10,6 +10,7 @@ package entities
*/
import (
"maps"
"sort"
"strconv"
"strings"
@ -39,9 +40,8 @@ func GetEntities() EntitiesByClass {
for entityName, entityInstances := range entities {
copiedInstances := make(entityInstancesByKey, len(entityInstances))
for key, entity := range entityInstances {
copiedInstances[key] = entity
}
maps.Copy(copiedInstances, entityInstances)
copiedEntities[entityName] = copiedInstances
}
@ -57,9 +57,8 @@ func GetEntityInstances(entityName string) entityInstancesByKey {
if entities, ok := entities[entityName]; ok {
copiedInstances := make(entityInstancesByKey, len(entities))
for key, entity := range entities {
copiedInstances[key] = entity
}
maps.Copy(copiedInstances, entities)
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 {
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
}
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
argValue := req.Arguments[argName]
err := typecheckActionArgument(&arg, argValue, req.Binding.Action)
err := typecheckActionArgument(&arg, argValue)
if err != nil {
return "", err
@ -153,7 +153,7 @@ func argumentSkipsValidation(arg *config.ActionArgument) bool {
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) {
return nil
}
@ -199,7 +199,7 @@ func ValidateArgument(arg *config.ActionArgument, value string, action *config.A
mangledValue := MangleArgumentValue(arg, value, action.Title)
// 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 {

View File

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

View File

@ -15,11 +15,14 @@ import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"os"
"os/exec"
"path"
"regexp"
"slices"
"strings"
"sync"
"time"
@ -39,53 +42,44 @@ func isValidTrackingID(id string) bool {
}
type ActionBinding struct {
ID string
Action *config.Action
Entity *entities.Entity
ConfigOrder int
ID string
OnDashboards []DashboardNavigationTarget
ConfigOrder int
}
// Executor represents a helper class for executing commands. It's main method
// is ExecRequest
type Executor struct {
logs map[string]*InternalLogEntry
logsTrackingIdsByDate []string
LogsByBindingId map[string][]*InternalLogEntry
logmutex sync.RWMutex
MapActionBindings map[string]*ActionBinding
Cfg *config.Config
logsTrackingIdsByDate []string
listeners []listener
chainOfCommand []executorStepFunc
groupQueue []*queuedExecution
logmutex sync.RWMutex
MapActionBindingsLock sync.RWMutex
Cfg *config.Config
listeners []listener
listenersMu sync.RWMutex
chainOfCommand []executorStepFunc
groupQueue []*queuedExecution
groupQueueMu sync.Mutex
listenersMu sync.RWMutex
groupQueueMu sync.Mutex
}
// ExecutionRequest is a request to execute an action. It's passed to an
// Executor. They're created from the api.
type ExecutionRequest struct {
Binding *ActionBinding
Arguments map[string]string
TrackingID string
Tags []string
Cfg *config.Config
AuthenticatedUser *authpublic.AuthenticatedUser
TriggerDepth int
Justification string
Arguments map[string]string
Binding *ActionBinding
Cfg *config.Config
AuthenticatedUser *authpublic.AuthenticatedUser
executor *Executor
logEntry *InternalLogEntry
finalParsedCommand string
TrackingID string
Justification string
Tags []string
execArgs []string
TriggerDepth int
useDirectExec bool
executor *Executor
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.
type LogEntrySnapshot struct {
Output string
ExitCode int32
Queued bool
Blocked bool
ExecutionStarted bool
ExecutionFinished bool
ExitCode int32
Output string
}
// 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
// easily serializable.
type InternalLogEntry struct {
Binding *ActionBinding
DatetimeStarted time.Time
DatetimeFinished time.Time
Output string
TimedOut bool
Blocked bool
Queued bool
QueuedForGroup string
ExitCode int32
Tags []string
ExecutionStarted bool
ExecutionFinished bool
ExecutionTrackingID string
Binding *ActionBinding
Process *os.Process
Arguments map[string]string
ExecutionTrackingID string
Justification string
QueuedForGroup string
ActionIcon string
ActionTitle string
ActionConfigTitle string
Output string
Username string
Index int64
EntityPrefix string
ActionConfigTitle string // This is the title of the action as defined in the config, not the final parsed title.
/*
The following 3 properties are obviously on Action normally, but it's useful
that logs are lightweight (so we don't need to have an action associated to
logs, etc. Therefore, we duplicate those values here.
*/
ActionTitle string
ActionIcon string
Justification string
Arguments map[string]string
Tags []string
Index int64
ExitCode int32
Blocked bool
ExecutionFinished bool
ExecutionStarted bool
Queued bool
TimedOut bool
}
// .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 {
for _, tag := range req.Tags {
if tag == "webhook" {
return true
}
}
return false
return slices.Contains(req.Tags, "webhook")
}
var systemArgumentDefinitions = []config.ActionArgument{
@ -958,9 +941,7 @@ func injectSystemArgs(req *ExecutionRequest) error {
return err
}
for name, value := range args {
req.Arguments[name] = value
}
maps.Copy(req.Arguments, args)
return nil
}
@ -1097,8 +1078,8 @@ func appendErrorToStderr(req *ExecutionRequest, err error) {
type OutputStreamer struct {
Req *ExecutionRequest
mu sync.Mutex
output bytes.Buffer
mu sync.Mutex
}
func (ost *OutputStreamer) Write(o []byte) (n int, err error) {
@ -1186,7 +1167,7 @@ func stepExec(req *ExecutionRequest) bool {
appendErrorToStderr(req, runerr)
appendErrorToStderr(req, waiterr)
if ctx.Err() == context.DeadlineExceeded {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
log.WithFields(log.Fields{
"actionTitle": req.logEntry.ActionTitle,
}).Warnf("Action timed out")
@ -1263,7 +1244,7 @@ func stepExecAfter(req *ExecutionRequest) bool {
appendErrorToStderr(req, runerr)
appendErrorToStderr(req, waiterr)
if ctx.Err() == context.DeadlineExceeded {
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
req.mutateLogEntry(func(entry *InternalLogEntry) {
entry.Output += "Your shellAfterCompleted command timed out."
})
@ -1290,23 +1271,97 @@ func shellAfterCompletedAction(req *ExecutionRequest) (*config.Action, bool) {
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 {
replacements := []struct{ old, new string }{
{"{{ output }}", `"$OUTPUT"`},
{"{{output}}", `"$OUTPUT"`},
{"{{ exitCode }}", `"$EXITCODE"`},
{"{{exitCode}}", `"$EXITCODE"`},
{"{{ exitCode}}", `"$EXITCODE"`},
{"{{exitCode }}", `"$EXITCODE"`},
}
for _, replacement := range replacements {
command = strings.ReplaceAll(command, replacement.old, replacement.new)
}
command = replaceShellAfterEnvRef(command, shellAfterOutputRef, "$OUTPUT")
command = replaceShellAfterEnvRef(command, shellAfterExitCodeRef, "$EXITCODE")
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) {
finalParsedCommand, err := tpl.ParseTemplateWithActionContext(commandTemplate, req.Binding.Entity, args)
if err != nil {
@ -1338,7 +1393,7 @@ func buildShellAfterCommand(ctx context.Context, req *ExecutionRequest, stdout,
}
commandTemplate := substituteShellAfterCompletedEnvRefs(action.ShellAfterCompleted)
finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, args)
finalParsedCommand, err := parseShellAfterCompletedCommand(req, commandTemplate, shellAfterTemplateArgs(args))
if err != nil {
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")
}
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) {
req := newExecRequest()
req.Binding.Action = &config.Action{

View File

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

View File

@ -2,6 +2,7 @@ package executor
import (
"context"
"errors"
"os"
"sync"
"time"
@ -55,7 +56,7 @@ func (tc *timeoutContext) setProcess(process *os.Process) {
tc.processMu.Unlock()
// 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}
if err := tc.executor.Kill(logEntry); err != nil {
log.WithFields(log.Fields{

View File

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

View File

@ -14,6 +14,7 @@ import (
"net/url"
"path"
"strings"
"time"
"github.com/OliveTin/OliveTin/internal/api"
"github.com/OliveTin/OliveTin/internal/auth"
@ -153,8 +154,12 @@ func StartFrontendMux(cfg *config.Config, ex *executor.Executor) {
}
srv := &http.Server{
Addr: cfg.ListenAddressSingleHTTPFrontend,
Handler: securityHeadersMiddleware(cfg, mux),
Addr: cfg.ListenAddressSingleHTTPFrontend,
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())

View File

@ -2,6 +2,7 @@ package httpservers
import (
"net/http"
"time"
config "github.com/OliveTin/OliveTin/internal/config"
"github.com/prometheus/client_golang/prometheus"
@ -19,8 +20,19 @@ func StartPrometheus(cfg *config.Config) {
prometheus.Unregister(collectors.NewGoCollector())
}
http.Handle("/", promhttp.Handler())
err := http.ListenAndServe(cfg.ListenAddressPrometheus, nil)
mux := http.NewServeMux()
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 {
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) {
// dirName := path.Dir(r.URL.Path)
// Mangle requests for any path like /logs or /config to load the webui index.html
if path.Ext(r.URL.Path) == "" && 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"))
} else {
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)
return
}
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 {
@ -84,7 +82,7 @@ func (s *webUIServer) findCustomWebuiDir() string {
func (s *webUIServer) setupCustomWebuiDir() {
dir := s.findCustomWebuiDir()
err := os.MkdirAll(path.Join(dir, "themes/"), 0775)
err := os.MkdirAll(path.Join(dir, "themes/"), 0o750)
if err != nil {
log.Warnf("Could not create themes directory: %v", err)

View File

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

View File

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

View File

@ -5,12 +5,12 @@ type Record struct {
Status string
Action string
User string
Output string
Tags []string
ExitCode int32
Blocked bool
TimedOut bool
Running bool
ExitCode int32
Output string
}
// 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")
absoluteDir := t.TempDir()
assert.Equal(t, "", resolveLogDirectory("", baseDir))
assert.Empty(t, resolveLogDirectory("", baseDir))
assert.Equal(t, absoluteDir, resolveLogDirectory(absoluteDir, baseDir))
assert.Equal(t, filepath.Join(baseDir, "logs", "service"), resolveLogDirectory("./logs/service", baseDir))
assert.Equal(t, "logs/service", resolveLogDirectory("logs/service", ""))

View File

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

View File

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

View File

@ -1,6 +1,7 @@
package updatecheck
import (
"context"
"encoding/json"
"github.com/Masterminds/semver"
config "github.com/OliveTin/OliveTin/internal/config"
@ -10,12 +11,13 @@ import (
"io"
"net/http"
"os"
"time"
)
type versionMapType struct {
ApiVersion int
Latest string
History map[string]string
Latest string
ApiVersion int
}
// StartUpdateChecker will start a job that runs periodically, checking
@ -84,7 +86,11 @@ func parseIfVersionIsLater(currentString string, latestString string) 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 {
log.Errorf("Update check failed %v", err)

View File

@ -8,11 +8,11 @@ import (
)
type JSONMatcher struct {
payload interface{}
payload any
}
func NewJSONMatcher(payload []byte) (*JSONMatcher, error) {
var data interface{}
var data any
if err := json.Unmarshal(payload, &data); err != nil {
return nil, err
}
@ -60,6 +60,6 @@ func (m *JSONMatcher) ExtractValue(pathExpr string) (string, error) {
return string(jsonBytes), nil
}
func (m *JSONMatcher) GetPayload() interface{} {
func (m *JSONMatcher) GetPayload() any {
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 {
if strings.HasPrefix(expected, "regex:") {
pattern := strings.TrimPrefix(expected, "regex:")
if pattern, hasRegex := strings.CutPrefix(expected, "regex:"); hasRegex {
matched, err := regexp.MatchString(pattern, actual)
if err != nil {
log.WithFields(log.Fields{

View File

@ -12,7 +12,7 @@ import (
func TestExtractJustificationFromWebhookBody(t *testing.T) {
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)
matcher := NewWebhookMatcher(config.WebhookConfig{
@ -25,7 +25,7 @@ func TestExtractJustificationFromWebhookBody(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)
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) {
err := f.Watch(func(evt interface{}, err error) {
err := f.Watch(func(evt any, err error) {
log.Infof("config file changed: %v", evt)
errLoad := k.Load(f, yaml.Parser())

View File

@ -2,7 +2,9 @@ package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@ -41,20 +43,20 @@ type runSummary struct {
}
type jsonlRecord struct {
Run int `json:"run"`
Timestamp string `json:"timestamp"`
FailureDetails []testFailure `json:"failureDetails"`
Run int `json:"run"`
ExitCode int `json:"exitCode"`
DurationMs int64 `json:"durationMs"`
Passes int `json:"passes"`
Failures int `json:"failures"`
Skipped int `json:"skipped"`
FailureDetails []testFailure `json:"failureDetails"`
}
type testRunState struct {
summary runSummary
failures []testFailure
failureOutput map[string]*strings.Builder
failures []testFailure
summary runSummary
}
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) {
if err := cmd.Wait(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
if errExit, ok := errors.AsType[*exec.ExitError](err); ok {
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
}
@ -322,7 +324,11 @@ func finishTestCommand(cmd *exec.Cmd, state *testRunState) (int, runSummary, []t
}
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
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.