mirror of
https://github.com/OliveTin/OliveTin
synced 2025-12-12 00:55:34 +00:00
Compare commits
69 Commits
2021-11-02
...
2022-01-06
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f214fd45 | ||
|
|
acaf28e200 | ||
|
|
c263a84aa7 | ||
|
|
f09501278a | ||
|
|
5edeace62e | ||
|
|
07ca9f21bc | ||
|
|
4c7b4ee7de | ||
|
|
908edc352f | ||
|
|
fbea7ba928 | ||
|
|
edb0ebbda4 | ||
|
|
d8fa35087e | ||
|
|
299f492675 | ||
|
|
f4ff0a209d | ||
|
|
f91e5a7751 | ||
|
|
2b9e763e02 | ||
|
|
7bdb99764c | ||
|
|
7d0b73c169 | ||
|
|
8d3a2ad223 | ||
|
|
a1501ebbe3 | ||
|
|
9ca94756e5 | ||
|
|
54d6855b3d | ||
|
|
f3231655fa | ||
|
|
aef1e4db1b | ||
|
|
d5c008188e | ||
|
|
d139f24d13 | ||
|
|
5b4f51f698 | ||
|
|
af5889a04d | ||
|
|
e1ccf444ce | ||
|
|
9943d1ced5 | ||
|
|
31411d0e95 | ||
|
|
8e3112ee16 | ||
|
|
395c5bea99 | ||
|
|
7ee404f44c | ||
|
|
c7bc22ac7e | ||
|
|
e3f4cd8113 | ||
|
|
1b94e29721 | ||
|
|
fd04922e59 | ||
|
|
8ecaf33b1a | ||
|
|
2771f58469 | ||
|
|
ff5d60a2dc | ||
|
|
2f6a975bb3 | ||
|
|
b029c7f0ac | ||
|
|
9b2b866701 | ||
|
|
d2c25a35f0 | ||
|
|
16b43b7b4f | ||
|
|
e37a653655 | ||
|
|
a23d5265b8 | ||
|
|
850fe8d704 | ||
|
|
5be6934ca6 | ||
|
|
4e8f20e1e6 | ||
|
|
f9526749eb | ||
|
|
2e45f9304f | ||
|
|
41dc1d9b72 | ||
|
|
acde5f1fd5 | ||
|
|
b3b5b6fe60 | ||
|
|
91ce4e93a2 | ||
|
|
08eff24dda | ||
|
|
78efc5c94e | ||
|
|
3aa7c97bfb | ||
|
|
12475cd310 | ||
|
|
80f3b29d2b | ||
|
|
6357c9dc61 | ||
|
|
4b2ef44959 | ||
|
|
b97fa9ed4a | ||
|
|
bc73ba340c | ||
|
|
c4b6c39dc9 | ||
|
|
08f32627fc | ||
|
|
666d29cd03 | ||
|
|
2a767199e2 |
28
.githooks/commit-msg
Executable file
28
.githooks/commit-msg
Executable file
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import sys
|
||||
|
||||
commitmsg = ""
|
||||
|
||||
with open('.git/COMMIT_EDITMSG', mode='r') as f:
|
||||
commitmsg = f.readline().strip()
|
||||
|
||||
print("Commit message is: " + commitmsg)
|
||||
|
||||
ALLOWED_COMMIT_TYPES = [
|
||||
"cicd",
|
||||
"typo",
|
||||
"fmt",
|
||||
"doc",
|
||||
"bugfix",
|
||||
"security",
|
||||
"feature",
|
||||
]
|
||||
|
||||
for allowedType in ALLOWED_COMMIT_TYPES:
|
||||
if commitmsg.startswith(allowedType + ":"):
|
||||
print("Allowing commit type: ", allowedType)
|
||||
sys.exit(0)
|
||||
|
||||
print("Commit message should start with commit type. One of: ", ", ".join(ALLOWED_COMMIT_TYPES))
|
||||
sys.exit(1)
|
||||
44
.github/workflows/build-snapshot.yml
vendored
Normal file
44
.github/workflows/build-snapshot.yml
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
name: "Build Snapshot"
|
||||
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
build-snapshot:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
platforms: arm64,arm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.16.0'
|
||||
|
||||
- name: grpc
|
||||
run: make grpc
|
||||
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --snapshot --rm-dist
|
||||
|
||||
- name: Archive binaries
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/OliveTin*.*
|
||||
|
||||
- name: Archive integration tests
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: integration-tests
|
||||
path: integration-tests
|
||||
55
.github/workflows/build-tag.yml
vendored
Normal file
55
.github/workflows/build-tag.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
name: "Build Tag"
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
build-tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
id: qemu
|
||||
uses: docker/setup-qemu-action@v1
|
||||
with:
|
||||
image: tonistiigi/binfmt:latest
|
||||
platforms: arm64,arm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.16.0'
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_KEY }}
|
||||
|
||||
- name: grpc
|
||||
run: make grpc
|
||||
|
||||
- name: goreleaser
|
||||
uses: goreleaser/goreleaser-action@v2
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --rm-dist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
|
||||
- name: Archive binaries
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: dist
|
||||
path: dist/OliveTin*.*
|
||||
|
||||
- name: Archive integration tests
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: integration-tests
|
||||
path: integration-tests
|
||||
6
.github/workflows/codeql-analysis.yml
vendored
6
.github/workflows/codeql-analysis.yml
vendored
@@ -13,6 +13,12 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'cmd/**'
|
||||
- 'internal/**'
|
||||
- 'webui/**'
|
||||
- 'integration-tests/**'
|
||||
- 'OliveTin.proto'
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
|
||||
15
.github/workflows/codestyle.yml
vendored
15
.github/workflows/codestyle.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: "Codestyle checks"
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- 'cmd/**'
|
||||
- 'internal/**'
|
||||
- 'webui/**'
|
||||
- 'integration-tests/**'
|
||||
- 'OliveTin.proto'
|
||||
|
||||
|
||||
jobs:
|
||||
codestyle:
|
||||
@@ -11,6 +19,11 @@ jobs:
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: '^1.16.0'
|
||||
|
||||
- name: deps
|
||||
run: make grpc
|
||||
|
||||
- name: daemon
|
||||
run: make daemon-codestyle
|
||||
|
||||
23
.github/workflows/jenkins-rc-build.yml
vendored
23
.github/workflows/jenkins-rc-build.yml
vendored
@@ -1,23 +0,0 @@
|
||||
name: "Jenkins RC Build"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: "Reason"
|
||||
required: true
|
||||
default: "no reason given"
|
||||
|
||||
jobs:
|
||||
jenkins-trigger:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger jenkins job
|
||||
uses: appleboy/jenkins-action@master
|
||||
with:
|
||||
url: ${{ secrets.JENKINS_URL }}
|
||||
user: ${{ secrets.JENKINS_USER }}
|
||||
token: ${{ secrets.JENKINS_TOKEN }}
|
||||
job: "OliveTin/OliveTin-rc-builder"
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -3,9 +3,9 @@ webui/node_modules
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
gen/
|
||||
OliveTin
|
||||
OliveTin.armhf
|
||||
OliveTin.exe
|
||||
/OliveTin
|
||||
/OliveTin.armhf
|
||||
/OliveTin.exe
|
||||
reports
|
||||
releases/
|
||||
dist/
|
||||
|
||||
104
.goreleaser.yml
104
.goreleaser.yml
@@ -1,8 +1,8 @@
|
||||
project_name: OliveTin
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- make grpc
|
||||
- go mod download
|
||||
- rm -rf webui/node_modules
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
@@ -18,6 +18,8 @@ builds:
|
||||
|
||||
goarm:
|
||||
- 5 # For old RPIs
|
||||
- 6
|
||||
- 7
|
||||
|
||||
main: cmd/OliveTin/main.go
|
||||
|
||||
@@ -50,7 +52,7 @@ archives:
|
||||
format: tar.gz
|
||||
|
||||
files:
|
||||
- configs/config.yaml
|
||||
- config.yaml
|
||||
- LICENSE
|
||||
- README.md
|
||||
- Dockerfile
|
||||
@@ -59,11 +61,105 @@ archives:
|
||||
|
||||
replacements:
|
||||
darwin: macOS
|
||||
arm: arm32v
|
||||
|
||||
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}"
|
||||
name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}{{ .Arm }}"
|
||||
|
||||
wrap_in_directory: true
|
||||
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "docker.io/jamesread/olivetin:{{ .Tag }}-amd64"
|
||||
dockerfile: Dockerfile
|
||||
goos: linux
|
||||
goarch: amd64
|
||||
skip_push: false
|
||||
|
||||
build_flag_templates:
|
||||
- "--platform=linux/amd64"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Tag}}"
|
||||
- "--platform=linux/amd64"
|
||||
extra_files:
|
||||
- webui
|
||||
|
||||
- image_templates:
|
||||
- "docker.io/jamesread/olivetin:{{ .Tag }}-arm64"
|
||||
dockerfile: Dockerfile.arm64
|
||||
goos: linux
|
||||
goarch: arm64
|
||||
skip_push: false
|
||||
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm64"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Tag}}"
|
||||
extra_files:
|
||||
- webui
|
||||
|
||||
# This container image actually uses the goarm v5 binary, because v5 helps
|
||||
# support rpi 1's, but it seems most container images start with v7.
|
||||
- image_templates:
|
||||
- "docker.io/jamesread/olivetin:{{ .Tag }}-armv7"
|
||||
dockerfile: Dockerfile.armv7
|
||||
goos: linux
|
||||
goarch: arm
|
||||
goarm: 7
|
||||
skip_push: false
|
||||
|
||||
build_flag_templates:
|
||||
- "--platform=linux/arm/v7"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Tag}}"
|
||||
extra_files:
|
||||
- webui
|
||||
|
||||
docker_manifests:
|
||||
- name_template: docker.io/jamesread/olivetin:{{ .Version }}
|
||||
image_templates:
|
||||
- docker.io/jamesread/olivetin:{{ .Version }}-amd64
|
||||
- docker.io/jamesread/olivetin:{{ .Version }}-arm64
|
||||
- docker.io/jamesread/olivetin:{{ .Version }}-armv7
|
||||
|
||||
- name_template: docker.io/jamesread/olivetin:latest
|
||||
image_templates:
|
||||
- docker.io/jamesread/olivetin:{{ .Version }}-amd64
|
||||
- docker.io/jamesread/olivetin:{{ .Version }}-arm64
|
||||
- docker.io/jamesread/olivetin:{{ .Version }}-armv7
|
||||
|
||||
nfpms:
|
||||
- maintainer: James Read <contact@jread.com>
|
||||
description: OliveTin is a web interface for running Linux shell commands.
|
||||
homepage: https://github.com/jamesread/OliveTin
|
||||
license: AGPL-3.0
|
||||
formats:
|
||||
- deb
|
||||
- rpm
|
||||
|
||||
contents:
|
||||
- src: OliveTin.service
|
||||
dst: /etc/systemd/system/OliveTin.service
|
||||
|
||||
- src: webui
|
||||
dst: /var/www/olivetin/
|
||||
|
||||
- src: config.yaml
|
||||
dst: /etc/OliveTin/config.yaml
|
||||
type: "config|noreplace"
|
||||
|
||||
release:
|
||||
footer: |
|
||||
## Useful links
|
||||
|
||||
- [Which download do I need?](https://docs.olivetin.app/choose-package.html)
|
||||
- [Ask for help and chat with others users in the Discord community](https://discord.gg/jhYWWpNJ3v)
|
||||
|
||||
Thanks for your interest in OliveTin!
|
||||
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
FROM fedora
|
||||
FROM --platform=linux/amd64 docker.io/amd64/fedora
|
||||
|
||||
RUN useradd -rm olivetin -u 1000
|
||||
|
||||
RUN mkdir -p /config /var/www/olivetin/ && \
|
||||
RUN mkdir -p /config /var/www/olivetin \
|
||||
&& \
|
||||
dnf install -y \
|
||||
iputils \
|
||||
openssh-clients \
|
||||
docker \
|
||||
&& dnf clean all && \
|
||||
rm -rf /var/cache/yum # install ping
|
||||
rm -rf /var/cache/dnf
|
||||
|
||||
EXPOSE 1337/tcp
|
||||
|
||||
|
||||
22
Dockerfile.arm64
Normal file
22
Dockerfile.arm64
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM --platform=linux/arm64 docker.io/arm64v8/fedora
|
||||
|
||||
RUN useradd -rm olivetin -u 1000
|
||||
|
||||
RUN mkdir -p /config /var/www/olivetin \
|
||||
&& \
|
||||
dnf install -y \
|
||||
iputils \
|
||||
openssh-clients \
|
||||
&& dnf clean all && \
|
||||
rm -rf /var/cache/dnf
|
||||
|
||||
EXPOSE 1337/tcp
|
||||
|
||||
VOLUME /config
|
||||
|
||||
COPY OliveTin /usr/bin/OliveTin
|
||||
COPY webui /var/www/olivetin/
|
||||
|
||||
USER olivetin
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/OliveTin" ]
|
||||
22
Dockerfile.armv7
Normal file
22
Dockerfile.armv7
Normal file
@@ -0,0 +1,22 @@
|
||||
FROM --platform=linux/arm/v7 arm32v7/fedora:latest
|
||||
|
||||
RUN useradd -rm olivetin -u 1000
|
||||
|
||||
RUN mkdir -p /config /var/www/olivetin \
|
||||
&& \
|
||||
dnf install -y \
|
||||
iputils \
|
||||
openssh-clients \
|
||||
&& dnf clean all && \
|
||||
rm -rf /var/cache/dnf
|
||||
|
||||
EXPOSE 1337/tcp
|
||||
|
||||
VOLUME /config
|
||||
|
||||
COPY OliveTin /usr/bin/OliveTin
|
||||
COPY webui /var/www/olivetin/
|
||||
|
||||
USER olivetin
|
||||
|
||||
ENTRYPOINT [ "/usr/bin/OliveTin" ]
|
||||
14
Makefile
14
Makefile
@@ -15,21 +15,26 @@ daemon-codestyle:
|
||||
go fmt ./...
|
||||
go vet ./...
|
||||
gocyclo -over 4 cmd internal
|
||||
gocritic check ./...
|
||||
|
||||
daemon-unittests:
|
||||
mkdir -p reports
|
||||
go test ./... -coverprofile reports/unittests.out
|
||||
go tool cover -html=reports/unittests.out -o reports/unittests.html
|
||||
|
||||
githooks:
|
||||
cp -v .githooks/* .git/hooks/
|
||||
|
||||
go-tools:
|
||||
go install "github.com/bufbuild/buf/cmd/buf"
|
||||
go install "github.com/fzipp/gocyclo/cmd/gocyclo"
|
||||
go install "github.com/go-critic/go-critic/cmd/gocritic"
|
||||
go install "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
|
||||
go install "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
|
||||
go install "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
|
||||
go install "google.golang.org/protobuf/cmd/protoc-gen-go"
|
||||
go install "github.com/fzipp/gocyclo/cmd/gocyclo"
|
||||
|
||||
grpc: go-tools
|
||||
grpc: githooks go-tools
|
||||
buf generate
|
||||
|
||||
podman-image:
|
||||
@@ -41,6 +46,11 @@ podman-container:
|
||||
podman create --name olivetin -p 1337:1337 -v /etc/OliveTin/:/config:ro olivetin
|
||||
podman start olivetin
|
||||
|
||||
integration-tests-docker-image:
|
||||
docker rm -f olivetin && docker rmi -f olivetin
|
||||
docker build -t olivetin:latest .
|
||||
docker create --name olivetin -p 1337:1337 -v `pwd`/integration-tests/configs/:/config/ olivetin
|
||||
|
||||
devrun: compile
|
||||
killall OliveTin || true
|
||||
./OliveTin &
|
||||
|
||||
52
README.md
52
README.md
@@ -2,21 +2,32 @@
|
||||
|
||||
<img alt = "project logo" src = "https://github.com/OliveTin/OliveTin/blob/main/webui/OliveTinLogo.png" align = "right" width = "160px" />
|
||||
|
||||
OliveTin is a web interface for running Linux shell commands.
|
||||
OliveTin gives **safe** and **simple** access to predefined shell commands from a web interface.
|
||||
|
||||
[](https://discord.gg/jhYWWpNJ3v)
|
||||
[](https://github.com/awesome-selfhosted/awesome-selfhosted#automation)
|
||||
[](https://bestpractices.coreinfrastructure.org/projects/5050)
|
||||
|
||||
[](https://goreportcard.com/report/github.com/OliveTin/OliveTin)
|
||||
[](https://github.com/OliveTin/OliveTin/actions/workflows/build-snapshot.yml)
|
||||
|
||||
## Use cases
|
||||
|
||||
Some example **use cases**;
|
||||
**Safely** give access to commands, for less technical people;
|
||||
|
||||
1. Give controlled access to run shell commands to less technical folks who cannot be trusted with SSH. I use this so my family can `podman restart plex` without asking me, and without giving them shell access!
|
||||
2. Great for home automation tablets stuck on walls around your house - I use this to turn Hue lights on and off for example.
|
||||
3. Sometimes SSH access isn't possible to a server, or you are feeling too lazy to type a long command you run regularly! I use this to send Wake on Lan commands to servers around my house.
|
||||
* eg: Give your family a button to `podman restart plex`
|
||||
* eg: Give junior admins a simple web form with dropdowns, to start your custom script. `backupScript.sh --folder {{ customerName }}`
|
||||
* eg: Enable SSH access to the server for the next 20 mins `firewall-cmd --add-service ssh --timeout 20m`
|
||||
|
||||
[Join the community on Discord.](https://discord.gg/jhYWWpNJ3v)
|
||||
**Simplify** complex commands, make them accessible and repeatable;
|
||||
|
||||
## YouTube video demo (6 mins)
|
||||
* eg: Expose complex commands on touchscreen tablets stuck on walls around your house. `wake-on-lan aa:bb:cc:11:22:33`
|
||||
* eg: Run long running on your servers from your cell phone. `dnf update -y`
|
||||
* eg: Define complex commands with lots of preset arguments, and turn a few arguments into dropdown select boxes. `docker rm {{ container }} && docker create {{ container }} && docker start {{ container }}`
|
||||
|
||||
[Join the community on Discord](https://discord.gg/jhYWWpNJ3v) to talk with other users about use cases, or to ask for support in getting started.
|
||||
|
||||
## YouTube demo video (6 mins)
|
||||
|
||||
[](https://www.youtube.com/watch?v=Ej6NM9rmZtk)
|
||||
|
||||
@@ -66,20 +77,31 @@ logLevel: "INFO"
|
||||
actions:
|
||||
# Docs: https://docs.olivetin.app/action-container-control.html
|
||||
- title: Restart Plex
|
||||
icon: smile
|
||||
icon: restart
|
||||
shell: docker restart plex
|
||||
|
||||
# This will send 1 ping
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
- title: Ping host
|
||||
shell: ping {{ host }} -c {{ count }}
|
||||
icon: ping
|
||||
arguments:
|
||||
- name: host
|
||||
title: host
|
||||
type: ascii_identifier
|
||||
default: example.com
|
||||
|
||||
- name: count
|
||||
title: Count
|
||||
type: int
|
||||
default: 1
|
||||
|
||||
# Restart lightdm on host "overseer"
|
||||
# Restart http on host "webserver1"
|
||||
# Docs: https://docs.olivetin.app/action-ssh.html
|
||||
- title: restart lightdm
|
||||
icon: poop
|
||||
shell: ssh root@overseer 'service lightdm restart'
|
||||
- title: restart httpd
|
||||
icon: restart
|
||||
shell: ssh root@webserver1 'service httpd restart'
|
||||
```
|
||||
|
||||
A full example config can be found at in this repository - [config.yaml](https://github.com/OliveTin/OliveTin/blob/main/var/config.yaml).
|
||||
A full example config can be found at in this repository - [config.yaml](https://github.com/OliveTin/OliveTin/blob/main/config.yaml).
|
||||
|
||||
|
||||
@@ -11,3 +11,10 @@ plugins:
|
||||
- name: grpc-gateway
|
||||
out: gen/grpc/
|
||||
opt: paths=source_relative
|
||||
|
||||
# - name: swagger
|
||||
# out: reports/swagger
|
||||
|
||||
# - name: openapiv2
|
||||
# out: reports/openapiv2
|
||||
|
||||
|
||||
@@ -63,7 +63,7 @@ func reloadConfig() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
config.Sanitize(cfg)
|
||||
cfg.Sanitize()
|
||||
}
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -10,25 +10,32 @@ logLevel: "INFO"
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
# This will run a simple script that you create.
|
||||
- title: Run backup script
|
||||
shell: /opt/backupScript.sh
|
||||
icon: backup
|
||||
|
||||
# This will send 1 ping (-c 1)
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
- title: Ping host
|
||||
shell: ping {{ host }} -c {{ count }}
|
||||
icon: ping
|
||||
arguments:
|
||||
- name: host
|
||||
title: host
|
||||
type: ascii_identifier
|
||||
default: example.com
|
||||
|
||||
- name: count
|
||||
title: Count
|
||||
type: int
|
||||
default: 1
|
||||
|
||||
# Restart lightdm on host "overseer"
|
||||
# Restart lightdm on host "server1"
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: restart lightdm
|
||||
icon: poop
|
||||
shell: ssh root@overseer 'service lightdm restart'
|
||||
|
||||
- title: sleep 2 seconds
|
||||
shell: sleep 2
|
||||
icon: "🥱"
|
||||
|
||||
- title: sleep 5 seconds (timeout)
|
||||
shell: sleep 5
|
||||
icon: "😪"
|
||||
- title: restart httpd
|
||||
icon: restart
|
||||
shell: ssh root@server1 'service httpd restart'
|
||||
|
||||
# OliveTin can run long-running jobs like Ansible playbooks.
|
||||
#
|
||||
@@ -47,7 +54,24 @@ actions:
|
||||
# see the docs below.
|
||||
#
|
||||
# Docs: https://docs.olivetin.app/action-container-control.html
|
||||
- title: Restart Plex
|
||||
icon: smile
|
||||
shell: docker restart plex
|
||||
- title: Restart Docker Container
|
||||
icon: restart
|
||||
shell: docker restart {{ container }}
|
||||
arguments:
|
||||
- name: container
|
||||
title: Container name
|
||||
choices:
|
||||
- value: plex
|
||||
- value: traefik
|
||||
- value: grafana
|
||||
|
||||
- title: Slow Script
|
||||
shell: sleep 3
|
||||
timeout: 5
|
||||
icon: "🥱"
|
||||
|
||||
- title: Broken Script (timeout)
|
||||
shell: sleep 5
|
||||
timeout: 5
|
||||
icon: "😪"
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
# There is a built-in micro proxy that will host the webui and REST API all on
|
||||
# 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
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
# Choose from INFO (default), WARN and DEBUG
|
||||
logLevel: "INFO"
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
# This will send 1 ping (-c 1)
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
icon: ping
|
||||
|
||||
# Restart lightdm on host "overseer"
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: restart lightdm
|
||||
icon: poop
|
||||
shell: ssh root@overseer 'service lightdm restart'
|
||||
|
||||
- title: sleep 2 seconds
|
||||
shell: sleep 2
|
||||
icon: "🥱"
|
||||
|
||||
- title: sleep 5 seconds (timeout)
|
||||
shell: sleep 5
|
||||
icon: "😪"
|
||||
|
||||
# OliveTin can run long-running jobs like Ansible playbooks.
|
||||
#
|
||||
# For such jobs, you will need to install ansible-playbook on the host where
|
||||
# you are running OliveTin, or in the container.
|
||||
#
|
||||
# You probably want a much longer timeout as well (so that ansible completes).
|
||||
- title: "Run Ansible Playbook"
|
||||
icon: "🇦"
|
||||
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
|
||||
timeout: 120
|
||||
|
||||
# OliveTin can control containers - docker is just a command line app.
|
||||
#
|
||||
# However, if you are running in a container you will need to do some setup,
|
||||
# see the docs below.
|
||||
#
|
||||
# Docs: https://docs.olivetin.app/action-container-control.html
|
||||
- title: Restart Plex
|
||||
icon: smile
|
||||
shell: docker restart plex
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# There is a built-in micro proxy that will host the webui and REST API all on
|
||||
# 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
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
hideNavigation: true
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
- title: Ping example.com
|
||||
shell: ping example.com -c 1
|
||||
icon: ping
|
||||
@@ -1,24 +0,0 @@
|
||||
# WARNING
|
||||
# This is considered an advanced installation of OliveTin, and 99% of users
|
||||
# probably won't need this configuration file. If you're just getting started
|
||||
# with OliveTin, don't use this.
|
||||
# WARNING
|
||||
#
|
||||
# This tells OliveTin to not spawn the internal micro reverse proxy.
|
||||
#
|
||||
# This gives you more fine grained control, but requires quite a bit more setup.
|
||||
# Most users will set this to "true" and use the built-in micro reverse proxy.
|
||||
useSingleHTTPFrontend: false
|
||||
|
||||
# The WebUI is simply a static webserver. OliveTin comes with one builtin to
|
||||
# make things easy, but you can also host the "webui" directory on a static
|
||||
# webserver.
|
||||
#listenAddressWebUI: 0.0.0.0:1340
|
||||
|
||||
# The REST API is used by the WebUI.
|
||||
#listenAddressRestActions: 0.0.0.0:1338
|
||||
|
||||
# The gRPC API is unsed by the WebUI, and can also be limited to localhost:1339.
|
||||
#listenAddressGrpcActions: 0.0.0.0:1339
|
||||
|
||||
|
||||
3
go.mod
3
go.mod
@@ -8,10 +8,13 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.4.9
|
||||
github.com/fzipp/gocyclo v0.3.1
|
||||
github.com/go-co-op/gocron v1.6.2
|
||||
github.com/go-critic/go-critic v0.6.1
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.5.0
|
||||
github.com/sirupsen/logrus v1.8.1
|
||||
github.com/spf13/viper v1.8.1
|
||||
github.com/stretchr/testify v1.7.0
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b // indirect
|
||||
golang.org/x/tools v0.1.7 // indirect
|
||||
google.golang.org/genproto v0.0.0-20210617175327-b9e0b3197ced
|
||||
google.golang.org/grpc v1.40.0-dev.0.20210708170655-30dfb4b933a5
|
||||
google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0
|
||||
|
||||
49
go.sum
49
go.sum
@@ -87,9 +87,30 @@ github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-co-op/gocron v1.6.2 h1:x5g1tWnWcXIZesdosJJcbziRi4XG6tKB92yKLUpoBkU=
|
||||
github.com/go-co-op/gocron v1.6.2/go.mod h1:DbJm9kdgr1sEvWpHCA7dFFs/PGHPMil9/97EXCRPr4k=
|
||||
github.com/go-critic/go-critic v0.6.1 h1:lS8B9LH/VVsvQQP7Ao5TJyQqteVKVs3E4dXiHMyubtI=
|
||||
github.com/go-critic/go-critic v0.6.1/go.mod h1:SdNCfU0yF3UBjtaZGw6586/WocupMOJuiqgom5DsQxM=
|
||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||
github.com/go-toolsmith/astcast v1.0.0 h1:JojxlmI6STnFVG9yOImLeGREv8W2ocNUM+iOhR6jE7g=
|
||||
github.com/go-toolsmith/astcast v1.0.0/go.mod h1:mt2OdQTeAQcY4DQgPSArJjHCcOwlX+Wl/kwN+LbLGQ4=
|
||||
github.com/go-toolsmith/astcopy v1.0.0 h1:OMgl1b1MEpjFQ1m5ztEO06rz5CUd3oBv9RF7+DyvdG8=
|
||||
github.com/go-toolsmith/astcopy v1.0.0/go.mod h1:vrgyG+5Bxrnz4MZWPF+pI4R8h3qKRjjyvV/DSez4WVQ=
|
||||
github.com/go-toolsmith/astequal v1.0.0/go.mod h1:H+xSiq0+LtiDC11+h1G32h7Of5O3CYFJ99GVbS5lDKY=
|
||||
github.com/go-toolsmith/astequal v1.0.1 h1:JbSszi42Jiqu36Gnf363HWS9MTEAz67vTQLponh3Moc=
|
||||
github.com/go-toolsmith/astequal v1.0.1/go.mod h1:4oGA3EZXTVItV/ipGiOx7NWkY5veFfcsOJVS2YxltLw=
|
||||
github.com/go-toolsmith/astfmt v1.0.0 h1:A0vDDXt+vsvLEdbMFJAUBI/uTbRw1ffOPnxsILnFL6k=
|
||||
github.com/go-toolsmith/astfmt v1.0.0/go.mod h1:cnWmsOAuq4jJY6Ct5YWlVLmcmLMn1JUPuQIHCY7CJDw=
|
||||
github.com/go-toolsmith/astinfo v0.0.0-20180906194353-9809ff7efb21/go.mod h1:dDStQCHtmZpYOmjRP/8gHHnCCch3Zz3oEgCdZVdtweU=
|
||||
github.com/go-toolsmith/astp v1.0.0 h1:alXE75TXgcmupDsMK1fRAy0YUzLzqPVvBKoyWV+KPXg=
|
||||
github.com/go-toolsmith/astp v1.0.0/go.mod h1:RSyrtpVlfTFGDYRbrjyWP1pYu//tSFcvdYrA8meBmLI=
|
||||
github.com/go-toolsmith/pkgload v1.0.0 h1:4DFWWMXVfbcN5So1sBNW9+yeiMqLFGl1wFLTL5R0Tgg=
|
||||
github.com/go-toolsmith/pkgload v1.0.0/go.mod h1:5eFArkbO80v7Z0kdngIxsRXRMTaX4Ilcwuh3clNrQJc=
|
||||
github.com/go-toolsmith/strparse v1.0.0 h1:Vcw78DnpCAKlM20kSbAyO4mPfJn/lyYA4BJUDxe2Jb4=
|
||||
github.com/go-toolsmith/strparse v1.0.0/go.mod h1:YI2nUKP9YGZnL/L1/DLFBfixrcjslWct4wyljWhSRy8=
|
||||
github.com/go-toolsmith/typep v1.0.0/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU=
|
||||
github.com/go-toolsmith/typep v1.0.2 h1:8xdsa1+FSIH/RhEkgnD1j2CJOy5mNllW1Q9tRiYwvlk=
|
||||
github.com/go-toolsmith/typep v1.0.2/go.mod h1:JSQCQMUPdRlMZFswiq3TGpNp1GMktqkR2Ns5AIQkATU=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw=
|
||||
github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU=
|
||||
@@ -162,6 +183,7 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
|
||||
github.com/google/pprof v0.0.0-20210122040257-d980be63207e/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.0.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
@@ -216,10 +238,13 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e h1:9MlwzLdW7QSDrhDjFlsEYmxpFyIoXmYRon3dt0io31k=
|
||||
github.com/logrusorgru/aurora v0.0.0-20181002194514-a7b3b318ed4e/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
|
||||
github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
|
||||
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
|
||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
|
||||
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
|
||||
github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
@@ -235,6 +260,7 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/nishanths/predeclared v0.0.0-20200524104333-86fad755b4d3/go.mod h1:nt3d53pc1VYcphSCIaYAJtnPYnr3Zyn8fMq2wvPGPso=
|
||||
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
|
||||
github.com/pborman/uuid v1.2.0/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k=
|
||||
github.com/pelletier/go-toml v1.9.3 h1:zeC5b1GviRUyKYd6OJPvBU/mcVDVoL1OhT17FCt5dSQ=
|
||||
github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
@@ -247,6 +273,16 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||
github.com/quasilyte/go-consistent v0.0.0-20190521200055-c6f3937de18c/go.mod h1:5STLWrekHfjyYwxBRVRXNOSewLJ3PWfDJd1VyTS21fI=
|
||||
github.com/quasilyte/go-ruleguard v0.3.1-0.20210203134552-1b5a410e1cc8/go.mod h1:KsAh3x0e7Fkpgs+Q9pNLS5XpFSvYCEVl5gP9Pp1xp30=
|
||||
github.com/quasilyte/go-ruleguard v0.3.13 h1:O1G41cq1jUr3cJmqp7vOUT0SokqjzmS9aESWJuIDRaY=
|
||||
github.com/quasilyte/go-ruleguard v0.3.13/go.mod h1:Ul8wwdqR6kBVOCt2dipDBkE+T6vAV/iixkrKuRTN1oQ=
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.0/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
|
||||
github.com/quasilyte/go-ruleguard/dsl v0.3.10/go.mod h1:KeCP03KrjuSO0H1kTuZQCWlQPulDV6YMIXmpQss17rU=
|
||||
github.com/quasilyte/go-ruleguard/rules v0.0.0-20201231183845-9e62ed36efe1/go.mod h1:7JTjp89EGyU1d6XfBiXihJNG37wB2VRkd125Q1u7Plc=
|
||||
github.com/quasilyte/go-ruleguard/rules v0.0.0-20210428214800-545e0d2e0bf7/go.mod h1:4cgAphtvu7Ftv7vOT2ZOYhC6CvBxZixcasr8qIOTA50=
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95 h1:L8QM9bvf68pVdQ3bCFZMDmnt9yqcMBro1pC7F+IPYMY=
|
||||
github.com/quasilyte/regex/syntax v0.0.0-20200407221936-30656e2c4a95/go.mod h1:rlzQ04UMyJXu/aOvhd8qT+hvDrFpiwqp8MRXDY9szc0=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
|
||||
@@ -291,6 +327,7 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
|
||||
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
|
||||
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
|
||||
@@ -355,6 +392,7 @@ golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2 h1:Gz96sIWK3OalVv/I/qNygP42zyoKp3xptRVCWRFEBvo=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -392,6 +430,7 @@ golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a h1:bRuuGXV8wwSdGTB+CtJf+FjgO1APK1CoO39T4BN/XBw=
|
||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
@@ -463,8 +502,10 @@ golang.org/x/sys v0.0.0-20210403161142-5e06dd20ab57/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf h1:2ucpDCmfkl8Bd/FsLtiD653Wf96cW37s+iGx93zsu4k=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210823070655-63515b42dcdf/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b h1:1VkfZQv42XQlA/jchYumAnv1UPo6RgF9rJFkTgZIxO4=
|
||||
golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b h1:9zKuko04nR4gjZ4+DNjHqRlAJqbJETHwiNKDqTfOjfE=
|
||||
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -482,6 +523,7 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190110163146-51295c7ec13a/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
@@ -526,18 +568,21 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY
|
||||
golang.org/x/tools v0.0.0-20200717024301-6ddee64345a6/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200812195022-5ae4c3c160a0/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
|
||||
golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE=
|
||||
golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20201230224404-63754364767c/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.3/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
|
||||
golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.7 h1:6j8CgantCy3yc8JGBqkDLMKWqZ0RDU2g1HVgacojGWQ=
|
||||
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
1
integration-tests/.gitignore
vendored
1
integration-tests/.gitignore
vendored
@@ -1,2 +1,3 @@
|
||||
results
|
||||
node_modules
|
||||
.vagrant
|
||||
|
||||
@@ -1,11 +1,3 @@
|
||||
container:
|
||||
rm -rf *.tar.gz
|
||||
mv dist/*.tar.gz ./
|
||||
tar xavf OliveTin-*linux-amd64.tar.gz
|
||||
docker rm -f olivetin && docker rmi -f olivetin
|
||||
docker build -t olivetin:latest OliveTin-*linux-amd64/
|
||||
docker create --name olivetin -p 1337:1337 -v `pwd`/config/:/config/ olivetin
|
||||
|
||||
cypress:
|
||||
npm install
|
||||
./cypressRun.sh "general"
|
||||
|
||||
35
integration-tests/Vagrantfile
vendored
Normal file
35
integration-tests/Vagrantfile
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# This Vagrantfile is designed to be used with artifacts that have been built by goreleaser.
|
||||
# (eg, snapshot builds on GitHub)
|
||||
|
||||
|
||||
Vagrant.configure("2") do |config|
|
||||
config.vm.box = "generic/centos8"
|
||||
config.vm.provision "shell", inline: "mkdir /etc/OliveTin && chmod o+w /etc/OliveTin/", privileged: true
|
||||
config.vm.provision "file", source: "configs/config.general.yaml/.", destination: "/etc/OliveTin/config.yaml"
|
||||
|
||||
config.vm.provider :libvirt do |libvirt|
|
||||
libvirt.management_network_device = 'virbr0'
|
||||
end
|
||||
|
||||
config.vm.define :f34 do |f34|
|
||||
f34.vm.box = "generic/fedora34"
|
||||
f34.vm.provision "file", source: "/opt/OliveTin-vagrant/linux_amd64_rpm/.", destination: "."
|
||||
f34.vm.provision "shell", inline: "rpm -U OliveTin* && systemctl enable --now OliveTin && systemctl disable --now firewalld"
|
||||
end
|
||||
|
||||
config.vm.define :debian do |debian|
|
||||
debian.vm.box = "generic/debian10"
|
||||
debian.vm.provision "file", source: "/opt/OliveTin-vagrant/linux_amd64_deb/.", destination: "."
|
||||
debian.vm.provision "shell", inline: "dpkg --force-confold -i OliveTin* && systemctl enable --now OliveTin"
|
||||
end
|
||||
|
||||
config.vm.define :ubuntu do |ubuntu|
|
||||
ubuntu.vm.box = "generic/ubuntu2110"
|
||||
ubuntu.vm.provision "file", source: "/opt/OliveTin-vagrant/linux_amd64_deb/.", destination: "."
|
||||
ubuntu.vm.provision "shell", inline: "dpkg --force-confold -i OliveTin* && systemctl enable --now OliveTin && systemctl disable --now firewalld"
|
||||
end
|
||||
|
||||
# TODO
|
||||
#
|
||||
|
||||
end
|
||||
@@ -1,53 +0,0 @@
|
||||
# There is a built-in micro proxy that will host the webui and REST API all on
|
||||
# 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
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
# Choose from INFO (default), WARN and DEBUG
|
||||
logLevel: "DEBUG"
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
# This will send 1 ping (-c 1)
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
icon: ping
|
||||
|
||||
# Restart lightdm on host "overseer"
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: restart lightdm
|
||||
icon: poop
|
||||
shell: ssh root@overseer 'service lightdm restart'
|
||||
|
||||
- title: sleep 2 seconds
|
||||
shell: sleep 2
|
||||
icon: "🥱"
|
||||
|
||||
- title: sleep 5 seconds (timeout)
|
||||
shell: sleep 5
|
||||
icon: "😪"
|
||||
|
||||
# OliveTin can run long-running jobs like Ansible playbooks.
|
||||
#
|
||||
# For such jobs, you will need to install ansible-playbook on the host where
|
||||
# you are running OliveTin, or in the container.
|
||||
#
|
||||
# You probably want a much longer timeout as well (so that ansible completes).
|
||||
- title: "Run Ansible Playbook"
|
||||
icon: "🇦"
|
||||
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
|
||||
timeout: 120
|
||||
|
||||
# OliveTin can control containers - docker is just a command line app.
|
||||
#
|
||||
# However, if you are running in a container you will need to do some setup,
|
||||
# see the docs below.
|
||||
#
|
||||
# Docs: https://docs.olivetin.app/action-container-control.html
|
||||
- title: Restart Plex
|
||||
icon: smile
|
||||
shell: docker restart plex
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
# There is a built-in micro proxy that will host the webui and REST API all on
|
||||
# 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
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
hideNavigation: true
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
- title: Ping example.com
|
||||
shell: ping example.com -c 1
|
||||
icon: ping
|
||||
@@ -1,53 +0,0 @@
|
||||
# There is a built-in micro proxy that will host the webui and REST API all on
|
||||
# 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
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
# Choose from INFO (default), WARN and DEBUG
|
||||
logLevel: "DEBUG"
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
# This will send 1 ping (-c 1)
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
icon: ping
|
||||
|
||||
# Restart lightdm on host "overseer"
|
||||
# Docs: https://docs.olivetin.app/action-ping.html
|
||||
- title: restart lightdm
|
||||
icon: poop
|
||||
shell: ssh root@overseer 'service lightdm restart'
|
||||
|
||||
- title: sleep 2 seconds
|
||||
shell: sleep 2
|
||||
icon: "🥱"
|
||||
|
||||
- title: sleep 5 seconds (timeout)
|
||||
shell: sleep 5
|
||||
icon: "😪"
|
||||
|
||||
# OliveTin can run long-running jobs like Ansible playbooks.
|
||||
#
|
||||
# For such jobs, you will need to install ansible-playbook on the host where
|
||||
# you are running OliveTin, or in the container.
|
||||
#
|
||||
# You probably want a much longer timeout as well (so that ansible completes).
|
||||
- title: "Run Ansible Playbook"
|
||||
icon: "🇦"
|
||||
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
|
||||
timeout: 120
|
||||
|
||||
# OliveTin can control containers - docker is just a command line app.
|
||||
#
|
||||
# However, if you are running in a container you will need to do some setup,
|
||||
# see the docs below.
|
||||
#
|
||||
# Docs: https://docs.olivetin.app/action-container-control.html
|
||||
- title: Restart Plex
|
||||
icon: smile
|
||||
shell: docker restart plex
|
||||
|
||||
35
integration-tests/configs/config.general.yaml
Normal file
35
integration-tests/configs/config.general.yaml
Normal file
@@ -0,0 +1,35 @@
|
||||
#
|
||||
# Integration Test Config: General
|
||||
#
|
||||
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
logLevel: "DEBUG"
|
||||
checkForUpdates: false
|
||||
|
||||
actions:
|
||||
- title: Ping Google.com
|
||||
shell: ping google.com -c 1
|
||||
icon: ping
|
||||
|
||||
- title: restart lightdm
|
||||
icon: poop
|
||||
shell: ssh root@overseer 'service lightdm restart'
|
||||
|
||||
- title: sleep 2 seconds
|
||||
shell: sleep 2
|
||||
icon: "🥱"
|
||||
|
||||
- title: sleep 5 seconds (timeout)
|
||||
shell: sleep 5
|
||||
icon: "😪"
|
||||
|
||||
- title: "Run Ansible Playbook"
|
||||
icon: "🇦"
|
||||
shell: ansible-playbook -i /etc/hosts /root/myRepo/myPlaybook.yaml
|
||||
timeout: 120
|
||||
|
||||
- title: Restart Plex
|
||||
icon: smile
|
||||
shell: docker restart plex
|
||||
|
||||
14
integration-tests/configs/config.hiddenNav.yaml
Normal file
14
integration-tests/configs/config.hiddenNav.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
#
|
||||
# Integration Test Config: General
|
||||
#
|
||||
|
||||
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
|
||||
|
||||
hideNavigation: true
|
||||
checkForUpdates: false
|
||||
|
||||
# Actions (buttons) to show up on the WebUI:
|
||||
actions:
|
||||
- title: Ping example.com
|
||||
shell: ping example.com -c 1
|
||||
icon: ping
|
||||
@@ -12,7 +12,7 @@ describe('Homepage rendering', () => {
|
||||
})
|
||||
|
||||
it('Switcher navigation is visible', () => {
|
||||
cy.get('#switcher').then($el => {
|
||||
cy.get('#sectionSwitcher').then($el => {
|
||||
expect(Cypress.dom.isHidden($el)).to.be.false
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ describe('Hidden Nav', () => {
|
||||
})
|
||||
|
||||
it('Switcher navigation is hidden', () => {
|
||||
cy.get('#switcher').then($el => {
|
||||
cy.get('#sectionSwitcher').then($el => {
|
||||
expect(Cypress.dom.isHidden($el)).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -4,7 +4,7 @@ set -o xtrace
|
||||
|
||||
echo "Running config $1"
|
||||
|
||||
cp -f ../configs/config.$1.yaml ./config/config.yaml
|
||||
cp -f ./configs/config.$1.yaml ./configs/config.yaml
|
||||
docker start olivetin
|
||||
NO_COLOR=1 ./node_modules/.bin/cypress run --headless -s cypress/integration/$1/* || true
|
||||
docker kill olivetin
|
||||
|
||||
12
integration-tests/cypressRunVagrant.sh
Executable file
12
integration-tests/cypressRunVagrant.sh
Executable file
@@ -0,0 +1,12 @@
|
||||
#!/bin/bash
|
||||
|
||||
# args:
|
||||
# $1: The Vagrant VM to test against. If blank and only one VM is provisioned, it will use that.
|
||||
|
||||
IP=$(vagrant ssh-config $1 | grep HostName | awk '{print $2}')
|
||||
BASE_URL="http://$IP:1337/"
|
||||
|
||||
echo "IP: $IP, BaseURL: $BASE_URL"
|
||||
|
||||
# Only run the general test, as we cannot easily switch out configs in VMs yet.
|
||||
./node_modules/.bin/cypress run --headless -c baseUrl=$BASE_URL -s cypress/integration/general/*
|
||||
@@ -6,10 +6,12 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// User respresents a person.
|
||||
type User struct {
|
||||
Username string
|
||||
}
|
||||
|
||||
// IsAllowedExec checks if a User is allowed to execute an Action
|
||||
func IsAllowedExec(cfg *config.Config, user *User, action *config.Action) bool {
|
||||
canExec := cfg.DefaultPermissions.Exec
|
||||
|
||||
@@ -40,6 +42,7 @@ func IsAllowedExec(cfg *config.Config, user *User, action *config.Action) bool {
|
||||
return canExec
|
||||
}
|
||||
|
||||
// IsAllowedView checks if a User is allowed to view an Action
|
||||
func IsAllowedView(cfg *config.Config, user *User, action *config.Action) bool {
|
||||
canView := cfg.DefaultPermissions.View
|
||||
|
||||
@@ -75,6 +78,8 @@ func isUserInGroup(user *User, usergroup string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// UserFromContext tries to find a user from a grpc context - obviously this is
|
||||
// a stub at the moment.
|
||||
func UserFromContext(ctx context.Context) *User {
|
||||
return &User{
|
||||
Username: "Guest",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package config
|
||||
|
||||
import ()
|
||||
|
||||
// Action represents the core functionality of OliveTin - commands that show up
|
||||
// as buttons in the UI.
|
||||
type Action struct {
|
||||
ID string
|
||||
Title string
|
||||
@@ -13,6 +13,7 @@ type Action struct {
|
||||
Arguments []ActionArgument
|
||||
}
|
||||
|
||||
// ActionArgument objects appear on Actions.
|
||||
type ActionArgument struct {
|
||||
Name string
|
||||
Title string
|
||||
@@ -21,6 +22,7 @@ type ActionArgument struct {
|
||||
Choices []ActionArgumentChoice
|
||||
}
|
||||
|
||||
// ActionArgumentChoice represents a predefined choice for an argument.
|
||||
type ActionArgumentChoice struct {
|
||||
Value string
|
||||
Title string
|
||||
@@ -35,17 +37,20 @@ type Entity struct {
|
||||
CSS map[string]string
|
||||
}
|
||||
|
||||
// PermissionsEntry defines what users can do with an action.
|
||||
type PermissionsEntry struct {
|
||||
Usergroup string
|
||||
View bool
|
||||
Exec bool
|
||||
}
|
||||
|
||||
// DefaultPermissions will be used when no PermissionsEntry overrides it.
|
||||
type DefaultPermissions struct {
|
||||
View bool
|
||||
Exec bool
|
||||
}
|
||||
|
||||
// UserGroup is a group of users.
|
||||
type UserGroup struct {
|
||||
Name string
|
||||
Members []string
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package config
|
||||
|
||||
// FindAction will return a action if there is a match on Title
|
||||
func (cfg *Config) FindAction(actionTitle string) *Action {
|
||||
for _, action := range cfg.Actions {
|
||||
if action.Title == actionTitle {
|
||||
@@ -9,3 +10,14 @@ func (cfg *Config) FindAction(actionTitle string) *Action {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindArg will return an arg if there is a match on Name
|
||||
func (action *Action) FindArg(name string) *ActionArgument {
|
||||
for _, arg := range action.Arguments {
|
||||
if arg.Name == name {
|
||||
return &arg
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
33
internal/config/config_helpers_test.go
Normal file
33
internal/config/config_helpers_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFindAction(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
|
||||
a1 := Action{}
|
||||
a1.Title = "a1"
|
||||
c.Actions = append(c.Actions, a1)
|
||||
|
||||
a2 := Action{
|
||||
Title: "a2",
|
||||
Arguments: []ActionArgument{
|
||||
{
|
||||
Name: "Blat",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
c.Actions = append(c.Actions, a2)
|
||||
|
||||
assert.NotNil(t, c.FindAction("a1"), "Find action a1")
|
||||
|
||||
assert.NotNil(t, c.FindAction("a2"), "Find action a2")
|
||||
assert.NotNil(t, c.FindAction("a2").FindArg("Blat"), "Find action argument")
|
||||
assert.Nil(t, c.FindAction("a2").FindArg("Blatey Cake"), "Find non-existent action argument")
|
||||
|
||||
assert.Nil(t, c.FindAction("waffles"), "Find non-existent action")
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package config
|
||||
|
||||
var emojis = map[string]string{
|
||||
"poop": "💩",
|
||||
"smile": "😀",
|
||||
"ping": "📡",
|
||||
"": "😀", // default icon
|
||||
"poop": "💩",
|
||||
"smile": "😀",
|
||||
"ping": "📡",
|
||||
"backup": "💾",
|
||||
"reboot": "⏻",
|
||||
"restart": "⏻",
|
||||
}
|
||||
|
||||
func lookupHTMLIcon(keyToLookup string) string {
|
||||
|
||||
@@ -4,47 +4,55 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
func Sanitize(cfg *Config) {
|
||||
sanitizeLogLevel(cfg)
|
||||
// Sanitize will look for common configuration issues, and fix them. For example,
|
||||
// populating undefined fields - name -> title, etc.
|
||||
func (cfg *Config) Sanitize() {
|
||||
cfg.sanitizeLogLevel()
|
||||
|
||||
//log.Infof("cfg %p", cfg)
|
||||
// log.Infof("cfg %p", cfg)
|
||||
|
||||
for idx, _ := range cfg.Actions {
|
||||
sanitizeAction(&cfg.Actions[idx])
|
||||
for idx := range cfg.Actions {
|
||||
cfg.Actions[idx].sanitize()
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeLogLevel(cfg *Config) {
|
||||
func (cfg *Config) sanitizeLogLevel() {
|
||||
if logLevel, err := log.ParseLevel(cfg.LogLevel); err == nil {
|
||||
log.Info("Setting log level to ", logLevel)
|
||||
log.SetLevel(logLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeAction(action *Action) {
|
||||
func (action *Action) sanitize() {
|
||||
if action.Timeout < 3 {
|
||||
action.Timeout = 3
|
||||
}
|
||||
|
||||
action.Icon = lookupHTMLIcon(action.Icon)
|
||||
|
||||
for idx, _ := range action.Arguments {
|
||||
sanitizeActionArgument(&action.Arguments[idx])
|
||||
for idx := range action.Arguments {
|
||||
action.Arguments[idx].sanitize()
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeActionArgument(arg *ActionArgument) {
|
||||
func (arg *ActionArgument) sanitize() {
|
||||
if arg.Title == "" {
|
||||
arg.Title = arg.Name
|
||||
}
|
||||
|
||||
sanitizeActionArgumentNoType(arg)
|
||||
for idx, choice := range arg.Choices {
|
||||
if choice.Title == "" {
|
||||
arg.Choices[idx].Title = choice.Value
|
||||
}
|
||||
}
|
||||
|
||||
arg.sanitizeNoType()
|
||||
|
||||
// TODO Validate the default against the type checker, but this creates a
|
||||
// import loop
|
||||
}
|
||||
|
||||
func sanitizeActionArgumentNoType(arg *ActionArgument) {
|
||||
func (arg *ActionArgument) sanitizeNoType() {
|
||||
if len(arg.Choices) == 0 && arg.Type == "" {
|
||||
log.WithFields(log.Fields{
|
||||
"arg": arg.Name,
|
||||
|
||||
38
internal/config/sanitize_test.go
Normal file
38
internal/config/sanitize_test.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSanitizeConfig(t *testing.T) {
|
||||
c := DefaultConfig()
|
||||
|
||||
a := Action{
|
||||
Title: "Mr Waffles",
|
||||
Arguments: []ActionArgument{
|
||||
{
|
||||
Name: "Carrots",
|
||||
Choices: []ActionArgumentChoice{
|
||||
{
|
||||
Value: "Waffle",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "foobar",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
c.Actions = append(c.Actions, a)
|
||||
c.Sanitize()
|
||||
|
||||
a2 := c.FindAction("Mr Waffles")
|
||||
|
||||
assert.NotNil(t, a2, "Found action after adding it")
|
||||
assert.Equal(t, 3, a2.Timeout, "Default timeout is set")
|
||||
assert.Equal(t, "😀", a2.Icon, "Default icon is a smiley")
|
||||
assert.Equal(t, "Carrots", a2.Arguments[0].Title, "Arg title is set to name")
|
||||
assert.Equal(t, "Waffle", a2.Arguments[0].Choices[0].Title, "Choice title is set to name")
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"bytes"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -24,6 +25,9 @@ var (
|
||||
}
|
||||
)
|
||||
|
||||
// InternalLogEntry objects are created by an Executor, and represent the final
|
||||
// state of execution (even if the command is not executed). It's designed to be
|
||||
// easily serializable.
|
||||
type InternalLogEntry struct {
|
||||
Datetime string
|
||||
Stdout string
|
||||
@@ -40,6 +44,8 @@ type InternalLogEntry struct {
|
||||
ActionIcon string
|
||||
}
|
||||
|
||||
// ExecutionRequest is a request to execute an action. It's passed to an
|
||||
// Executor. They're created from the grpcapi.
|
||||
type ExecutionRequest struct {
|
||||
ActionName string
|
||||
Arguments map[string]string
|
||||
@@ -50,33 +56,37 @@ type ExecutionRequest struct {
|
||||
finalParsedCommand string
|
||||
}
|
||||
|
||||
type ExecutorStep interface {
|
||||
type executorStep interface {
|
||||
Exec(*ExecutionRequest) bool
|
||||
}
|
||||
|
||||
// Executor represents a helper class for executing commands. It's main method
|
||||
// is ExecRequest
|
||||
type Executor struct {
|
||||
Logs []InternalLogEntry
|
||||
|
||||
chainOfCommand []ExecutorStep
|
||||
chainOfCommand []executorStep
|
||||
}
|
||||
|
||||
// DefaultExecutor returns an Executor, with a sensible "chain of command" for
|
||||
// executing actions.
|
||||
func DefaultExecutor() *Executor {
|
||||
e := Executor{}
|
||||
e.chainOfCommand = []ExecutorStep{
|
||||
StepFindAction{},
|
||||
StepAclCheck{},
|
||||
StepParseArgs{},
|
||||
StepLogStart{},
|
||||
StepExec{},
|
||||
StepLogFinish{},
|
||||
e.chainOfCommand = []executorStep{
|
||||
stepFindAction{},
|
||||
stepACLCheck{},
|
||||
stepParseArgs{},
|
||||
stepLogStart{},
|
||||
stepExec{},
|
||||
stepLogFinish{},
|
||||
}
|
||||
|
||||
return &e
|
||||
}
|
||||
|
||||
type StepFindAction struct{}
|
||||
type stepFindAction struct{}
|
||||
|
||||
func (s StepFindAction) Exec(req *ExecutionRequest) bool {
|
||||
func (s stepFindAction) Exec(req *ExecutionRequest) bool {
|
||||
actualAction := req.Cfg.FindAction(req.ActionName)
|
||||
|
||||
if actualAction == nil {
|
||||
@@ -85,7 +95,6 @@ func (s StepFindAction) Exec(req *ExecutionRequest) bool {
|
||||
}).Warnf("Action not found")
|
||||
|
||||
req.logEntry.Stderr = "Action not found"
|
||||
req.logEntry.ExitCode = -1337
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -96,9 +105,9 @@ func (s StepFindAction) Exec(req *ExecutionRequest) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type StepAclCheck struct{}
|
||||
type stepACLCheck struct{}
|
||||
|
||||
func (s StepAclCheck) Exec(req *ExecutionRequest) bool {
|
||||
func (s stepACLCheck) Exec(req *ExecutionRequest) bool {
|
||||
return acl.IsAllowedExec(req.Cfg, req.User, req.action)
|
||||
}
|
||||
|
||||
@@ -107,6 +116,9 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
|
||||
req.logEntry = &InternalLogEntry{
|
||||
Datetime: time.Now().Format("2006-01-02 15:04:05"),
|
||||
ActionTitle: req.ActionName,
|
||||
Stdout: "",
|
||||
Stderr: "",
|
||||
ExitCode: -1337, // If an Action is not actually executed, this is the default exit code.
|
||||
}
|
||||
|
||||
for _, step := range e.chainOfCommand {
|
||||
@@ -130,9 +142,9 @@ func (e *Executor) ExecRequest(req *ExecutionRequest) *pb.StartActionResponse {
|
||||
}
|
||||
}
|
||||
|
||||
type StepLogStart struct{}
|
||||
type stepLogStart struct{}
|
||||
|
||||
func (e StepLogStart) Exec(req *ExecutionRequest) bool {
|
||||
func (e stepLogStart) Exec(req *ExecutionRequest) bool {
|
||||
log.WithFields(log.Fields{
|
||||
"title": req.action.Title,
|
||||
"timeout": req.action.Timeout,
|
||||
@@ -141,9 +153,9 @@ func (e StepLogStart) Exec(req *ExecutionRequest) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type StepLogFinish struct{}
|
||||
type stepLogFinish struct{}
|
||||
|
||||
func (e StepLogFinish) Exec(req *ExecutionRequest) bool {
|
||||
func (e stepLogFinish) Exec(req *ExecutionRequest) bool {
|
||||
log.WithFields(log.Fields{
|
||||
"title": req.action.Title,
|
||||
"stdout": req.logEntry.Stdout,
|
||||
@@ -155,16 +167,14 @@ func (e StepLogFinish) Exec(req *ExecutionRequest) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type StepParseArgs struct{}
|
||||
type stepParseArgs struct{}
|
||||
|
||||
func (e StepParseArgs) Exec(req *ExecutionRequest) bool {
|
||||
func (e stepParseArgs) Exec(req *ExecutionRequest) bool {
|
||||
var err error
|
||||
|
||||
req.finalParsedCommand, err = parseActionArguments(req.action.Shell, req.Arguments, req.action)
|
||||
|
||||
if err != nil {
|
||||
req.logEntry.ExitCode = -1337
|
||||
req.logEntry.Stderr = ""
|
||||
req.logEntry.Stdout = err.Error()
|
||||
|
||||
log.Warnf(err.Error())
|
||||
@@ -175,26 +185,33 @@ func (e StepParseArgs) Exec(req *ExecutionRequest) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type StepExec struct{}
|
||||
type stepExec struct{}
|
||||
|
||||
func (e StepExec) Exec(req *ExecutionRequest) bool {
|
||||
func (e stepExec) Exec(req *ExecutionRequest) bool {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(req.action.Timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", req.finalParsedCommand)
|
||||
stdout, stderr := cmd.Output()
|
||||
var stdout bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
|
||||
if stderr != nil {
|
||||
req.logEntry.Stderr = stderr.Error()
|
||||
cmd := exec.CommandContext(ctx, "sh", "-c", req.finalParsedCommand)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
runerr := cmd.Run()
|
||||
|
||||
req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
|
||||
req.logEntry.Stdout = stdout.String()
|
||||
req.logEntry.Stderr = stderr.String()
|
||||
|
||||
if runerr != nil {
|
||||
req.logEntry.Stderr = runerr.Error() + "\n\n" + req.logEntry.Stderr
|
||||
}
|
||||
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
req.logEntry.TimedOut = true
|
||||
}
|
||||
|
||||
req.logEntry.ExitCode = int32(cmd.ProcessState.ExitCode())
|
||||
req.logEntry.Stdout = string(stdout)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -225,7 +242,7 @@ func parseActionArguments(rawShellCommand string, values map[string]string, acti
|
||||
"value": argValue,
|
||||
}).Debugf("Arg assigned")
|
||||
|
||||
rawShellCommand = strings.Replace(rawShellCommand, match[0], argValue, -1)
|
||||
rawShellCommand = strings.ReplaceAll(rawShellCommand, match[0], argValue)
|
||||
}
|
||||
|
||||
log.WithFields(log.Fields{
|
||||
@@ -236,7 +253,7 @@ func parseActionArguments(rawShellCommand string, values map[string]string, acti
|
||||
}
|
||||
|
||||
func typecheckActionArgument(name string, value string, action *config.Action) error {
|
||||
arg := findArg(name, action)
|
||||
arg := action.FindArg(name)
|
||||
|
||||
if arg == nil {
|
||||
return errors.New("Action arg not defined: " + name)
|
||||
@@ -256,16 +273,17 @@ func typecheckChoice(value string, arg *config.ActionArgument) error {
|
||||
}
|
||||
}
|
||||
|
||||
return errors.New("Arg value is not one of the predefined choices")
|
||||
return errors.New("argument value is not one of the predefined choices")
|
||||
}
|
||||
|
||||
// TypeSafetyCheck checks argument values match a specific type. The types are
|
||||
// defined in typecheckRegex, and, you guessed it, uses regex to check for allowed
|
||||
// characters.
|
||||
func TypeSafetyCheck(name string, value string, typ string) error {
|
||||
pattern, found := typecheckRegex[typ]
|
||||
|
||||
log.Infof("%v %v", pattern, typ)
|
||||
|
||||
if !found {
|
||||
return errors.New("Arg type not implemented " + typ)
|
||||
return errors.New("argument type not implemented " + typ)
|
||||
}
|
||||
|
||||
matches, _ := regexp.MatchString(pattern, value)
|
||||
@@ -277,17 +295,7 @@ func TypeSafetyCheck(name string, value string, typ string) error {
|
||||
"value": value,
|
||||
}).Warn("Arg type check safety failure")
|
||||
|
||||
return errors.New("Invalid argument, doesn't match " + typ)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func findArg(name string, action *config.Action) *config.ActionArgument {
|
||||
for _, arg := range action.Arguments {
|
||||
if arg.Name == name {
|
||||
return &arg
|
||||
}
|
||||
return errors.New("invalid argument, doesn't match " + typ)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
70
internal/executor/executor_test.go
Normal file
70
internal/executor/executor_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package executor
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
|
||||
acl "github.com/jamesread/OliveTin/internal/acl"
|
||||
config "github.com/jamesread/OliveTin/internal/config"
|
||||
)
|
||||
|
||||
func TestSanitizeUnsafe(t *testing.T) {
|
||||
assert.Nil(t, TypeSafetyCheck("", "_zomg_ c:/ haxxor ' bobby tables && rm -rf ", "very_dangerous_raw_string"))
|
||||
}
|
||||
|
||||
func testingExecutor() (*Executor, *config.Config) {
|
||||
e := DefaultExecutor()
|
||||
|
||||
cfg := config.DefaultConfig()
|
||||
|
||||
a1 := config.Action{
|
||||
Title: "Do some tickles",
|
||||
Shell: "echo 'Tickling {{ person }}'",
|
||||
Arguments: []config.ActionArgument{
|
||||
{
|
||||
Name: "person",
|
||||
Type: "ascii",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cfg.Actions = append(cfg.Actions, a1)
|
||||
cfg.Sanitize()
|
||||
|
||||
return e, cfg
|
||||
}
|
||||
|
||||
func TestCreateExecutorAndExec(t *testing.T) {
|
||||
e, cfg := testingExecutor()
|
||||
|
||||
req := ExecutionRequest{
|
||||
ActionName: "Do some tickles",
|
||||
User: &acl.User{Username: "Mr Tickle"},
|
||||
Cfg: cfg,
|
||||
Arguments: map[string]string{
|
||||
"person": "yourself",
|
||||
},
|
||||
}
|
||||
|
||||
e.ExecRequest(&req)
|
||||
|
||||
assert.NotNil(t, e, "Create an executor")
|
||||
|
||||
assert.NotNil(t, e.ExecRequest(&req), "Execute a request")
|
||||
assert.Equal(t, int32(0), req.logEntry.ExitCode, "Exit code is zero")
|
||||
}
|
||||
|
||||
func TestExecNonExistant(t *testing.T) {
|
||||
e, cfg := testingExecutor()
|
||||
|
||||
req := ExecutionRequest{
|
||||
ActionName: "Waffles",
|
||||
logEntry: &InternalLogEntry{},
|
||||
Cfg: cfg,
|
||||
}
|
||||
|
||||
e.ExecRequest(&req)
|
||||
|
||||
assert.Equal(t, int32(-1337), req.logEntry.ExitCode, "Log entry is set to an internal error code")
|
||||
assert.Equal(t, "", req.logEntry.ActionIcon, "Log entry icon wasnt found")
|
||||
}
|
||||
@@ -105,7 +105,12 @@ func Start(globalConfig *config.Config) {
|
||||
|
||||
grpcServer := grpc.NewServer()
|
||||
pb.RegisterOliveTinApiServer(grpcServer, newServer())
|
||||
grpcServer.Serve(lis)
|
||||
|
||||
err = grpcServer.Serve(lis)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Could not start gRPC Server - %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func newServer() *oliveTinAPI {
|
||||
|
||||
@@ -31,7 +31,6 @@ func startRestAPIServer(globalConfig *config.Config) error {
|
||||
defer cancel()
|
||||
|
||||
// The JSONPb.EmitDefaults is necssary, so "empty" fields are returned in JSON.
|
||||
//mux := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true}))
|
||||
mux := runtime.NewServeMux(
|
||||
runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.HTTPBodyMarshaler{
|
||||
Marshaler: &runtime.JSONPb{
|
||||
@@ -47,7 +46,9 @@ func startRestAPIServer(globalConfig *config.Config) error {
|
||||
err := gw.RegisterOliveTinApiHandlerFromEndpoint(ctx, mux, cfg.ListenAddressGrpcActions, opts)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("gw error %v", err)
|
||||
log.Errorf("Could not register REST API Handler %v", err)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
return http.ListenAndServe(cfg.ListenAddressRestActions, cors.AllowCors(mux))
|
||||
|
||||
@@ -24,11 +24,14 @@ func findWebuiDir() string {
|
||||
directoriesToSearch := []string{
|
||||
"./webui",
|
||||
"/var/www/olivetin/",
|
||||
"/etc/OliveTin/webui/",
|
||||
}
|
||||
|
||||
for _, dir := range directoriesToSearch {
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
log.Infof("Found the webui directory here: %v", dir)
|
||||
log.WithFields(log.Fields{
|
||||
"dir": dir,
|
||||
}).Infof("Found the webui directory")
|
||||
|
||||
return dir
|
||||
}
|
||||
@@ -55,7 +58,11 @@ func generateWebUISettings(w http.ResponseWriter, r *http.Request) {
|
||||
ShowNewVersions: cfg.ShowNewVersions,
|
||||
})
|
||||
|
||||
w.Write([]byte(jsonRet))
|
||||
_, err := w.Write([]byte(jsonRet))
|
||||
|
||||
if err != nil {
|
||||
log.Warnf("Could not write webui settings: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func startWebUIServer(cfg *config.Config) {
|
||||
|
||||
@@ -2,10 +2,13 @@ package httpservers
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGetWebuiDir(t *testing.T) {
|
||||
os.Chdir("../../") // go test sets the cwd to "httpservers" by default
|
||||
|
||||
dir := findWebuiDir()
|
||||
|
||||
assert.Equal(t, "./webui", dir, "Finding the webui dir")
|
||||
|
||||
@@ -21,7 +21,10 @@ type updateRequest struct {
|
||||
MachineID string
|
||||
}
|
||||
|
||||
// AvailableVersion is updated when checking with the update service.
|
||||
var AvailableVersion = "none"
|
||||
|
||||
// CurrentVersion is set by the main cmd (which is in tern set as a compile constant)
|
||||
var CurrentVersion = "?"
|
||||
|
||||
func machineID() string {
|
||||
@@ -38,13 +41,13 @@ func machineID() string {
|
||||
// StartUpdateChecker will start a job that runs periodically, checking
|
||||
// for updates.
|
||||
func StartUpdateChecker(currentVersion string, currentCommit string, cfg *config.Config) {
|
||||
CurrentVersion = currentVersion
|
||||
|
||||
if !cfg.CheckForUpdates {
|
||||
log.Warn("Update checking is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
CurrentVersion = currentVersion
|
||||
|
||||
payload := updateRequest{
|
||||
CurrentVersion: currentVersion,
|
||||
CurrentCommit: currentCommit,
|
||||
|
||||
11
tools.go
11
tools.go
@@ -3,10 +3,11 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
_ "github.com/bufbuild/buf/cmd/buf"
|
||||
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
|
||||
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
|
||||
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
|
||||
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
|
||||
_ "github.com/bufbuild/buf/cmd/buf"
|
||||
_ "github.com/fzipp/gocyclo/cmd/gocyclo"
|
||||
_ "github.com/go-critic/go-critic/cmd/gocritic"
|
||||
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway"
|
||||
_ "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2"
|
||||
_ "google.golang.org/grpc/cmd/protoc-gen-go-grpc"
|
||||
_ "google.golang.org/protobuf/cmd/protoc-gen-go"
|
||||
)
|
||||
|
||||
81
var/initscript/OliveTin
Executable file
81
var/initscript/OliveTin
Executable file
@@ -0,0 +1,81 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# olivetin Start/Stop OliveTin
|
||||
#
|
||||
# chkconfig: 2345 55 25
|
||||
# description: SSH is a protocol for secure remote shell access. \
|
||||
# This service starts up the OpenSSH server daemon.
|
||||
#
|
||||
# processname: OliveTin
|
||||
|
||||
### BEGIN INIT INFO
|
||||
# Provides: OliveTin
|
||||
# Required-Start: $local_fs $network $syslog
|
||||
# Required-Stop: $local_fs $syslog
|
||||
# Should-Start: $syslog
|
||||
# Should-Stop: $network $syslog
|
||||
# Default-Start: 2 3 4 5
|
||||
# Default-Stop: 0 1 6
|
||||
# Short-Description: Start/Stop OliveTin
|
||||
# Description: OliveTin is an app to run your Linux shell commands from a web interface.
|
||||
#
|
||||
### END INIT INFO
|
||||
|
||||
# source function library
|
||||
. /etc/rc.d/init.d/functions
|
||||
|
||||
RETVAL=0
|
||||
prog="OliveTin"
|
||||
lockfile=/var/lock/subsys/$prog
|
||||
|
||||
runlevel=$(set -- $(runlevel); eval "echo \$$#" )
|
||||
|
||||
start()
|
||||
{
|
||||
echo -n $"Starting $prog: "
|
||||
/usr/local/bin/OliveTin $OPTIONS &
|
||||
RETVAL=$?
|
||||
return $RETVAL
|
||||
}
|
||||
|
||||
stop()
|
||||
{
|
||||
echo -n $"Stopping $prog: "
|
||||
killall OliveTin
|
||||
RETVAL=$?
|
||||
return $RETVAL
|
||||
}
|
||||
|
||||
status() {
|
||||
PID=$(pidof OliveTin)
|
||||
RETVAL=$?
|
||||
|
||||
if [ $RETVAL -eq 1 ] ; then
|
||||
echo "OliveTin is stopped"
|
||||
else
|
||||
echo "OliveTin is running"
|
||||
fi
|
||||
|
||||
return $RETVAL
|
||||
}
|
||||
|
||||
restart() {
|
||||
stop
|
||||
start
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
start
|
||||
;;
|
||||
stop)
|
||||
stop
|
||||
;;
|
||||
status)
|
||||
status
|
||||
;;
|
||||
*)
|
||||
echo $"Usage: $0 {start|stop|status|restart}"
|
||||
RETVAL=2
|
||||
esac
|
||||
exit $RETVAL
|
||||
BIN
webui/OliveTinLogo-120px.png
Normal file
BIN
webui/OliveTinLogo-120px.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
BIN
webui/OliveTinLogo-180px.png
Normal file
BIN
webui/OliveTinLogo-180px.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
BIN
webui/OliveTinLogo-57px.png
Normal file
BIN
webui/OliveTinLogo-57px.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
@@ -8,17 +8,21 @@
|
||||
<title>OliveTin</title>
|
||||
<link rel = "stylesheet" type = "text/css" href = "style.css" />
|
||||
<link rel = "shortcut icon" type = "image/png" href = "OliveTinLogo.png" />
|
||||
|
||||
<link rel = "apple-touch-icon" sizes="57x57" href="OliveTinLogo-57px.png" />
|
||||
<link rel = "apple-touch-icon" sizes="120x120" href="OliveTinLogo-120px.png" />
|
||||
<link rel = "apple-touch-icon" sizes="180x180" href="OliveTinLogo-180px.png" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<main title = "main content">
|
||||
<fieldset id = "switcher">
|
||||
<fieldset id = "sectionSwitcher" title = "Sections">
|
||||
<button id = "showActions">Actions</button>
|
||||
<button id = "showLogs">Logs</button>
|
||||
</fieldset>
|
||||
|
||||
<section id = "contentLogs" title = "Logs" hidden>
|
||||
<table>
|
||||
<table title = "Logs">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Timestamp</th>
|
||||
@@ -31,10 +35,15 @@
|
||||
</section>
|
||||
|
||||
<section id = "contentActions" title = "Actions" hidden >
|
||||
<fieldset id = "rootGroup">
|
||||
<fieldset id = "rootGroup" title = "Dashboard of buttons">
|
||||
</fieldset>
|
||||
</section>
|
||||
|
||||
<noscript>
|
||||
<div class = "error">Sorry, JavaScript is required to use OliveTin.</div>
|
||||
</noscript>
|
||||
</main>
|
||||
|
||||
<footer title = "footer">
|
||||
<p><img title = "application icon" src = "OliveTinLogo.png" height = "1em" class = "logo" /> OliveTin</p>
|
||||
<p>
|
||||
@@ -46,24 +55,28 @@
|
||||
</footer>
|
||||
|
||||
<template id = "tplArgumentForm">
|
||||
<div class = "wrapper">
|
||||
<div>
|
||||
<span class = "icon" role = "icon"></span>
|
||||
<h2>Argument form</h2>
|
||||
</div>
|
||||
<form class = "actionArguments">
|
||||
<div class = "wrapper">
|
||||
<div>
|
||||
<span class = "icon" role = "icon"></span>
|
||||
<h2>Argument form</h2>
|
||||
</div>
|
||||
|
||||
<div class = "arguments"></div>
|
||||
<div class = "arguments"></div>
|
||||
|
||||
<div class = "buttons">
|
||||
<input name = "start" type = "submit" value = "Start">
|
||||
<button name = "cancel">Cancel</button>
|
||||
<div class = "buttons">
|
||||
<input name = "start" type = "submit" value = "Start">
|
||||
<button name = "cancel">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form>
|
||||
</template>
|
||||
|
||||
<template id = "tplActionButton">
|
||||
<span role = "icon" title = "button icon" class = "icon">💩</span>
|
||||
<p role = "title" class = "title">Untitled Button</p>
|
||||
<button>
|
||||
<span role = "icon" title = "button icon" class = "icon">💩</span>
|
||||
<p role = "title" class = "title">Untitled Button</p>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<template id = "tplLogRow">
|
||||
@@ -92,6 +105,28 @@
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script type = "text/javascript">
|
||||
/**
|
||||
This is the bootstrap code, which relies on very simple, old javascript
|
||||
to at least display a helpful error message if we can't use OliveTin.
|
||||
*/
|
||||
function showBigError (type, friendlyType, message) {
|
||||
clearInterval(window.buttonInterval)
|
||||
|
||||
console.error('Error ' + type + ': ', message)
|
||||
|
||||
const domErr = document.createElement('div')
|
||||
domErr.classList.add('error')
|
||||
domErr.innerHTML = '<h1>Error ' + friendlyType + '</h1><p>' + message + "</p><p><a href = 'http://docs.olivetin.app/troubleshooting.html' target = 'blank'/>OliveTin Documentation</a></p>"
|
||||
|
||||
document.getElementById('rootGroup').appendChild(domErr)
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type = "text/javascript" nomodule>
|
||||
showBigError("js-modules-not-supported", "Sorry, your browser does not support JavaScript modules.", null)
|
||||
</script>
|
||||
|
||||
<script type = "module" src = "main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
import { marshalLogsJsonToHtml } from './marshaller.js'
|
||||
import './ArgumentForm.js'
|
||||
|
||||
class ActionButton extends window.HTMLButtonElement {
|
||||
class ActionButton extends window.HTMLElement {
|
||||
constructFromJson (json) {
|
||||
this.updateIterationTimestamp = 0
|
||||
|
||||
this.title = json.title
|
||||
this.constructDomFromTemplate()
|
||||
|
||||
// Class attributes
|
||||
this.temporaryStatusMessage = null
|
||||
this.isWaiting = false
|
||||
this.actionCallUrl = window.restBaseUrl + 'StartAction'
|
||||
|
||||
this.updateFromJson(json)
|
||||
|
||||
this.onclick = () => {
|
||||
// DOM Attributes
|
||||
this.setAttribute('role', 'none')
|
||||
this.btn.title = json.title
|
||||
this.btn.onclick = () => {
|
||||
console.log(json.arguments)
|
||||
if (json.arguments.length > 0) {
|
||||
const frm = document.createElement('form', { is: 'argument-form' })
|
||||
const frm = document.createElement('argument-form')
|
||||
frm.setup(json, (args) => {
|
||||
this.startAction(args)
|
||||
})
|
||||
@@ -25,9 +31,9 @@ class ActionButton extends window.HTMLButtonElement {
|
||||
}
|
||||
}
|
||||
|
||||
this.constructTemplate()
|
||||
this.updateFromJson(json)
|
||||
|
||||
this.updateHtml()
|
||||
this.updateDom()
|
||||
|
||||
this.setAttribute('id', 'actionButton_' + json.id)
|
||||
}
|
||||
@@ -47,17 +53,17 @@ class ActionButton extends window.HTMLButtonElement {
|
||||
}
|
||||
|
||||
startAction (actionArgs) {
|
||||
this.disabled = true
|
||||
this.btn.disabled = true
|
||||
this.isWaiting = true
|
||||
this.updateHtml()
|
||||
this.classList = [] // Removes old animation classes
|
||||
this.updateDom()
|
||||
this.btn.classList = [] // Removes old animation classes
|
||||
|
||||
if (actionArgs === undefined) {
|
||||
actionArgs = []
|
||||
}
|
||||
|
||||
const startActionArgs = {
|
||||
actionName: this.title,
|
||||
actionName: this.btn.title,
|
||||
arguments: actionArgs
|
||||
}
|
||||
|
||||
@@ -92,28 +98,29 @@ class ActionButton extends window.HTMLButtonElement {
|
||||
}
|
||||
|
||||
onActionResult (cssClass, temporaryStatusMessage) {
|
||||
this.btn.disabled = false
|
||||
this.temporaryStatusMessage = '[ ' + temporaryStatusMessage + ' ]'
|
||||
this.updateHtml()
|
||||
this.classList.add(cssClass)
|
||||
this.updateDom()
|
||||
this.btn.classList.add(cssClass)
|
||||
|
||||
setTimeout(() => {
|
||||
this.classList.remove(cssClass)
|
||||
this.btn.classList.remove(cssClass)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
onActionError (err) {
|
||||
console.log('callback error', err)
|
||||
this.disabled = false
|
||||
console.error('callback error', err)
|
||||
this.btn.disabled = false
|
||||
this.isWaiting = false
|
||||
this.updateHtml()
|
||||
this.classList.add('actionFailed')
|
||||
this.updateDom()
|
||||
this.btn.classList.add('actionFailed')
|
||||
|
||||
setTimeout(() => {
|
||||
this.classList.remove('actionFailed')
|
||||
this.btn.classList.remove('actionFailed')
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
constructTemplate () {
|
||||
constructDomFromTemplate () {
|
||||
const tpl = document.getElementById('tplActionButton')
|
||||
const content = tpl.content.cloneNode(true)
|
||||
|
||||
@@ -124,11 +131,12 @@ class ActionButton extends window.HTMLButtonElement {
|
||||
|
||||
this.appendChild(content)
|
||||
|
||||
this.domTitle = this.querySelector('.title')
|
||||
this.domIcon = this.querySelector('.icon')
|
||||
this.btn = this.querySelector('button')
|
||||
this.domTitle = this.btn.querySelector('.title')
|
||||
this.domIcon = this.btn.querySelector('.icon')
|
||||
}
|
||||
|
||||
updateHtml () {
|
||||
updateDom () {
|
||||
if (this.temporaryStatusMessage != null) {
|
||||
this.domTitle.innerText = this.temporaryStatusMessage
|
||||
this.domTitle.classList.add('temporaryStatusMessage')
|
||||
@@ -138,16 +146,16 @@ class ActionButton extends window.HTMLButtonElement {
|
||||
setTimeout(() => {
|
||||
this.temporaryStatusMessage = null
|
||||
this.domTitle.classList.remove('temporaryStatusMessage')
|
||||
this.updateHtml()
|
||||
this.updateDom()
|
||||
}, 2000)
|
||||
} else if (this.isWaiting) {
|
||||
this.domTitle.innerText = 'Waiting...'
|
||||
} else {
|
||||
this.domTitle.innerText = this.title
|
||||
this.domTitle.innerText = this.btn.title
|
||||
}
|
||||
|
||||
this.domIcon.innerHTML = this.unicodeIcon
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('action-button', ActionButton, { extends: 'button' })
|
||||
window.customElements.define('action-button', ActionButton)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
class ArgumentForm extends window.HTMLFormElement {
|
||||
class ArgumentForm extends window.HTMLElement {
|
||||
setup (json, callback) {
|
||||
this.setAttribute('class', 'actionArguments')
|
||||
|
||||
@@ -140,4 +140,4 @@ class ArgumentForm extends window.HTMLFormElement {
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define('argument-form', ArgumentForm, { extends: 'form' })
|
||||
window.customElements.define('argument-form', ArgumentForm)
|
||||
|
||||
@@ -7,19 +7,20 @@ export function marshalActionButtonsJsonToHtml (json) {
|
||||
let htmlButton = document.querySelector('#actionButton_' + jsonButton.id)
|
||||
|
||||
if (htmlButton == null) {
|
||||
htmlButton = document.createElement('button', { is: 'action-button' })
|
||||
htmlButton = document.createElement('action-button')
|
||||
htmlButton.constructFromJson(jsonButton)
|
||||
|
||||
document.getElementById('rootGroup').appendChild(htmlButton)
|
||||
} else {
|
||||
htmlButton.updateFromJson(jsonButton)
|
||||
htmlButton.updateHtml()
|
||||
htmlButton.updateDom()
|
||||
}
|
||||
|
||||
htmlButton.updateIterationTimestamp = currentIterationTimestamp
|
||||
}
|
||||
|
||||
for (const existingButton of document.querySelector('#contentActions').querySelectorAll('button')) {
|
||||
// Remove existing, but stale buttons (that were not updated in this round)
|
||||
for (const existingButton of document.querySelector('#contentActions').querySelectorAll('action-button')) {
|
||||
if (existingButton.updateIterationTimestamp !== currentIterationTimestamp) {
|
||||
existingButton.remove()
|
||||
}
|
||||
|
||||
@@ -2,18 +2,6 @@
|
||||
|
||||
import { marshalActionButtonsJsonToHtml, marshalLogsJsonToHtml } from './js/marshaller.js'
|
||||
|
||||
function showBigError (type, friendlyType, message) {
|
||||
clearInterval(window.buttonInterval)
|
||||
|
||||
console.error('Error ' + type + ': ', message)
|
||||
|
||||
const domErr = document.createElement('div')
|
||||
domErr.classList.add('error')
|
||||
domErr.innerHTML = '<h1>Error ' + friendlyType + '</h1><p>' + message + "</p><p><a href = 'http://docs.olivetin.app/troubleshooting.html' target = 'blank'/>OliveTin Documentation</a></p>"
|
||||
|
||||
document.getElementById('rootGroup').appendChild(domErr)
|
||||
}
|
||||
|
||||
function showSection (name) {
|
||||
for (const otherName of ['Actions', 'Logs']) {
|
||||
document.getElementById('show' + otherName).classList.remove('activeSection')
|
||||
@@ -39,7 +27,7 @@ function fetchGetDashboardComponents () {
|
||||
}).then(res => {
|
||||
marshalActionButtonsJsonToHtml(res)
|
||||
}).catch(err => {
|
||||
showBigError('fetch-buttons', 'getting buttons', err, 'blat')
|
||||
window.showBigError('fetch-buttons', 'getting buttons', err, 'blat')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,7 +39,7 @@ function fetchGetLogs () {
|
||||
}).then(res => {
|
||||
marshalLogsJsonToHtml(res)
|
||||
}).catch(err => {
|
||||
showBigError('fetch-buttons', 'getting buttons', err, 'blat')
|
||||
window.showBigError('fetch-buttons', 'getting buttons', err, 'blat')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,20 +62,24 @@ function processWebuiSettingsJson (settings) {
|
||||
document.querySelector('#availableVersion').hidden = false
|
||||
}
|
||||
|
||||
document.querySelector('#switcher').hidden = settings.HideNavigation
|
||||
document.querySelector('#sectionSwitcher').hidden = settings.HideNavigation
|
||||
}
|
||||
|
||||
setupSections()
|
||||
function main () {
|
||||
setupSections()
|
||||
|
||||
window.fetch('webUiSettings.json').then(res => {
|
||||
return res.json()
|
||||
}).then(res => {
|
||||
processWebuiSettingsJson(res)
|
||||
window.fetch('webUiSettings.json').then(res => {
|
||||
return res.json()
|
||||
}).then(res => {
|
||||
processWebuiSettingsJson(res)
|
||||
|
||||
fetchGetDashboardComponents()
|
||||
fetchGetLogs()
|
||||
fetchGetDashboardComponents()
|
||||
fetchGetLogs()
|
||||
|
||||
window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
|
||||
}).catch(err => {
|
||||
showBigError('fetch-webui-settings', 'getting webui settings', err)
|
||||
})
|
||||
window.buttonInterval = setInterval(fetchGetDashboardComponents, 3000)
|
||||
}).catch(err => {
|
||||
window.showBigError('fetch-webui-settings', 'getting webui settings', err)
|
||||
})
|
||||
}
|
||||
|
||||
main() // call self
|
||||
|
||||
@@ -42,10 +42,6 @@ tr:hover td {
|
||||
background-color: beige;
|
||||
}
|
||||
|
||||
button.activeSection {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
legend {
|
||||
padding-top: 1em;
|
||||
}
|
||||
@@ -90,11 +86,12 @@ div.entity h2 {
|
||||
grid-column: 1 / span all;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
|
||||
button,
|
||||
input[type="submit"] {
|
||||
padding: 1em;
|
||||
color: black;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
border: 1px solid #999;
|
||||
background-color: white;
|
||||
@@ -120,20 +117,43 @@ input[type="submit"]:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
fieldset#switcher {
|
||||
fieldset#sectionSwitcher {
|
||||
border: 0;
|
||||
text-align: right;
|
||||
margin-bottom: 1em;
|
||||
}
|
||||
|
||||
fieldset#switcher button:first-child {
|
||||
fieldset#sectionSwitcher button {
|
||||
padding: 1em;
|
||||
color: black;
|
||||
display: table-cell;
|
||||
text-align: center;
|
||||
border: 1px solid #999;
|
||||
background-color: white;
|
||||
box-shadow: 0 0 6px 0 #aaa;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
fieldset#rootGroup action-button button {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
fieldset#sectionSwitcher button:first-child {
|
||||
border-radius: 1em 0 0 1em;
|
||||
}
|
||||
|
||||
fieldset#switcher button:last-child {
|
||||
fieldset#sectionSwitcher button:last-child {
|
||||
border-radius: 0 1em 1em 0;
|
||||
}
|
||||
|
||||
button.activeSection {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Button animations */
|
||||
|
||||
.actionFailed {
|
||||
animation: kfActionFailed 1s;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user