├── .gitattributes ├── .github └── workflows │ ├── ci.yml │ ├── osv-scanner-pr.yml │ └── stale.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── Magefile.go ├── README.md ├── build ├── Dockerfile └── README.md ├── config-example.yaml ├── go.mod ├── go.sum ├── pkg ├── cmd │ └── grafana-kiosk │ │ ├── main.go │ │ └── main_test.go ├── initialize │ ├── lxde.go │ └── lxde_test.go └── kiosk │ ├── anonymous_login.go │ ├── apikey_login.go │ ├── aws_login.go │ ├── config.go │ ├── grafana_com_login.go │ ├── grafana_genericoauth_login.go │ ├── grafana_idtoken_login.go │ ├── listen_chrome_events.go │ ├── local_login.go │ ├── utils.go │ └── utils_test.go ├── scripts └── grafana-kiosk.service └── testdata ├── config-anon.yaml ├── config-aws.yaml ├── config-gcom.yaml └── config-local.yaml /.gitattributes: -------------------------------------------------------------------------------- 1 | * text eol=lf 2 | bin/** filter=lfs diff=lfs merge=lfs -text 3 | artifacts/** filter=lfs diff=lfs merge=lfs -text -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - '*' 7 | tags: 8 | - 'v*' 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v4 15 | with: 16 | fetch-depth: 0 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version-file: go.mod 22 | check-latest: true 23 | 24 | - name: Install dependencies 25 | run: go get . 26 | 27 | - name: Install golangci-lint 28 | uses: golangci/golangci-lint-action@v6 29 | with: 30 | version: latest 31 | args: --exclude-use-default 32 | skip-cache: true 33 | 34 | - name: Run Gosec Security Scanner 35 | uses: securego/gosec@master 36 | with: 37 | args: ./... 38 | 39 | - name: Get BRANCH, NAME, TAG 40 | id: branch_name 41 | run: | 42 | echo "SOURCE_NAME=${GITHUB_REF#refs/*/}" >> $GITHUB_OUTPUT 43 | echo "SOURCE_BRANCH=${GITHUB_REF#refs/heads/}" >> $GITHUB_OUTPUT 44 | echo "SOURCE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT 45 | 46 | - name: Build with Mage 47 | uses: magefile/mage-action@v3 48 | with: 49 | version: latest 50 | args: -v build:ci 51 | 52 | - name: Get PR 53 | uses: jwalton/gh-find-current-pr@master 54 | id: findPr 55 | with: 56 | state: open 57 | 58 | - name: Archive Build 59 | uses: actions/upload-artifact@v4 60 | if: success() && steps.findPr.outputs.number 61 | env: 62 | PR: ${{ steps.findPr.outputs.pr }} 63 | with: 64 | name: grafana-kiosk-pr-${{env.PR}} 65 | path: bin 66 | overwrite: true 67 | retention-days: 7 68 | 69 | - name: Upload Code Climate Report 70 | uses: paambaati/codeclimate-action@v9 71 | env: 72 | CC_TEST_REPORTER_ID: ${{secrets.CC_TEST_REPORTER_ID}} 73 | with: 74 | prefix: github.com/grafana/grafana-kiosk 75 | coverageLocations: | 76 | ${{github.workspace}}/coverage.out:gocov 77 | 78 | - name: Package Release 79 | id: package-release 80 | if: startsWith(github.ref, 'refs/tags/v') 81 | env: 82 | SOURCE_TAG: ${{ steps.branch_name.outputs.SOURCE_TAG }} 83 | run: | 84 | export RELEASE_TARGET_DIR=grafana-kiosk-$SOURCE_TAG 85 | mkdir $RELEASE_TARGET_DIR 86 | cp -p bin/darwin_amd64/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.darwin.amd64 87 | cp -p bin/darwin_arm64/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.darwin.arm64 88 | cp -p bin/linux_386/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.linux.386 89 | cp -p bin/linux_amd64/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.linux.amd64 90 | cp -p bin/linux_arm64/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.linux.arm64 91 | cp -p bin/linux_armv5/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.linux.armv5 92 | cp -p bin/linux_armv6/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.linux.armv6 93 | cp -p bin/linux_armv7/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.linux.armv7 94 | cp -p bin/windows_amd64/grafana-kiosk $RELEASE_TARGET_DIR/grafana-kiosk.windows.amd64.exe 95 | zip -r grafana-kiosk-$SOURCE_TAG.zip $RELEASE_TARGET_DIR 96 | tar cf grafana-kiosk-$SOURCE_TAG.tar $RELEASE_TARGET_DIR 97 | gzip grafana-kiosk-$SOURCE_TAG.tar 98 | mv grafana-kiosk-$SOURCE_TAG.tar.gz $RELEASE_TARGET_DIR 99 | mv grafana-kiosk-$SOURCE_TAG.zip $RELEASE_TARGET_DIR 100 | 101 | - name: Upload Release Artifacts 102 | uses: actions/upload-artifact@v4 103 | if: startsWith(github.ref, 'refs/tags/v') 104 | with: 105 | name: upload-release-artifacts 106 | path: grafana-kiosk-v*/** 107 | 108 | - name: Release 109 | uses: softprops/action-gh-release@v2 110 | env: 111 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 112 | if: startsWith(github.ref, 'refs/tags/v') 113 | with: 114 | prerelease: true 115 | generate_release_notes: true 116 | files: | 117 | ./grafana-kiosk-v*/** 118 | body: | 119 | ** Draft release ** 120 | -------------------------------------------------------------------------------- /.github/workflows/osv-scanner-pr.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: OSV-Scanner PR Scan 3 | 4 | on: 5 | pull_request: 6 | branches: [ main ] 7 | merge_group: 8 | branches: [ main ] 9 | 10 | permissions: 11 | # Require writing security events to upload SARIF file to security tab 12 | security-events: write 13 | # Only need to read contents 14 | contents: read 15 | 16 | jobs: 17 | scan-pr: 18 | uses: "google/osv-scanner/.github/workflows/osv-scanner-reusable-pr.yml@main" 19 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues and PRs' 2 | on: 3 | schedule: 4 | - cron: '30 4 * * *' 5 | permissions: 6 | issues: write 7 | pull-requests: write 8 | jobs: 9 | stale: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/stale@v9 13 | with: 14 | # Number of days of inactivity before a stale Issue or Pull Request is closed. 15 | # Set to -1 to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 16 | days-before-close: 60 17 | # Number of days of inactivity before an Issue or Pull Request becomes stale 18 | days-before-stale: 90 19 | exempt-issue-labels: no stalebot 20 | exempt-pr-labels: no stalebot 21 | operations-per-run: 100 22 | stale-issue-label: stale 23 | stale-pr-label: stale 24 | stale-pr-message: > 25 | This pull request has been automatically marked as stale because it has not had 26 | activity in the last 30 days. It will be closed in 2 weeks if no further activity occurs. Please 27 | feel free to give a status update now, ping for review, or re-open when it's ready. 28 | Thank you for your contributions! 29 | close-pr-message: > 30 | This pull request has been automatically closed because it has not had 31 | activity in the last 2 weeks. Please feel free to give a status update now, ping for review, or re-open when it's ready. 32 | Thank you for your contributions! 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | coverage/ 3 | coverage.out 4 | coverage.html 5 | .idea 6 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.0.9 4 | 5 | - Fix for issue [[#159](https://github.com/grafana/grafana-kiosk/issues/159)] 6 | - Fix for issue [[#160](https://github.com/grafana/grafana-kiosk/issues/160)] 7 | - Updates go packages 8 | - Please note: When using a service account token, you may need to increase the delay in your configuration for playlists depending on the device being used (10000 ms for RPi4b appears stable) 9 | 10 | ## 1.0.8 11 | 12 | - Fix for issue [#137](https://github.com/grafana/grafana-kiosk/issues/137) How to get rid of "Choose your search engine" window 13 | - Fix for scale-factor parameter [#142](https://github.com/grafana/grafana-kiosk/pull/142) 14 | 15 | ## 1.0.7 16 | 17 | - Fix for GCOM login Issue [#132](https://github.com/grafana/grafana-kiosk/issues/132) 18 | - Update go modules 19 | 20 | ## 1.0.6 21 | 22 | - Includes PR#93 - adds authentication with API Key! 23 | - Reduces app CPU utilization to near zero while running 24 | - Adds version to build based on git tag 25 | - Adds user agent with kiosk version 26 | - Switch to git workflow for builds 27 | - Switch to Mage for building instead of make 28 | - Update go modules 29 | 30 | ## 1.0.5 31 | 32 | - Update go modules to fix continuous error messages 33 | - Updated linters and circleci config for go 1.19 34 | - Adds support for Google IAP Auth (idtoken) 35 | - Fixes GCOM auth login hanging 36 | 37 | ## 1.0.4 38 | 39 | - Fix startup issue with new flags 40 | 41 | ## 1.0.3 42 | 43 | - OAuth merged 44 | - Fix Grafana Cloud login 45 | - Updated modules 46 | - Added "window-position" option, allows running kiosk on different displays 47 | - Added `--check-for-update-interval=31536000` to default flags sent to chromium to workaround update popup 48 | 49 | ## 1.0.2 50 | 51 | - Also compatible with Grafana v7 52 | - New flag to ignore SSL certificates for local login 53 | - Updated chromedp and build with go 1.14.2 54 | - New configuration file based startup 55 | 56 | ## 1.0.1 57 | 58 | - Automated build 59 | - Includes PR #15 60 | - Compatible with Grafana v6.4.1+ 61 | 62 | ## 1.0.0 63 | 64 | - First Release 65 | - Compatible with Grafana v6.3 66 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /Magefile.go: -------------------------------------------------------------------------------- 1 | //go:build mage 2 | // +build mage 3 | 4 | package main 5 | 6 | import ( 7 | "context" 8 | "fmt" 9 | "log" 10 | "os" 11 | "os/exec" 12 | "runtime" 13 | "strings" 14 | 15 | "github.com/magefile/mage/mg" 16 | "github.com/magefile/mage/sh" 17 | // mg contains helpful utility functions, like Deps 18 | ) 19 | 20 | // Build namespace 21 | type Build mg.Namespace 22 | 23 | // Test namespace 24 | type Test mg.Namespace 25 | 26 | // Run namespace 27 | type Run mg.Namespace 28 | 29 | var archTargets = map[string]map[string]string{ 30 | "darwin_amd64": { 31 | "CGO_ENABLED": "0", 32 | "GO111MODULE": "on", 33 | "GOARCH": "amd64", 34 | "GOOS": "darwin", 35 | }, 36 | "darwin_arm64": { 37 | "CGO_ENABLED": "0", 38 | "GO111MODULE": "on", 39 | "GOARCH": "arm64", 40 | "GOOS": "darwin", 41 | }, 42 | "linux_amd64": { 43 | "CGO_ENABLED": "0", 44 | "GO111MODULE": "on", 45 | "GOARCH": "amd64", 46 | "GOOS": "linux", 47 | }, 48 | "linux_arm64": { 49 | "CGO_ENABLED": "0", 50 | "GO111MODULE": "on", 51 | "GOARCH": "arm64", 52 | "GOOS": "linux", 53 | }, 54 | "linux_386": { 55 | "CGO_ENABLED": "0", 56 | "GO111MODULE": "on", 57 | "GOARCH": "386", 58 | "GOOS": "linux", 59 | }, 60 | "linux_armv5": { 61 | "CGO_ENABLED": "0", 62 | "GO111MODULE": "on", 63 | "GOARCH": "arm", 64 | "GOARM": "5", 65 | "GOOS": "linux", 66 | }, 67 | "linux_armv6": { 68 | "CGO_ENABLED": "0", 69 | "GO111MODULE": "on", 70 | "GOARCH": "arm", 71 | "GOARM": "6", 72 | "GOOS": "linux", 73 | }, 74 | "linux_armv7": { 75 | "CGO_ENABLED": "0", 76 | "GO111MODULE": "on", 77 | "GOARCH": "arm", 78 | "GOARM": "7", 79 | "GOOS": "linux", 80 | }, 81 | "windows_amd64": { 82 | "CGO_ENABLED": "0", 83 | "GO111MODULE": "on", 84 | "GOARCH": "amd64", 85 | "GOOS": "windows", 86 | }, 87 | } 88 | 89 | func getVersion() string { 90 | out, err := exec.Command("git", "describe", "--tags").Output() 91 | if err != nil { 92 | return "unknown" 93 | } 94 | version := strings.TrimRight(string(out), "\r\n") 95 | return version 96 | } 97 | 98 | // Default target to run when none is specified 99 | // If not set, running mage will list available targets 100 | var Default = Build.Local 101 | 102 | func buildCommand(command string, arch string) error { 103 | env, ok := archTargets[arch] 104 | if !ok { 105 | return fmt.Errorf("unknown arch %s", arch) 106 | } 107 | log.Printf("Building %s/%s\n", arch, command) 108 | outDir := fmt.Sprintf("./bin/%s/%s", arch, command) 109 | cmdDir := fmt.Sprintf("./pkg/cmd/%s", command) 110 | if err := sh.RunWith( 111 | env, 112 | "go", 113 | "build", 114 | "-ldflags", 115 | fmt.Sprintf("-X main.Version=%s", getVersion()), 116 | "-o", outDir, cmdDir); err != nil { 117 | return err 118 | } 119 | 120 | // intentionally ignores errors 121 | sh.RunV("chmod", "+x", outDir) 122 | return nil 123 | } 124 | 125 | func kioskCmd() error { 126 | return buildCommand("grafana-kiosk", runtime.GOOS+"_"+runtime.GOARCH) 127 | } 128 | 129 | func buildCmdAll() error { 130 | for anArch := range archTargets { 131 | if err := buildCommand("grafana-kiosk", anArch); err != nil { 132 | return err 133 | } 134 | } 135 | return nil 136 | } 137 | 138 | func testVerbose() error { 139 | os.Setenv("GO111MODULE", "on") 140 | os.Setenv("CGO_ENABLED", "0") 141 | if err := sh.RunV("go", "test", "-v", "-coverpkg=./...", "--coverprofile=coverage.out", "./pkg/..."); err != nil { 142 | return err 143 | } 144 | if err := sh.RunV("go", "tool", "cover", "-func", "coverage.out"); err != nil { 145 | return err 146 | } 147 | return sh.RunV("go", "tool", "cover", "-html=coverage.out", "-o", "coverage.html") 148 | } 149 | 150 | func test() error { 151 | os.Setenv("GO111MODULE", "on") 152 | os.Setenv("CGO_ENABLED", "0") 153 | if err := sh.RunV("go", "test", "-coverpkg=./...", "--coverprofile=coverage.out", "./pkg/..."); err != nil { 154 | return err 155 | } 156 | if err := sh.RunV("go", "tool", "cover", "-func", "coverage.out"); err != nil { 157 | return err 158 | } 159 | return sh.RunV("go", "tool", "cover", "-html=coverage.out", "-o", "coverage.html") 160 | } 161 | 162 | // Format Formats the source files 163 | func (Build) Format() error { 164 | if err := sh.RunV("gofmt", "-w", "./pkg"); err != nil { 165 | return err 166 | } 167 | return nil 168 | } 169 | 170 | // Local Minimal build 171 | func (Build) Local(ctx context.Context) { 172 | mg.Deps( 173 | Clean, 174 | kioskCmd, 175 | ) 176 | } 177 | 178 | // CI Lint/Format/Test/Build 179 | func (Build) CI(ctx context.Context) { 180 | mg.Deps( 181 | Build.Lint, 182 | Build.Format, 183 | Test.Verbose, 184 | Clean, 185 | buildCmdAll, 186 | ) 187 | } 188 | 189 | // All build all 190 | func (Build) All(ctx context.Context) { 191 | mg.Deps( 192 | Build.Lint, 193 | Build.Format, 194 | Test.Verbose, 195 | buildCmdAll, 196 | ) 197 | } 198 | 199 | // Build a docker image, call with image name as an argument: mage -v build:dockerArm64 "slimbean/grafana-kiosk:2024-11-29" 200 | func (Build) DockerArm64(ctx context.Context, image string) error { 201 | log.Printf("Building docker...") 202 | return sh.RunV("docker", "build", "--build-arg", "TARGET_PLATFORM=linux/arm64", "--build-arg", "COMPILE_GOARCH=arm64", "-t", image, "-f", "build/Dockerfile", ".") 203 | } 204 | 205 | // Lint Run linter against codebase 206 | func (Build) Lint() error { 207 | os.Setenv("GO111MODULE", "on") 208 | log.Printf("Linting...") 209 | return sh.RunV("golangci-lint", "--timeout", "5m", "run", "./pkg/...") 210 | } 211 | 212 | // Verbose Run tests in verbose mode 213 | func (Test) Verbose() { 214 | mg.Deps( 215 | testVerbose, 216 | ) 217 | } 218 | 219 | // Default Run tests in normal mode 220 | func (Test) Default() { 221 | mg.Deps( 222 | test, 223 | ) 224 | } 225 | 226 | // Clean Removes built files 227 | func Clean() { 228 | log.Printf("Cleaning all") 229 | os.RemoveAll("./bin/linux_386") 230 | os.RemoveAll("./bin/linux_amd64") 231 | os.RemoveAll("./bin/linux_arm64") 232 | os.RemoveAll("./bin/linux_armv5") 233 | os.RemoveAll("./bin/linux_armv6") 234 | os.RemoveAll("./bin/linux_armv7") 235 | os.RemoveAll("./bin/darwin_amd64") 236 | os.RemoveAll("./bin/darwin_arm64") 237 | os.RemoveAll("./bin/windows_amd64") 238 | } 239 | 240 | // Local Build and Run 241 | func (Run) Local() error { 242 | mg.Deps(Build.Local) 243 | return sh.RunV( 244 | "./bin/"+runtime.GOOS+"_"+runtime.GOARCH+"/grafana-kiosk", 245 | "-c", 246 | "config-example.yaml", 247 | ) 248 | } 249 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Grafana Kiosk 2 | 3 | [![Build Status](https://img.shields.io/endpoint.svg?url=https%3A%2F%2Factions-badge.atrox.dev%2Fgrafana%2Fgrafana-kiosk%2Fbadge%3Fref%3Dmain&style=flat)](https://actions-badge.atrox.dev/grafana/grafana-kiosk/goto?ref=main) 4 | [![Go Report Card](https://goreportcard.com/badge/github.com/grafana/grafana-kiosk)](https://goreportcard.com/report/github.com/grafana/grafana-kiosk) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/8cdc385a20fe3d480455/maintainability)](https://codeclimate.com/github/grafana/grafana-kiosk/maintainability) 6 | [![Test Coverage](https://api.codeclimate.com/v1/badges/8cdc385a20fe3d480455/test_coverage)](https://codeclimate.com/github/grafana/grafana-kiosk/test_coverage) 7 | 8 | A very useful feature of Grafana is the ability to display dashboards and playlists on a large TV. 9 | 10 | This provides a utility to quickly standup a kiosk on devices like a Raspberry Pi or NUC. 11 | 12 | The utility provides these options: 13 | 14 | - Login 15 | - to a Grafana server (local account or bypass OAuth) 16 | - to a Grafana server with anonymous-mode enabled (same method used on [play.grafana.org](https://play.grafana.org)) 17 | - to a Grafana Cloud instance 18 | - to a Grafana server with OAuth enabled 19 | - to an AWS Managed Grafana instance (both with and without MFA) 20 | - Switch to kiosk or kiosk-tv mode 21 | - Display the default home page set for the user 22 | - Display a specified dashboard 23 | - Start a playlist immediately (inactive mode enable) 24 | - Can specify where to start kiosk for multiple displays 25 | 26 | Additionally, an initialize option is provided to configure LXDE for Raspberry Pi Desktop. 27 | 28 | ## Installing on Linux 29 | 30 | Download the zip or tar file from [releases](https://github.com/grafana/grafana-kiosk/releases) 31 | 32 | The release file includes pre-built binaries. See table below for the types available. 33 | 34 | | OS | Architecture | Description | Executable | 35 | | ------- | ------------ | ------------ | ------------------------------- | 36 | | linux | amd64 | 64bit | grafana-kiosk.linux.amd64 | 37 | | linux | 386 | 32bit | grafana-kiosk.linux.386 | 38 | | linux | arm64 | 64bit Arm v7 | grafana-kiosk.linux.arm64 | 39 | | linux | arm | ARM v5 | grafana-kiosk.linux.armv5 | 40 | | linux | arm | ARM v6 | grafana-kiosk.linux.armv6 | 41 | | linux | arm | ARM v7 | grafana-kiosk.linux.armv7 | 42 | | darwin | amd64 | 64bit | grafana-kiosk.darwin.amd64 | 43 | | windows | amd64 | 64bit | grafana-kiosk.windows.amd64.exe | 44 | 45 | Extract the zip or tar file, and copy the appropriate binary to /usr/bin/grafana-kiosk: 46 | 47 | ```BASH 48 | # sudo cp -p grafana-kiosk.linux.armv7 /usr/bin/grafana-kiosk 49 | # sudo chmod 755 /usr/bin/grafana-kiosk 50 | ``` 51 | 52 | ## Dependencies/Suggestion Packages 53 | 54 | This application can run on most operating systems, but for linux some additional 55 | binaries are suggested for full support. 56 | 57 | Suggesting Packages: 58 | 59 | `unclutter` (for hiding mouse/cursor) 60 | `rng-tools` (for entropy issues) 61 | 62 | ## Usage 63 | 64 | NOTE: Flags with parameters should use an "equals" 65 | `-autofit=true` 66 | `-URL=https://play.grafana.org` when used with any boolean flags. 67 | 68 | ```TEXT 69 | -URL string 70 | URL to Grafana server (default "https://play.grafana.org") 71 | -apikey string 72 | apikey 73 | -audience string 74 | idtoken audience 75 | -auto-login 76 | oauth_auto_login is enabled in grafana config 77 | (set this flag along with the "local" login-method to bypass OAuth via the /login/local url and use a local grafana user/pass before continuing to the target URL) 78 | -autofit 79 | Fit panels to screen (default true) 80 | -c string 81 | Path to configuration file (config.yaml) 82 | -field-password string 83 | Fieldname for the password (default "password") 84 | -field-username string 85 | Fieldname for the username (default "username") 86 | -ignore-certificate-errors 87 | Ignore SSL/TLS certificate error 88 | -keyfile string 89 | idtoken json credentials (default "key.json") 90 | -kiosk-mode string 91 | Kiosk Display Mode [full|tv|disabled] 92 | full = No TOPNAV and No SIDEBAR 93 | tv = No SIDEBAR 94 | disabled = omit option 95 | (default "full") 96 | -login-method string 97 | [anon|local|gcom|goauth|idtoken|apikey|aws] (default "anon") 98 | -lxde 99 | Initialize LXDE for kiosk mode 100 | -lxde-home string 101 | Path to home directory of LXDE user running X Server (default "/home/pi") 102 | -password string 103 | password (default "guest") 104 | -playlists 105 | URL is a playlist 106 | -username string 107 | username (default "guest") 108 | -use-mfa 109 | MFA is enabled for given account (default false) 110 | -window-position string 111 | Top Left Position of Kiosk (default "0,0") 112 | -window-size string 113 | Size of Kiosk in pixels (e.g. "1920,1080") 114 | -scale-factor string 115 | Scale factor of Kiosk. This is sort of like zoom. (default: "1") 116 | ``` 117 | 118 | ### Using a configuration file 119 | 120 | The kiosk can also be started using a configuration file, along with environment variables. 121 | When using this option, all other arguments passed are ignored. 122 | 123 | ```YAML 124 | general: 125 | kiosk-mode: full 126 | autofit: true 127 | lxde: true 128 | lxde-home: /home/pi 129 | scale-factor: 1.0 130 | 131 | target: 132 | login-method: anon 133 | username: user 134 | password: changeme 135 | playlist: false 136 | URL: https://play.grafana.org 137 | ignore-certificate-errors: false 138 | ``` 139 | 140 | ```BASH 141 | grafana-kiosk -c config.yaml 142 | ``` 143 | 144 | Environment variables can be set and will override the configuration file. 145 | They can also be used instead of a configuration file. 146 | 147 | ```TEXT 148 | KIOSK_AUTOFIT bool 149 | fit panels to screen (default "true") 150 | KIOSK_LXDE_ENABLED bool 151 | initialize LXDE for kiosk mode (default "false") 152 | KIOSK_LXDE_HOME string 153 | path to home directory of LXDE user running X Server (default "/home/pi") 154 | KIOSK_MODE string 155 | [full|tv|disabled] (default "full") 156 | KIOSK_WINDOW_POSITION string 157 | Top Left Position of Kiosk (default "0,0") 158 | KIOSK_WINDOW_SIZE string 159 | Size of Kiosk in pixels (e.g. "1920,1080") 160 | KIOSK_SCALE_FACTOR string 161 | Scale factor, like zoom 162 | KIOSK_IGNORE_CERTIFICATE_ERRORS bool 163 | Ignore SSL/TLS certificate errors (default "false") 164 | KIOSK_IS_PLAYLIST bool 165 | URL is a playlist (default "false") 166 | KIOSK_LOGIN_METHOD string 167 | [anon|local|gcom|goauth|idtoken|apikey|aws] (default "anon") 168 | KIOSK_LOGIN_PASSWORD string 169 | password (default "guest") 170 | KIOSK_URL string 171 | URL to Grafana server (default "https://play.grafana.org") 172 | KIOSK_LOGIN_USER string 173 | username (default "guest") 174 | KIOSK_GOAUTH_AUTO_LOGIN bool 175 | [false|true] 176 | KIOSK_GOAUTH_FIELD_USER string 177 | Username html input name value 178 | KIOSK_GOAUTH_FIELD_PASSWORD string 179 | Password html input name value 180 | KIOSK_GOAUTH_WAIT_FOR_PASSWORD_FIELD 181 | Wait for the password field to be visible 182 | KIOSK_GOAUTH_WAIT_FOR_PASSWORD_FIELD_CLASS 183 | Class to ignore for the Password Field to be visible 184 | KIOSK_IDTOKEN_KEYFILE string 185 | JSON Credentials for idtoken (default "key.json") 186 | KIOSK_IDTOKEN_AUDIENCE string 187 | Audience for idtoken, tpyically your oauth client id 188 | KIOSK_APIKEY_APIKEY string 189 | APIKEY Generated in Grafana Server 190 | ``` 191 | 192 | ### Hosted Grafana using grafana.com authentication 193 | 194 | This will login to a Hosted Grafana instance and take the browser to the default dashboard in fullscreen kiosk mode: 195 | 196 | ```bash 197 | ./bin/grafana-kiosk -URL=https://bkgann3.grafana.net -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode=full 198 | ``` 199 | 200 | This will login to a Hosted Grafana instance and take the browser to a specific dashboard in tv kiosk mode: 201 | 202 | ```bash 203 | ./bin/grafana-kiosk -URL=https://bkgann3.grafana.net/dashboard/db/sensu-summary -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode tv 204 | ``` 205 | 206 | This will login to a Hosted Grafana instance and take the browser to a playlist in fullscreen kiosk mode, and autofit the panels to fill the display. 207 | 208 | ```bash 209 | ./bin/grafana-kiosk -URL=https://bkgann3.grafana.net/playlists/play/1 -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode=full -playlist -autofit=true 210 | ``` 211 | 212 | ### Grafana Server with Local Accounts 213 | 214 | This will login to a grafana server that uses local accounts: 215 | 216 | ```bash 217 | ./bin/grafana-kiosk -URL=https://localhost:3000 -login-method=local -username=admin -password=admin -kiosk-mode=tv 218 | ``` 219 | 220 | If you are using a self-signed certificate, you can remove the certificate error with `-ignore-certificate-errors` 221 | 222 | ```bash 223 | ./bin/grafana-kiosk -URL=https://localhost:3000 -login-method=local -username=admin -password=admin -kiosk-mode=tv -ignore-certificate-errors 224 | ``` 225 | 226 | This will login to a grafana server, configured for AzureAD OAuth and has Oauth_auto_login is enabled, bypassing OAuth and using a manually setup local username and password. 227 | 228 | ```bash 229 | ./bin/grafana-kiosk -URL=https://localhost:3000 -login-method=local -username=admin -password=admin -auto-login=true -kiosk-mode=tv 230 | ``` 231 | 232 | ### Grafana Server with Anonymous access enabled 233 | 234 | This will take the browser to the default dashboard on play.grafana.org in fullscreen kiosk mode (no login needed): 235 | 236 | ```bash 237 | ./bin/grafana-kiosk -URL=https://play.grafana.org -login-method=anon -kiosk-mode=tv 238 | ``` 239 | 240 | This will take the browser to a playlist on play.grafana.org in fullscreen kiosk mode (no login needed): 241 | 242 | ```bash 243 | ./bin/grafana-kiosk -URL=https://play.grafana.org/playlists/play/1 -login-method=anon -kiosk-mode=tv 244 | ``` 245 | 246 | ### Grafana Server with Api Key 247 | 248 | This will take the browser to the default dashboard on play.grafana.org in fullscreen kiosk mode: 249 | 250 | ```bash 251 | ./bin/grafana-kiosk -URL=https://play.grafana.org -login-method apikey --apikey "xxxxxxxxxxxxxxx" -kiosk-mode=tv 252 | ``` 253 | 254 | ### Grafana Server with Generic Oauth 255 | 256 | This will login to a Generic Oauth service, configured on Grafana. Oauth_auto_login is disabeld. As Oauth provider is Keycloak used. 257 | 258 | ```bash 259 | go run pkg/cmd/grafana-kiosk/main.go -URL=https://my.grafana.oauth/playlists/play/1 -login-method=goauth -username=test -password=test 260 | ``` 261 | 262 | This will login to a Generic Oauth service, configured on Grafana. Oauth_auto_login is disabeld. As Oauth provider is Keycloak used and also the login and password html input name is set. 263 | 264 | ```bash 265 | go run pkg/cmd/grafana-kiosk/main.go -URL=https://my.grafana.oauth/playlists/play/1 -login-method=goauth -username=test -password=test -field-username=username -field-password=password 266 | ``` 267 | 268 | This will login to a Generic Oauth service, configured on Grafana. Oauth_auto_login is enabled. As Oauth provider is Keycloak used and also the login and password html input name is set. 269 | 270 | ```bash 271 | go run pkg/cmd/grafana-kiosk/main.go -URL=https://my.grafana.oauth/playlists/play/1 -login-method=goauth -username=test -password=test -field-username=username -field-password=password -auto-login=true 272 | ``` 273 | 274 | ### Google Idtoken authentication 275 | 276 | This allows you to log in through Google Identity Aware Proxy using a service account through injecting authorization headers with bearer tokens into each request. the idtoken library will generate new tokens as needed on expiry, allowing grafana kiosk mode without exposing a fully privileged google user on your kiosk device. 277 | 278 | ```bash 279 | ./bin/grafana-kiosk -URL=https://play.grafana.org/playlists/play/1 -login-method=idtoken -keyfile /tmp/foo.json -audience myoauthid.apps.googleusercontent.com 280 | ``` 281 | 282 | ## LXDE Options 283 | 284 | The `-lxde` option initializes settings for the desktop. 285 | 286 | Actions Performed: 287 | 288 | - sets profile via lxpanel to LXDE 289 | - sets pcmanfs profile to LXDE 290 | - runs `xset s off` to disable screensaver 291 | - runs `xset -dpms` to disable power-saving (prevents screen from turning off) 292 | - runs `xset s noblank` disables blank mode for screensaver (maybe not needed) 293 | - runs `unclutter` to hide the mouse 294 | 295 | The `-lxde-home` option allows you to specify a different $HOME directory where the lxde configuration files can be found. 296 | 297 | ## Automatic Startup 298 | 299 | ### Session-based 300 | 301 | LXDE can start the kiosk automatically by creating this file: 302 | 303 | Create/edit the file: `/home/pi/.config/lxsession/LXDE-pi/autostart` 304 | 305 | ```BASH 306 | /usr/bin/grafana-kiosk -URL=https://bkgann3.grafana.net/dashboard/db/sensu-summary -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode=full -lxde 307 | ``` 308 | 309 | #### Session with disconnected "screen" 310 | 311 | Alternatively you can run grafana-kiosk under screen, which can very useful for debugging. 312 | 313 | Create/edit the file: `/home/pi/.config/lxsession/LXDE-pi/autostart` 314 | 315 | ```BASH 316 | screen -d -m bash -c "/usr/bin/grafana-kiosk -URL=https://bkgann3.grafana.net/dashboard/db/sensu-summary -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode=full -lxde" 317 | ``` 318 | 319 | ### Desktop Link 320 | 321 | Create/edit the file: `/home/pi/.config/autostart/grafana-kiosk.desktop` 322 | 323 | ```INI 324 | [Desktop Entry] 325 | Type=Application 326 | Exec=/usr/bin/grafana-kiosk -URL=https://bkgann3.grafana.net/dashboard/db/sensu-summary -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode=full -lxde 327 | ``` 328 | 329 | #### Desktop Link with disconnected "screen" 330 | 331 | ```INI 332 | [Desktop Entry] 333 | Type=Application 334 | Exec=screen -d -m bash -c /usr/bin/grafana-kiosk -URL=https://bkgann3.grafana.net/dashboard/db/sensu-summary -login-method=gcom -username=bkgann -password=abc123 -kiosk-mode=full -lxde 335 | ``` 336 | 337 | ## Systemd startup 338 | 339 | ```BASH 340 | # sudo touch /etc/systemd/system/grafana-kiosk.service 341 | # sudo chmod 664 /etc/systemd/system/grafana-kiosk.service 342 | ``` 343 | 344 | ```INI 345 | [Unit] 346 | Description=Grafana Kiosk 347 | Documentation=https://github.com/grafana/grafana-kiosk 348 | Documentation=https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv 349 | After=network.target 350 | 351 | [Service] 352 | User=pi 353 | Environment="DISPLAY=:0" 354 | Environment="XAUTHORITY=/home/pi/.Xauthority" 355 | 356 | # Disable screensaver and monitor standby 357 | ExecStartPre=xset s off 358 | ExecStartPre=xset -dpms 359 | ExecStartPre=xset s noblank 360 | 361 | ExecStart=/usr/bin/grafana-kiosk -URL= -login-method=local -username= -password= -playlist=true 362 | 363 | [Install] 364 | WantedBy=graphical.target 365 | ``` 366 | 367 | Reload systemd: 368 | 369 | ```BASH 370 | # sudo systemctl daemon-reload 371 | ``` 372 | 373 | Enable, Start, Get Status, and logs: 374 | 375 | ```BASH 376 | # sudo systemctl enable grafana-kiosk 377 | # sudo systemctl start grafana-kiosk 378 | # sudo systemctl status grafana-kiosk 379 | ``` 380 | 381 | Logs: 382 | 383 | ```BASH 384 | journalctl -u grafana-kiosk 385 | ``` 386 | 387 | ## Troubleshooting 388 | 389 | ### Timeout Launching 390 | 391 | ```LOG 392 | 2020/08/24 10:18:41 Launching local login kiosk 393 | panic: websocket url timeout reached 394 | ``` 395 | 396 | Often this is due to lack of entropy, for linux you would need to install `rng-tools` (or an equivalent). 397 | 398 | ```BASH 399 | apt install rng-tools 400 | ``` 401 | 402 | ## Building 403 | 404 | A Magefile is provided for building the utility, you can install mage by following the instructions at 405 | 406 | ```bash 407 | mage -v 408 | ``` 409 | 410 | This will generate executables in "bin" that can be run on a variety of platforms. 411 | 412 | For full build and testing options use: 413 | 414 | ```BASH 415 | mage -l 416 | ``` 417 | 418 | ## TODO 419 | 420 | - RHEL/CentOS auto-startup 421 | - Everything in issues! 422 | 423 | ## References 424 | 425 | - [TV and Kiosk Mode](https://grafana.com/docs/guides/whats-new-in-v5-3/#tv-and-kiosk-mode) 426 | - [Grafana Playlists](https://grafana.com/docs/reference/playlist) 427 | 428 | ## Thanks to our Contributors 429 | 430 | - [Michael Pasqualone](https://github.com/michaelp85) for the session-based startup ideas! 431 | - [Brendan Ball](https://github.com/BrendanBall) for contributing the desktop link startup example for LXDE! 432 | - [Alex Heylin](https://github.com/AlexHeylin) for the v7 login fix - and also works with v6! 433 | - [Xan Manning](https://github.com/xanmanning) for the ignore certificate option! 434 | - [David Stäheli](https://github.com/mistadave) for the OAuth implementation! 435 | - [Marcus Ramberg](https://github.com/marcusramberg) for the Google ID Token Auth implementation! 436 | - [Ronan Salmon](https://github.com/ronansalmon) for API token authentication! 437 | 438 | Any many others! 439 | -------------------------------------------------------------------------------- /build/Dockerfile: -------------------------------------------------------------------------------- 1 | ARG BUILD_PLATFORM=linux/amd64 2 | ARG TARGET_PLATFORM=linux/amd64 3 | 4 | FROM --platform=${BUILD_PLATFORM} golang:1.23-alpine AS builder 5 | ARG COMPILE_GOOS=linux 6 | ARG COMPILE_GOARCH=amd64 7 | ARG COMPILE_GOARM="" 8 | COPY . /build 9 | WORKDIR /build 10 | RUN CGO_ENABLED=0 GOOS=${COMPILE_GOOS} GOARCH=${COMPILE_GOARCH} GOARM=${COMPILE_GOARM} go build -ldflags '-extldflags "-static"' -o grafana-kiosk ./pkg/cmd/grafana-kiosk/main.go 11 | RUN mv /build/grafana-kiosk / 12 | 13 | FROM --platform=${TARGET_PLATFORM} dtcooper/raspberrypi-os:latest 14 | RUN apt-get update && \ 15 | apt-get install -qy \ 16 | tzdata ca-certificates chromium-browser 17 | 18 | WORKDIR / 19 | COPY --from=builder /grafana-kiosk /kiosk/grafana-kiosk 20 | ENTRYPOINT [ "/kiosk/grafana-kiosk" ] -------------------------------------------------------------------------------- /build/README.md: -------------------------------------------------------------------------------- 1 | If you would like to run Grafana Kiosk from a docker container, this is possible and I do so in my kubernetes cluster! 2 | 3 | ## Note 4 | 5 | This only works for Raspberry Pi as it uses the Raspberry Pi OS as the base image. (See Debugging) 6 | 7 | Your raspberry pi must be running X, newer versions run wayland by default so you have to change this: 8 | * sudo raspi-config 9 | * Advanced Options 10 | * Wayland 11 | * Select `W1 X11` 12 | * OK -> Reboot 13 | 14 | ## Build 15 | 16 | Pass the image name in as an argument, e.g.: 17 | 18 | ```bash 19 | mage -v build:dockerArm64 "slimbean/grafana-kiosk:2024-11-29" 20 | ``` 21 | 22 | ## Running in Kubernetes 23 | 24 | Here's an example I use which creates a namespace, the deployment and also a cron job that restarts Grafana every day. 25 | 26 | The cron job isn't really necessary, but I generally deploy things like this with some mechanism of restarting them to deal with any unexpected issues. 27 | 28 | Also I have a few extra affinity and toleration settings, the node in my k3s cluster that I want to run this on has a label `role=grafana-kiosk` and also a taint so I can target it specifically. 29 | 30 | ```yaml 31 | affinity: 32 | nodeAffinity: 33 | requiredDuringSchedulingIgnoredDuringExecution: 34 | nodeSelectorTerms: 35 | - matchExpressions: 36 | - key: role 37 | operator: In 38 | values: 39 | - grafana-kiosk 40 | ``` 41 | 42 | Also that same node has a taint `role=grafana-kiosk:NoSchedule` so I can ensure that only this pod runs on that node. 43 | 44 | ```yaml 45 | tolerations: 46 | - effect: NoSchedule 47 | key: role 48 | operator: Equal 49 | value: grafana-kiosk 50 | ``` 51 | Toleration syntax is confusing to me, but what this says is that this pod "tolerates" the taint `role=grafana-kiosk` with the value `grafana-kiosk` and the effect `NoSchedule`. 52 | 53 | 54 | Full YAML: 55 | 56 | Make sure to update the URL, USR, PASS, and IMAGE fields. 57 | 58 | ```yaml 59 | apiVersion: v1 60 | kind: Namespace 61 | metadata: 62 | creationTimestamp: null 63 | name: grafana-kiosk 64 | spec: {} 65 | status: {} 66 | --- 67 | apiVersion: apps/v1 68 | kind: Deployment 69 | metadata: 70 | creationTimestamp: null 71 | labels: 72 | app: grafana-kiosk 73 | name: grafana-kiosk 74 | namespace: grafana-kiosk 75 | spec: 76 | replicas: 1 77 | selector: 78 | matchLabels: 79 | app: grafana-kiosk 80 | strategy: 81 | type: Recreate 82 | template: 83 | metadata: 84 | creationTimestamp: null 85 | labels: 86 | app: grafana-kiosk 87 | name: grafana-kiosk 88 | spec: 89 | affinity: 90 | nodeAffinity: 91 | requiredDuringSchedulingIgnoredDuringExecution: 92 | nodeSelectorTerms: 93 | - matchExpressions: 94 | - key: role 95 | operator: In 96 | values: 97 | - grafana-kiosk 98 | containers: 99 | - args: 100 | - -URL=FIXME URL 101 | - -login-method=local 102 | - -username=FIXME USER 103 | - -password=FIXME PASS 104 | - -kiosk-mode=full 105 | env: 106 | - name: DISPLAY 107 | value: unix:0.0 108 | - name: KIOSK_DEBUG 109 | value: "false" 110 | image: FIXME IMAGE 111 | imagePullPolicy: IfNotPresent 112 | name: grafana-kiosk 113 | ports: 114 | - containerPort: 3000 115 | name: http-metrics 116 | protocol: TCP 117 | resources: 118 | limits: 119 | cpu: "3" 120 | memory: 2000Mi 121 | requests: 122 | cpu: "1" 123 | memory: 500Mi 124 | volumeMounts: 125 | - mountPath: /tmp/.X11-unix 126 | name: x11 127 | tolerations: 128 | - effect: NoSchedule 129 | key: role 130 | operator: Equal 131 | value: grafana-kiosk 132 | volumes: 133 | - hostPath: 134 | path: /tmp/.X11-unix 135 | name: x11 136 | status: {} 137 | --- 138 | apiVersion: v1 139 | kind: ServiceAccount 140 | metadata: 141 | creationTimestamp: null 142 | name: kiosk-cron 143 | namespace: grafana-kiosk 144 | --- 145 | apiVersion: rbac.authorization.k8s.io/v1 146 | kind: Role 147 | metadata: 148 | creationTimestamp: null 149 | name: kiosk-cron 150 | namespace: grafana-kiosk 151 | rules: 152 | - apiGroups: 153 | - apps 154 | - extensions 155 | resources: 156 | - deployments 157 | verbs: 158 | - get 159 | - patch 160 | --- 161 | apiVersion: rbac.authorization.k8s.io/v1 162 | kind: RoleBinding 163 | metadata: 164 | creationTimestamp: null 165 | name: kiosk-cron 166 | namespace: grafana-kiosk 167 | roleRef: 168 | apiGroup: rbac.authorization.k8s.io 169 | kind: Role 170 | name: kiosk-cron 171 | subjects: 172 | - kind: ServiceAccount 173 | name: kiosk-cron 174 | namespace: grafana-kiosk 175 | --- 176 | apiVersion: batch/v1 177 | kind: CronJob 178 | metadata: 179 | creationTimestamp: null 180 | name: kiosk-cron 181 | namespace: grafana-kiosk 182 | spec: 183 | concurrencyPolicy: Forbid 184 | jobTemplate: 185 | metadata: 186 | creationTimestamp: null 187 | spec: 188 | backoffLimit: 2 189 | template: 190 | metadata: 191 | creationTimestamp: null 192 | spec: 193 | containers: 194 | - command: 195 | - kubectl 196 | - rollout 197 | - restart 198 | - deployment/grafana-kiosk 199 | image: rancher/kubectl:v1.22.2 200 | name: kubectl 201 | resources: {} 202 | restartPolicy: Never 203 | serviceAccountName: kiosk-cron 204 | schedule: 00 07 * * * 205 | status: {} 206 | 207 | ``` 208 | 209 | 210 | ## Debugging 211 | 212 | Debugging chromium issues is tricky... 213 | 214 | If you get a "context canceled" error when the kiosk is starting, this is typically because the chromium process failed to start or crashed. 215 | You can enable `KIOSK_DEBUG=true` env var but this will only help so much. 216 | 217 | Instead I modified the docker container to sleep forever so I could run chromium exec'd into it: 218 | 219 | using: 220 | ```dockerfile 221 | #ENTRYPOINT [ "/kiosk/grafana-kiosk" ] 222 | CMD sleep infinity 223 | ``` 224 | 225 | Then inside the container I was trying to run what I believe to be the similar command as run by the kiosk program 226 | 227 | ```bash 228 | chromium --autoplay-policy=no-user-gesture-required --bwsi --check-for-update-interval=31536000 --disable-atures=Translate --disable-notifications --disable-overlay-scrollbar --disable-sync --ignore-certificate-errors=false --incognito --kiosk --noerrdialogs --kiosk --start-fullscreen --start-maximized --user-agent="Mozilla/5.0 (X11; CrOS armv7l 13597.84.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36" --window-position="0,0" --user-data-dir="/tmp/123" 229 | ``` 230 | 231 | for debian bookworm it seemed like i made progress adding: 232 | ```bash 233 | --no-zygote --no-sandbox 234 | ``` 235 | 236 | While disabling these options worked, I found it a bit unsatisfying... and googling led me to believe that this may somehow be a raspberry pi problem which was already handled by the raspberry pi os images. 237 | 238 | Unfortunately they don't officially publish docker images for raspberry pi os, but I was able to find someone that is, using these images fixed my problems, even though they are huge images... 239 | 240 | Still wish I had succeeded in getting the debian images to work, but maybe I'll revisit it again someday, and since I'm running on raspberry pi's and way exceeded how much time I want to spend on this, this is how it stays for now :) -------------------------------------------------------------------------------- /config-example.yaml: -------------------------------------------------------------------------------- 1 | general: 2 | kiosk-mode: full 3 | autofit: true 4 | lxde: true 5 | lxde-home: /home/pi 6 | 7 | target: 8 | login-method: anon 9 | username: user 10 | password: changeme 11 | playlist: false 12 | use-mfa: false 13 | URL: https://play.grafana.org 14 | ignore-certificate-errors: false 15 | 16 | goauth: 17 | auto-login: false 18 | fieldname-username: username 19 | fieldname-password: password -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/grafana/grafana-kiosk 2 | 3 | go 1.23 4 | 5 | require ( 6 | github.com/chromedp/cdproto v0.0.0-20250120090109-d38428e4d9c8 7 | github.com/chromedp/chromedp v0.12.1 8 | github.com/ilyakaznacheev/cleanenv v1.5.0 9 | github.com/magefile/mage v1.15.0 10 | github.com/smartystreets/goconvey v1.8.1 11 | google.golang.org/api v0.218.0 12 | ) 13 | 14 | require ( 15 | cloud.google.com/go/compute/metadata v0.6.0 // indirect 16 | github.com/google/s2a-go v0.1.9 // indirect 17 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect 18 | github.com/smarty/assertions v1.16.0 // indirect 19 | golang.org/x/crypto v0.32.0 // indirect 20 | golang.org/x/net v0.34.0 // indirect 21 | golang.org/x/oauth2 v0.25.0 // indirect 22 | golang.org/x/text v0.21.0 // indirect 23 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250122153221-138b5a5a4fd4 // indirect 24 | google.golang.org/grpc v1.70.0 // indirect 25 | google.golang.org/protobuf v1.36.3 // indirect 26 | ) 27 | 28 | require ( 29 | cloud.google.com/go/auth v0.14.0 // indirect 30 | cloud.google.com/go/auth/oauth2adapt v0.2.7 // indirect 31 | github.com/BurntSushi/toml v1.4.0 // indirect 32 | github.com/chromedp/sysutil v1.1.0 // indirect 33 | github.com/felixge/httpsnoop v1.0.4 // indirect 34 | github.com/go-logr/logr v1.4.2 // indirect 35 | github.com/go-logr/stdr v1.2.2 // indirect 36 | github.com/gobwas/httphead v0.1.0 // indirect 37 | github.com/gobwas/pool v0.2.1 // indirect 38 | github.com/gobwas/ws v1.4.0 // indirect 39 | github.com/googleapis/gax-go/v2 v2.14.1 // indirect 40 | github.com/gopherjs/gopherjs v1.17.2 // indirect 41 | github.com/joho/godotenv v1.5.1 // indirect 42 | github.com/josharian/intern v1.0.0 // indirect 43 | github.com/jtolds/gls v4.20.0+incompatible // indirect 44 | github.com/mailru/easyjson v0.9.0 // indirect 45 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 46 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect 47 | go.opentelemetry.io/otel v1.34.0 // indirect 48 | go.opentelemetry.io/otel/metric v1.34.0 // indirect 49 | go.opentelemetry.io/otel/trace v1.34.0 // indirect 50 | golang.org/x/sys v0.29.0 // indirect 51 | gopkg.in/yaml.v3 v3.0.1 // indirect 52 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 // indirect 53 | ) 54 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go/auth v0.14.0 h1:A5C4dKV/Spdvxcl0ggWwWEzzP7AZMJSEIgrkngwhGYM= 2 | cloud.google.com/go/auth v0.14.0/go.mod h1:CYsoRL1PdiDuqeQpZE0bP2pnPrGqFcOkI0nldEQis+A= 3 | cloud.google.com/go/auth/oauth2adapt v0.2.7 h1:/Lc7xODdqcEw8IrZ9SvwnlLX6j9FHQM74z6cBk9Rw6M= 4 | cloud.google.com/go/auth/oauth2adapt v0.2.7/go.mod h1:NTbTTzfvPl1Y3V1nPpOgl2w6d/FjO7NNUQaWSox6ZMc= 5 | cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I= 6 | cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg= 7 | github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= 8 | github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= 9 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 10 | github.com/chromedp/cdproto v0.0.0-20250120090109-d38428e4d9c8 h1:Q2byC+xLgH/Z7hExJ8G/jVqsvCfGhMmNgM1ysZARA3o= 11 | github.com/chromedp/cdproto v0.0.0-20250120090109-d38428e4d9c8/go.mod h1:RTGuBeCeabAJGi3OZf71a6cGa7oYBfBP75VJZFLv6SU= 12 | github.com/chromedp/chromedp v0.12.1 h1:kBMblXk7xH5/6j3K9uk8d7/c+fzXWiUsCsPte0VMwOA= 13 | github.com/chromedp/chromedp v0.12.1/go.mod h1:F6+wdq9LKFDMoyxhq46ZLz4VLXrsrCAR3sFqJz4Nqc0= 14 | github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= 15 | github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= 16 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 17 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 18 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 19 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 20 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 21 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 22 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 23 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 24 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 25 | github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= 26 | github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= 27 | github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= 28 | github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= 29 | github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= 30 | github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= 31 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 32 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 33 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 34 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 35 | github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= 36 | github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 37 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 38 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 39 | github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= 40 | github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= 41 | github.com/googleapis/gax-go/v2 v2.14.1 h1:hb0FFeiPaQskmvakKu5EbCbpntQn48jyHuvrkurSS/Q= 42 | github.com/googleapis/gax-go/v2 v2.14.1/go.mod h1:Hb/NubMaVM88SrNkvl8X/o8XWwDJEPqouaLeN2IUxoA= 43 | github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= 44 | github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= 45 | github.com/ilyakaznacheev/cleanenv v1.5.0 h1:0VNZXggJE2OYdXE87bfSSwGxeiGt9moSR2lOrsHHvr4= 46 | github.com/ilyakaznacheev/cleanenv v1.5.0/go.mod h1:a5aDzaJrLCQZsazHol1w8InnDcOX0OColm64SlIi6gk= 47 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 48 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 49 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 50 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 51 | github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= 52 | github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 53 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 54 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 55 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 56 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 57 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= 58 | github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 59 | github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= 60 | github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= 61 | github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= 62 | github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= 63 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 64 | github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= 65 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 66 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 67 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 68 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 69 | github.com/smarty/assertions v1.16.0 h1:EvHNkdRA4QHMrn75NZSoUQ/mAUXAYWfatfB01yTCzfY= 70 | github.com/smarty/assertions v1.16.0/go.mod h1:duaaFdCS0K9dnoM50iyek/eYINOZ64gbh1Xlf6LG7AI= 71 | github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= 72 | github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= 73 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 74 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 75 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= 76 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= 77 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0 h1:r6I7RJCN86bpD/FQwedZ0vSixDpwuWREjW9oRMsmqDc= 78 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.54.0/go.mod h1:B9yO6b04uB80CzjedvewuqDhxJxi11s7/GtiGa8bAjI= 79 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= 80 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= 81 | go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 82 | go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 83 | go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 84 | go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 85 | go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4= 86 | go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= 87 | go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU= 88 | go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= 89 | go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 90 | go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 91 | golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 92 | golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= 93 | golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 94 | golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 95 | golang.org/x/oauth2 v0.25.0 h1:CY4y7XT9v0cRI9oupztF8AgiIu99L/ksR/Xp/6jrZ70= 96 | golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 97 | golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 98 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 99 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= 101 | golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 102 | golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= 103 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 104 | golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= 105 | golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 106 | google.golang.org/api v0.218.0 h1:x6JCjEWeZ9PFCRe9z0FBrNwj7pB7DOAqT35N+IPnAUA= 107 | google.golang.org/api v0.218.0/go.mod h1:5VGHBAkxrA/8EFjLVEYmMUJ8/8+gWWQ3s4cFH0FxG2M= 108 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250122153221-138b5a5a4fd4 h1:yrTuav+chrF0zF/joFGICKTzYv7mh/gr9AgEXrVU8ao= 109 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250122153221-138b5a5a4fd4/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= 110 | google.golang.org/grpc v1.70.0 h1:pWFv03aZoHzlRKHWicjsZytKAiYCtNS0dHbXnIdq7jQ= 111 | google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= 112 | google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= 113 | google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 114 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 115 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 116 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 117 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 118 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 119 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3 h1:slmdOY3vp8a7KQbHkL+FLbvbkgMqmXojpFUO/jENuqQ= 120 | olympos.io/encoding/edn v0.0.0-20201019073823-d3554ca0b0a3/go.mod h1:oVgVk4OWVDi43qWBEyGhXgYxt7+ED4iYNpTngSLX2Iw= 121 | -------------------------------------------------------------------------------- /pkg/cmd/grafana-kiosk/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "os" 9 | 10 | "github.com/ilyakaznacheev/cleanenv" 11 | 12 | "github.com/grafana/grafana-kiosk/pkg/initialize" 13 | "github.com/grafana/grafana-kiosk/pkg/kiosk" 14 | ) 15 | 16 | var ( 17 | // Version this is set during build time using git tags 18 | Version string 19 | ) 20 | 21 | // Args command-line parameters. 22 | type Args struct { 23 | AutoFit bool 24 | IgnoreCertificateErrors bool 25 | IsPlayList bool 26 | OauthAutoLogin bool 27 | OauthWaitForPasswordField bool 28 | OauthWaitForPasswordFieldIgnoreClass string 29 | LXDEEnabled bool 30 | UseMFA bool 31 | Audience string 32 | KeyFile string 33 | APIKey string 34 | LXDEHome string 35 | ConfigPath string 36 | Mode string 37 | LoginMethod string 38 | URL string 39 | Username string 40 | PageLoadDelayMS int64 41 | Password string 42 | UsernameField string 43 | PasswordField string 44 | WindowPosition string 45 | WindowSize string 46 | ScaleFactor string 47 | } 48 | 49 | // ProcessArgs processes and handles CLI arguments. 50 | func ProcessArgs(cfg interface{}) Args { 51 | var processedArgs Args 52 | 53 | flagSettings := flag.NewFlagSet("grafana-kiosk", flag.ContinueOnError) 54 | flagSettings.StringVar(&processedArgs.ConfigPath, "c", "", "Path to configuration file (config.yaml)") 55 | flagSettings.StringVar(&processedArgs.LoginMethod, "login-method", "anon", "[anon|local|gcom|goauth|idtoken|apikey|aws]") 56 | flagSettings.StringVar(&processedArgs.Username, "username", "guest", "username") 57 | flagSettings.StringVar(&processedArgs.Password, "password", "guest", "password") 58 | flagSettings.BoolVar(&processedArgs.UseMFA, "use-mfa", false, "password") 59 | flagSettings.StringVar(&processedArgs.Mode, "kiosk-mode", "full", "Kiosk Display Mode [full|tv|disabled]\nfull = No TOPNAV and No SIDEBAR\ntv = No SIDEBAR\ndisabled = omit option\n") 60 | flagSettings.StringVar(&processedArgs.URL, "URL", "https://play.grafana.org", "URL to Grafana server") 61 | flagSettings.StringVar(&processedArgs.WindowPosition, "window-position", "0,0", "Top Left Position of Kiosk") 62 | flagSettings.StringVar(&processedArgs.WindowSize, "window-size", "", "Size of Kiosk in pixels (width,height)") 63 | flagSettings.StringVar(&processedArgs.ScaleFactor, "scale-factor", "1.0", "Scale factor, sort of zoom") 64 | flagSettings.Int64Var(&processedArgs.PageLoadDelayMS, "page-load-delay-ms", 2000, "Delay in milliseconds before navigating to URL") 65 | flagSettings.BoolVar(&processedArgs.IsPlayList, "playlists", false, "URL is a playlist") 66 | flagSettings.BoolVar(&processedArgs.AutoFit, "autofit", true, "Fit panels to screen") 67 | flagSettings.BoolVar(&processedArgs.LXDEEnabled, "lxde", false, "Initialize LXDE for kiosk mode") 68 | flagSettings.StringVar(&processedArgs.LXDEHome, "lxde-home", "/home/pi", "Path to home directory of LXDE user running X Server") 69 | flagSettings.BoolVar(&processedArgs.IgnoreCertificateErrors, "ignore-certificate-errors", false, "Ignore SSL/TLS certificate error") 70 | flagSettings.BoolVar(&processedArgs.OauthAutoLogin, "auto-login", false, "oauth_auto_login is enabled in grafana config") 71 | flagSettings.BoolVar(&processedArgs.OauthWaitForPasswordField, "wait-for-password-field", false, "oauth_auto_login is enabled in grafana config") 72 | flagSettings.StringVar(&processedArgs.OauthWaitForPasswordFieldIgnoreClass, "wait-for-password-field-class", "", "oauth_auto_login is enabled in grafana config") 73 | flagSettings.StringVar(&processedArgs.UsernameField, "field-username", "username", "Fieldname for the username") 74 | flagSettings.StringVar(&processedArgs.PasswordField, "field-password", "password", "Fieldname for the password") 75 | flagSettings.StringVar(&processedArgs.Audience, "audience", "", "idtoken audience") 76 | flagSettings.StringVar(&processedArgs.KeyFile, "keyfile", "key.json", "idtoken json credentials") 77 | flagSettings.StringVar(&processedArgs.APIKey, "apikey", "", "apikey") 78 | 79 | fu := flagSettings.Usage 80 | flagSettings.Usage = func() { 81 | fu() 82 | 83 | envHelp, _ := cleanenv.GetDescription(cfg, nil) 84 | 85 | fmt.Fprintln(flagSettings.Output()) 86 | fmt.Fprintln(flagSettings.Output(), envHelp) 87 | } 88 | 89 | err := flagSettings.Parse(os.Args[1:]) 90 | if err != nil { 91 | os.Exit(-1) 92 | } 93 | 94 | return processedArgs 95 | } 96 | 97 | func setEnvironment() { 98 | // for linux/X display must be set 99 | var displayEnv = os.Getenv("DISPLAY") 100 | if displayEnv == "" { 101 | log.Println("DISPLAY not set, autosetting to :0.0") 102 | if err := os.Setenv("DISPLAY", ":0.0"); err != nil { 103 | log.Println("Error setting DISPLAY", err.Error()) 104 | } 105 | displayEnv = os.Getenv("DISPLAY") 106 | } 107 | 108 | log.Println("DISPLAY=", displayEnv) 109 | 110 | var xAuthorityEnv = os.Getenv("XAUTHORITY") 111 | if xAuthorityEnv == "" { 112 | log.Println("XAUTHORITY not set, autosetting") 113 | // use HOME of current user 114 | var homeEnv = os.Getenv("HOME") 115 | 116 | if err := os.Setenv("XAUTHORITY", homeEnv+"/.Xauthority"); err != nil { 117 | log.Println("Error setting XAUTHORITY", err.Error()) 118 | } 119 | xAuthorityEnv = os.Getenv("XAUTHORITY") 120 | } 121 | 122 | log.Println("XAUTHORITY=", xAuthorityEnv) 123 | } 124 | 125 | func summary(cfg *kiosk.Config) { 126 | // general 127 | log.Println("AutoFit:", cfg.General.AutoFit) 128 | log.Println("LXDEEnabled:", cfg.General.LXDEEnabled) 129 | log.Println("LXDEHome:", cfg.General.LXDEHome) 130 | log.Println("Mode:", cfg.General.Mode) 131 | log.Println("WindowPosition:", cfg.General.WindowPosition) 132 | log.Println("WindowSize:", cfg.General.WindowSize) 133 | log.Println("ScaleFactor:", cfg.General.ScaleFactor) 134 | log.Println("PageLoadDelayMS:", cfg.General.PageLoadDelayMS) 135 | // target 136 | log.Println("URL:", cfg.Target.URL) 137 | log.Println("LoginMethod:", cfg.Target.LoginMethod) 138 | log.Println("Username:", cfg.Target.Username) 139 | log.Println("Password:", "*redacted*") 140 | log.Println("IgnoreCertificateErrors:", cfg.Target.IgnoreCertificateErrors) 141 | log.Println("IsPlayList:", cfg.Target.IsPlayList) 142 | log.Println("UseMFA:", cfg.Target.UseMFA) 143 | // goauth 144 | log.Println("Fieldname AutoLogin:", cfg.GoAuth.AutoLogin) 145 | log.Println("Fieldname Username:", cfg.GoAuth.UsernameField) 146 | log.Println("Fieldname Password:", cfg.GoAuth.PasswordField) 147 | } 148 | 149 | func main() { 150 | var cfg kiosk.Config 151 | fmt.Println("GrafanaKiosk Version:", Version) 152 | // set the version 153 | cfg.BuildInfo.Version = Version 154 | 155 | // override 156 | args := ProcessArgs(&cfg) 157 | 158 | // validate auth methods 159 | switch args.LoginMethod { 160 | case "goauth", "anon", "local", "gcom", "idtoken", "apikey", "aws": 161 | default: 162 | log.Println("Invalid auth method", args.LoginMethod) 163 | os.Exit(-1) 164 | } 165 | 166 | // check if config specified 167 | if args.ConfigPath != "" { 168 | // read configuration from the file and then override with environment variables 169 | if err := cleanenv.ReadConfig(args.ConfigPath, &cfg); err != nil { 170 | log.Println("Error reading config file", err) 171 | os.Exit(-1) 172 | } else { 173 | log.Println("Using config from", args.ConfigPath) 174 | } 175 | } else { 176 | log.Println("No config specified, using environment and args") 177 | // no config, use environment and args 178 | if err := cleanenv.ReadEnv(&cfg); err != nil { 179 | log.Println("Error reading config from environment", err) 180 | } 181 | cfg.Target.URL = args.URL 182 | cfg.Target.LoginMethod = args.LoginMethod 183 | cfg.Target.Username = args.Username 184 | cfg.Target.Password = args.Password 185 | cfg.Target.IgnoreCertificateErrors = args.IgnoreCertificateErrors 186 | cfg.Target.IsPlayList = args.IsPlayList 187 | cfg.Target.UseMFA = args.UseMFA 188 | // 189 | cfg.General.AutoFit = args.AutoFit 190 | cfg.General.LXDEEnabled = args.LXDEEnabled 191 | cfg.General.LXDEHome = args.LXDEHome 192 | cfg.General.Mode = args.Mode 193 | cfg.General.WindowPosition = args.WindowPosition 194 | cfg.General.WindowSize = args.WindowSize 195 | cfg.General.ScaleFactor = args.ScaleFactor 196 | cfg.General.PageLoadDelayMS = args.PageLoadDelayMS 197 | // 198 | cfg.GoAuth.AutoLogin = args.OauthAutoLogin 199 | cfg.GoAuth.UsernameField = args.UsernameField 200 | cfg.GoAuth.PasswordField = args.PasswordField 201 | 202 | cfg.IDToken.Audience = args.Audience 203 | cfg.IDToken.KeyFile = args.KeyFile 204 | 205 | cfg.APIKey.APIKey = args.APIKey 206 | } 207 | 208 | // make sure the url has content 209 | if cfg.Target.URL == "" { 210 | os.Exit(1) 211 | } 212 | // validate url 213 | _, err := url.ParseRequestURI(cfg.Target.URL) 214 | if err != nil { 215 | panic(err) 216 | } 217 | 218 | summary(&cfg) 219 | 220 | if cfg.General.LXDEEnabled { 221 | initialize.LXDE(cfg.General.LXDEHome) 222 | } 223 | 224 | // for linux/X display must be set 225 | setEnvironment() 226 | log.Println("method ", cfg.Target.LoginMethod) 227 | 228 | messages := make(chan string) 229 | switch cfg.Target.LoginMethod { 230 | case "local": 231 | log.Printf("Launching local login kiosk") 232 | kiosk.GrafanaKioskLocal(&cfg, messages) 233 | case "gcom": 234 | log.Printf("Launching GCOM login kiosk") 235 | kiosk.GrafanaKioskGCOM(&cfg, messages) 236 | case "goauth": 237 | log.Printf("Launching Generic Oauth login kiosk") 238 | kiosk.GrafanaKioskGenericOauth(&cfg, messages) 239 | case "idtoken": 240 | log.Printf("Launching idtoken oauth kiosk") 241 | kiosk.GrafanaKioskIDToken(&cfg, messages) 242 | case "apikey": 243 | log.Printf("Launching apikey kiosk") 244 | kiosk.GrafanaKioskAPIKey(&cfg, messages) 245 | case "aws": 246 | log.Printf("Launching AWS SSO kiosk") 247 | kiosk.GrafanaKioskAWSLogin(&cfg, messages) 248 | default: 249 | log.Printf("Launching ANON login kiosk") 250 | kiosk.GrafanaKioskAnonymous(&cfg, messages) 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /pkg/cmd/grafana-kiosk/main_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "testing" 7 | 8 | "github.com/grafana/grafana-kiosk/pkg/kiosk" 9 | "github.com/ilyakaznacheev/cleanenv" 10 | . "github.com/smartystreets/goconvey/convey" 11 | ) 12 | 13 | // TestKiosk checks kiosk command. 14 | func TestMain(t *testing.T) { 15 | Convey("Given Default Configuration", t, func() { 16 | cfg := kiosk.Config{ 17 | BuildInfo: kiosk.BuildInfo{ 18 | Version: "1.0.0", 19 | }, 20 | General: kiosk.General{ 21 | AutoFit: true, 22 | LXDEEnabled: true, 23 | LXDEHome: "/home/pi", 24 | Mode: "full", 25 | WindowPosition: "0,0", 26 | WindowSize: "1920,1080", 27 | ScaleFactor: "1.0", 28 | }, 29 | Target: kiosk.Target{ 30 | IgnoreCertificateErrors: false, 31 | IsPlayList: false, 32 | UseMFA: false, 33 | LoginMethod: "local", 34 | Password: "admin", 35 | URL: "http://localhost:3000", 36 | Username: "admin", 37 | }, 38 | GoAuth: kiosk.GoAuth{ 39 | AutoLogin: false, 40 | UsernameField: "user", 41 | PasswordField: "password", 42 | }, 43 | IDToken: kiosk.IDToken{ 44 | KeyFile: "/tmp/key.json", 45 | Audience: "clientid", 46 | }, 47 | APIKey: kiosk.APIKey{ 48 | APIKey: "abc", 49 | }, 50 | } 51 | Convey("General Options", func() { 52 | Convey("Parameter - autofit", func() { 53 | oldArgs := os.Args 54 | defer func() { os.Args = oldArgs }() 55 | os.Args = []string{"grafana-kiosk", ""} 56 | // starts out default true 57 | result := ProcessArgs(cfg) 58 | So(result.AutoFit, ShouldBeTrue) 59 | // flag to set it false 60 | os.Args = []string{ 61 | "grafana-kiosk", 62 | "--autofit=false", 63 | } 64 | result = ProcessArgs(cfg) 65 | So(result.AutoFit, ShouldBeFalse) 66 | }) 67 | 68 | Convey("Environment - autofit", func() { 69 | oldArgs := os.Args 70 | defer func() { os.Args = oldArgs }() 71 | os.Args = []string{"grafana-kiosk", ""} 72 | os.Setenv("KIOSK_AUTOFIT", "false") 73 | cfg := kiosk.Config{} 74 | if err := cleanenv.ReadEnv(&cfg); err != nil { 75 | log.Println("Error reading config from environment", err) 76 | } 77 | So(cfg.General.AutoFit, ShouldBeFalse) 78 | }) 79 | }) 80 | // end of general options 81 | 82 | Convey("Anonymous Login", func() { 83 | oldArgs := os.Args 84 | defer func() { os.Args = oldArgs }() 85 | os.Args = []string{"grafana-kiosk", ""} 86 | result := ProcessArgs(cfg) 87 | So(result.LoginMethod, ShouldEqual, "anon") 88 | So(result.URL, ShouldEqual, "https://play.grafana.org") 89 | So(result.AutoFit, ShouldBeTrue) 90 | }) 91 | Convey("Local Login", func() { 92 | oldArgs := os.Args 93 | defer func() { os.Args = oldArgs }() 94 | os.Args = []string{"grafana-kiosk", "-login-method", "local"} 95 | result := ProcessArgs(cfg) 96 | So(result.LoginMethod, ShouldEqual, "local") 97 | So(result.URL, ShouldEqual, "https://play.grafana.org") 98 | So(result.AutoFit, ShouldBeTrue) 99 | }) 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /pkg/initialize/lxde.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "os/exec" 7 | ) 8 | 9 | // LXDE runs shell commands to setup LXDE for kiosk mode. 10 | func LXDE(path string) { 11 | var command = "/usr/bin/lxpanel" 12 | 13 | args := []string{"--profile", "LXDE"} 14 | runCommand(path, command, args, true) 15 | command = "/usr/bin/pcmanfm" 16 | args = []string{"--desktop", "--profile", "LXDE"} 17 | runCommand(path, command, args, true) 18 | command = "/usr/bin/xset" 19 | runCommand(path, command, args, true) 20 | args = []string{"s", "off"} 21 | runCommand(path, command, args, true) 22 | args = []string{"-dpms"} 23 | runCommand(path, command, args, true) 24 | args = []string{"s", "noblank"} 25 | runCommand(path, command, args, true) 26 | command = "/usr/bin/unclutter" 27 | displayEnv := os.Getenv("DISPLAY") 28 | args = []string{"-display", displayEnv, "-idle", "5"} 29 | 30 | go runCommand(path, command, args, true) 31 | } 32 | 33 | func runCommand(path string, command string, args []string, waitForEnd bool) { 34 | // check if command exists 35 | log.Printf("path: %v", path) 36 | log.Printf("command: %v", command) 37 | log.Printf("arg0: %v", args[0]) 38 | cmd := exec.Command(command, args...) 39 | 40 | cmd.Env = append(os.Environ(), 41 | "DISPLAY=:0.0", 42 | "XAUTHORITY="+path+"/.Xauthority", 43 | ) 44 | err := cmd.Start() 45 | 46 | if err != nil { 47 | // log.Printf(err) 48 | log.Printf("Error in output, ignoring...") 49 | } 50 | 51 | if waitForEnd { 52 | log.Printf("Waiting for command to finish...") 53 | 54 | err = cmd.Wait() 55 | 56 | if err != nil { 57 | log.Printf("Command finished with error: %v", err) 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /pkg/initialize/lxde_test.go: -------------------------------------------------------------------------------- 1 | package initialize 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | // TestKiosk checks kiosk command. 10 | func TestLXDESettings(t *testing.T) { 11 | Convey("Given LXDE settings", t, func() { 12 | Convey("LXDE should...", func() { 13 | So(true, ShouldBeTrue) 14 | }) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/kiosk/anonymous_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/chromedp/chromedp" 10 | ) 11 | 12 | // GrafanaKioskAnonymous creates a chrome-based kiosk using a local grafana-server account. 13 | func GrafanaKioskAnonymous(cfg *Config, messages chan string) { 14 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 15 | if err != nil { 16 | panic(err) 17 | } 18 | 19 | log.Println("Using temp dir:", dir) 20 | defer os.RemoveAll(dir) 21 | 22 | opts := generateExecutorOptions(dir, cfg) 23 | 24 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 25 | defer cancel() 26 | 27 | // also set up a custom logger 28 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 29 | defer cancel() 30 | 31 | listenChromeEvents(taskCtx, cfg, consoleAPICall|targetCrashed) 32 | 33 | // ensure that the browser process is started 34 | if err := chromedp.Run(taskCtx); err != nil { 35 | panic(err) 36 | } 37 | 38 | // Give browser time to load 39 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 40 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 41 | 42 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 43 | 44 | log.Println("Navigating to ", generatedURL) 45 | /* 46 | Launch chrome and look for main-view element 47 | */ 48 | if err := chromedp.Run(taskCtx, 49 | chromedp.Navigate(generatedURL), 50 | chromedp.WaitVisible(`//div[@class="main-view"]`, chromedp.BySearch), 51 | ); err != nil { 52 | panic(err) 53 | } 54 | // blocking wait 55 | for { 56 | messageFromChrome := <-messages 57 | if err := chromedp.Run(taskCtx, 58 | chromedp.Navigate(generatedURL), 59 | ); err != nil { 60 | panic(err) 61 | } 62 | log.Println("Chromium output:", messageFromChrome) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /pkg/kiosk/apikey_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "log" 7 | "net/url" 8 | "os" 9 | "strings" 10 | "time" 11 | 12 | "github.com/chromedp/cdproto/fetch" 13 | "github.com/chromedp/chromedp" 14 | ) 15 | 16 | // GrafanaKioskAPIKey creates a chrome-based kiosk using a grafana api key. 17 | func GrafanaKioskAPIKey(cfg *Config, messages chan string) { 18 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 19 | if err != nil { 20 | panic(err) 21 | } 22 | 23 | log.Println("Using temp dir:", dir) 24 | defer os.RemoveAll(dir) 25 | 26 | opts := generateExecutorOptions(dir, cfg) 27 | 28 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 29 | defer cancel() 30 | 31 | // also set up a custom logger 32 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 33 | defer cancel() 34 | 35 | listenChromeEvents(taskCtx, cfg, consoleAPICall|targetCrashed) 36 | 37 | // ensure that the browser process is started 38 | if err := chromedp.Run(taskCtx); err != nil { 39 | panic(err) 40 | } 41 | 42 | // Give browser time to load 43 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 44 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 45 | 46 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 47 | 48 | log.Println("Navigating to ", generatedURL) 49 | /* 50 | Launch chrome and look for main-view element 51 | */ 52 | u, err := url.Parse(cfg.Target.URL) 53 | if err != nil { 54 | panic(fmt.Errorf("url.Parse: %w", err)) 55 | } 56 | chromedp.ListenTarget(taskCtx, func(ev interface{}) { 57 | switch ev := ev.(type) { 58 | case *fetch.EventRequestPaused: 59 | go func() { 60 | fetchReq := fetch.ContinueRequest(ev.RequestID) 61 | requestURL, err := url.Parse(ev.Request.URL) 62 | if err != nil { 63 | panic(fmt.Errorf("url.Parse: %w", err)) 64 | } 65 | // handle both scheme/host, and subpath with query 66 | if strings.HasPrefix(ev.Request.URL, u.Scheme+"://"+u.Host) && 67 | strings.Contains(ev.Request.URL, "/api/ds/query?") { 68 | if cfg.General.DebugEnabled { 69 | log.Println("Appending Content-Type Header for Metric Query") 70 | } 71 | fetchReq.Headers = append( 72 | fetchReq.Headers, 73 | &fetch.HeaderEntry{Name: "content-type", Value: "application/json"}, 74 | ) 75 | } 76 | // if they match, append the Bearer token 77 | if requestURL.Host == u.Host { 78 | if cfg.General.DebugEnabled { 79 | log.Println("Appending Header Authorization: Bearer REDACTED") 80 | } 81 | fetchReq.Headers = append( 82 | fetchReq.Headers, 83 | &fetch.HeaderEntry{Name: "Authorization", Value: "Bearer " + cfg.APIKey.APIKey}, 84 | ) 85 | } 86 | err = fetchReq.Do(GetExecutor(taskCtx)) 87 | if err != nil { 88 | panic(fmt.Errorf("apikey fetchReq error: %w", err)) 89 | } 90 | }() 91 | } 92 | }) 93 | if err := chromedp.Run( 94 | taskCtx, 95 | fetch.Enable().WithPatterns([]*fetch.RequestPattern{{URLPattern: u.Scheme + "://" + u.Host + "/*"}}), 96 | chromedp.Navigate(generatedURL), 97 | chromedp.ActionFunc(func(context.Context) error { 98 | log.Printf("Sleeping %d MS before continuing", cfg.General.PageLoadDelayMS) 99 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 100 | return nil 101 | }), 102 | chromedp.WaitVisible(`//div[@class="main-view"]`, chromedp.BySearch), 103 | ); err != nil { 104 | panic(err) 105 | } 106 | // blocking wait 107 | for { 108 | messageFromChrome := <-messages 109 | if err := chromedp.Run(taskCtx, 110 | chromedp.Navigate(generatedURL), 111 | ); err != nil { 112 | panic(err) 113 | } 114 | log.Println("Chromium output:", messageFromChrome) 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /pkg/kiosk/aws_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/chromedp/chromedp" 10 | "github.com/chromedp/chromedp/kb" 11 | ) 12 | 13 | // GrafanaKioskAWSLogin Provides login for AWS Managed Grafana instances 14 | func GrafanaKioskAWSLogin(cfg *Config, messages chan string) { 15 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 16 | if err != nil { 17 | panic(err) 18 | } 19 | 20 | log.Println("Using temp dir:", dir) 21 | defer os.RemoveAll(dir) 22 | 23 | opts := generateExecutorOptions(dir, cfg) 24 | 25 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 26 | defer cancel() 27 | 28 | // also set up a custom logger 29 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 30 | defer cancel() 31 | 32 | listenChromeEvents(taskCtx, cfg, targetCrashed) 33 | // Give browser time to load 34 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 35 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 36 | 37 | // ensure that the browser process is started 38 | if err := chromedp.Run(taskCtx); err != nil { 39 | panic(err) 40 | } 41 | 42 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 43 | 44 | log.Println("Navigating to ", generatedURL) 45 | 46 | // Give browser time to load next page (this can be prone to failure, explore different options vs sleeping) 47 | time.Sleep(2000 * time.Millisecond) 48 | 49 | if err := chromedp.Run(taskCtx, 50 | chromedp.Navigate(generatedURL), 51 | chromedp.WaitVisible(`//a[contains(@href,'login/sso')]`, chromedp.BySearch), 52 | chromedp.Click(`//a[contains(@href,'login/sso')]`, chromedp.BySearch), 53 | chromedp.WaitVisible(`input#awsui-input-0`, chromedp.BySearch), 54 | chromedp.SendKeys(`input#awsui-input-0`, cfg.Target.Username+kb.Enter, chromedp.BySearch), 55 | chromedp.WaitVisible(`input#awsui-input-1`, chromedp.BySearch), 56 | chromedp.SendKeys(`input#awsui-input-1`, cfg.Target.Password+kb.Enter, chromedp.BySearch), 57 | ); err != nil { 58 | panic(err) 59 | } 60 | 61 | if cfg.Target.UseMFA { 62 | if err := chromedp.Run(taskCtx, 63 | chromedp.WaitNotVisible(`input#awsui-input-2`, chromedp.BySearch), 64 | ); err != nil { 65 | panic(err) 66 | } 67 | } 68 | 69 | // blocking wait 70 | for { 71 | messageFromChrome := <-messages 72 | if err := chromedp.Run(taskCtx, 73 | chromedp.Navigate(generatedURL), 74 | ); err != nil { 75 | panic(err) 76 | } 77 | log.Println("Chromium output:", messageFromChrome) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /pkg/kiosk/config.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | // BuildInfo contains the build version 4 | type BuildInfo struct { 5 | Version string `yaml:"version,omitempty"` 6 | } 7 | 8 | // General non-site specific configuations 9 | type General struct { 10 | AutoFit bool `yaml:"autofit" env:"KIOSK_AUTOFIT" env-default:"true" env-description:"fit panels to screen"` 11 | DebugEnabled bool `yaml:"debug" env:"KIOSK_DEBUG" env-default:"false" env-description:"enables debug output"` 12 | GPUEnabled bool `yaml:"gpu-enabled" env:"KIOSK_GPU_ENABLED" env-default:"false" env-description:"disable GPU support"` 13 | LXDEEnabled bool `yaml:"lxde" env:"KIOSK_LXDE_ENABLED" env-default:"false" env-description:"initialize LXDE for kiosk mode"` 14 | LXDEHome string `yaml:"lxde-home" env:"KIOSK_LXDE_HOME" env-default:"/home/pi" env-description:"path to home directory of LXDE user running X Server"` 15 | Mode string `yaml:"kiosk-mode" env:"KIOSK_MODE" env-default:"full" env-description:"[full|tv|disabled]"` 16 | OzonePlatform string `yaml:"ozone-platform" env:"KIOSK_OZONE_PLATFORM" env-default:"" env-description:"Set ozone-platform option (wayland|cast|drm|wayland|x11)"` 17 | PageLoadDelayMS int64 `yaml:"page-load-delay-ms" env:"KIOSK_PAGE_LOAD_DELAY_MS" env-default:"2000" env-description:"milliseconds to wait before expecting page load"` 18 | ScaleFactor string `yaml:"scale-factor" env:"KIOSK_SCALE_FACTOR" env-default:"1.0" env-description:"Scale factor, like zoom"` 19 | WindowPosition string `yaml:"window-position" env:"KIOSK_WINDOW_POSITION" env-default:"0,0" env-description:"Top Left Position of Kiosk"` 20 | WindowSize string `yaml:"window-size" env:"KIOSK_WINDOW_SIZE" env-default:"" env-description:"Size of Kiosk in pixels (width,height)"` 21 | } 22 | 23 | // Target the dashboard/playlist details 24 | type Target struct { 25 | IgnoreCertificateErrors bool `yaml:"ignore-certificate-errors" env:"KIOSK_IGNORE_CERTIFICATE_ERRORS" env-description:"ignore SSL/TLS certificate errors" env-default:"false"` 26 | IsPlayList bool `yaml:"playlist" env:"KIOSK_IS_PLAYLIST" env-default:"false" env-description:"URL is a playlist"` 27 | LoginMethod string `yaml:"login-method" env:"KIOSK_LOGIN_METHOD" env-default:"anon" env-description:"[anon|local|gcom|goauth|idtoken|apikey]"` 28 | Password string `yaml:"password" env:"KIOSK_LOGIN_PASSWORD" env-default:"guest" env-description:"password"` 29 | URL string `yaml:"URL" env:"KIOSK_URL" env-default:"https://play.grafana.org" env-description:"URL to Grafana server"` 30 | Username string `yaml:"username" env:"KIOSK_LOGIN_USER" env-default:"guest" env-description:"username"` 31 | UseMFA bool `yaml:"use-mfa" env:"KIOSK_USE_MFA" env-default:"false" env-description:"MFA is enabled for given account"` 32 | } 33 | 34 | // GoAuth OAuth 35 | type GoAuth struct { 36 | AutoLogin bool `yaml:"auto-login" env:"KIOSK_GOAUTH_AUTO_LOGIN" env-description:"[false|true]"` 37 | UsernameField string `yaml:"fieldname-username" env:"KIOSK_GOAUTH_FIELD_USER" env-description:"Username html input name value"` 38 | PasswordField string `yaml:"fieldname-password" env:"KIOSK_GOAUTH_FIELD_PASSWORD" env-description:"Password html input name value"` 39 | WaitForPasswordField bool `yaml:"wait-for-password-field" env:"KIOSK_GOAUTH_WAIT_FOR_PASSWORD_FIELD" env-description:"Indicate that it's necessary to wait for the password field"` 40 | WaitForPasswordFieldIgnoreClass string `yaml:"wait-for-password-field-class" env:"KIOSK_GOAUTH_WAIT_FOR_PASSWORD_FIELD_CLASS" env-description:"Ignore this password field when waiting for it being visible"` 41 | } 42 | 43 | // IDToken token based login 44 | type IDToken struct { 45 | KeyFile string `yaml:"idtoken-keyfile" env:"KIOSK_IDTOKEN_KEYFILE" env-default:"key.json" env-description:"JSON Credentials for idtoken"` 46 | Audience string `yaml:"idtoken-audience" env:"KIOSK_IDTOKEN_AUDIENCE" env-description:"Audience for idtoken, tpyically your oauth client id"` 47 | } 48 | 49 | // APIKey APIKey for login 50 | type APIKey struct { 51 | APIKey string `yaml:"apikey" env:"KIOSK_APIKEY_APIKEY" env-description:"APIKEY"` 52 | } 53 | 54 | // Config configuration for backend. 55 | type Config struct { 56 | BuildInfo BuildInfo `yaml:"buildinfo"` 57 | General General `yaml:"general"` 58 | Target Target `yaml:"target"` 59 | GoAuth GoAuth `yaml:"goauth"` 60 | IDToken IDToken `yaml:"idtoken"` 61 | APIKey APIKey `yaml:"apikey"` 62 | } 63 | -------------------------------------------------------------------------------- /pkg/kiosk/grafana_com_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/chromedp/chromedp" 10 | "github.com/chromedp/chromedp/kb" 11 | ) 12 | 13 | // GrafanaKioskGCOM creates a chrome-based kiosk using a grafana.com authenticated account. 14 | func GrafanaKioskGCOM(cfg *Config, messages chan string) { 15 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 16 | if err != nil { 17 | panic(err) 18 | } 19 | 20 | log.Println("Using temp dir:", dir) 21 | defer os.RemoveAll(dir) 22 | 23 | opts := generateExecutorOptions(dir, cfg) 24 | 25 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 26 | defer cancel() 27 | 28 | // also set up a custom logger 29 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 30 | defer cancel() 31 | 32 | listenChromeEvents(taskCtx, cfg, targetCrashed) 33 | 34 | // ensure that the browser process is started 35 | if err := chromedp.Run(taskCtx); err != nil { 36 | panic(err) 37 | } 38 | // Give browser time to load 39 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 40 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 41 | 42 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 43 | 44 | log.Println("Navigating to ", generatedURL) 45 | /* 46 | Launch chrome, click the grafana.com button, fill out login form and submit 47 | */ 48 | // XPATH of grafana.com login button = //*[@href="login/grafana_com"]/i 49 | // XPATH for grafana.com login (new) = //a[contains(@href,'login/grafana_com')] 50 | 51 | // chromedp.WaitVisible(`//*[@href="login/grafana_com"]/i`, chromedp.BySearch), 52 | 53 | // Click the grafana_com login button 54 | if err := chromedp.Run(taskCtx, 55 | chromedp.Navigate(generatedURL), 56 | chromedp.ActionFunc(func(context.Context) error { 57 | log.Println("waiting for login dialog") 58 | return nil 59 | }), 60 | chromedp.WaitVisible(`//a[contains(@href,'login/grafana_com')]`, chromedp.BySearch), 61 | chromedp.ActionFunc(func(context.Context) error { 62 | log.Println("gcom login dialog detected") 63 | return nil 64 | }), 65 | chromedp.Click(`//a[contains(@href,'login/grafana_com')]`, chromedp.BySearch), 66 | chromedp.ActionFunc(func(context.Context) error { 67 | log.Println("gcom button clicked") 68 | return nil 69 | }), 70 | ); err != nil { 71 | panic(err) 72 | } 73 | // Give browser time to load next page (this can be prone to failure, explore different options vs sleeping) 74 | time.Sleep(3000 * time.Millisecond) 75 | // Fill out grafana_com login page 76 | if err := chromedp.Run(taskCtx, 77 | chromedp.WaitVisible(`//input[@name="login"]`, chromedp.BySearch), 78 | chromedp.SendKeys(`//input[@name="login"]`, cfg.Target.Username, chromedp.BySearch), 79 | chromedp.Click(`#submit`, chromedp.ByID), 80 | chromedp.SendKeys(`//input[@name="password"]`, cfg.Target.Password+kb.Enter, chromedp.BySearch), 81 | ); err != nil { 82 | panic(err) 83 | } 84 | // blocking wait 85 | for { 86 | messageFromChrome := <-messages 87 | if err := chromedp.Run(taskCtx, 88 | chromedp.Navigate(generatedURL), 89 | ); err != nil { 90 | panic(err) 91 | } 92 | log.Println("Chromium output:", messageFromChrome) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /pkg/kiosk/grafana_genericoauth_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "time" 8 | 9 | "github.com/chromedp/chromedp" 10 | "github.com/chromedp/chromedp/kb" 11 | ) 12 | 13 | // GrafanaKioskGenericOauth creates a chrome-based kiosk using a oauth2 authenticated account. 14 | func GrafanaKioskGenericOauth(cfg *Config, messages chan string) { 15 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 16 | if err != nil { 17 | panic(err) 18 | } 19 | 20 | log.Println("Using temp dir:", dir) 21 | defer os.RemoveAll(dir) 22 | 23 | opts := generateExecutorOptions(dir, cfg) 24 | 25 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 26 | defer cancel() 27 | 28 | // also set up a custom logger 29 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 30 | defer cancel() 31 | 32 | listenChromeEvents(taskCtx, cfg, targetCrashed) 33 | 34 | // ensure that the browser process is started 35 | if err := chromedp.Run(taskCtx); err != nil { 36 | panic(err) 37 | } 38 | 39 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 40 | 41 | log.Println("Navigating to ", generatedURL) 42 | 43 | /* 44 | Launch chrome, click the GENERIC OAUTH button, fill out login form and submit 45 | */ 46 | // XPATH of grafana.com for Generic OAUTH login button = //*[@href="login/grafana_com"]/i 47 | 48 | // Click the OAUTH login button 49 | log.Println("Oauth_Auto_Login enabled: ", cfg.GoAuth.AutoLogin) 50 | 51 | if cfg.GoAuth.AutoLogin { 52 | if err := chromedp.Run(taskCtx, 53 | chromedp.Navigate(generatedURL), 54 | ); err != nil { 55 | panic(err) 56 | } 57 | } else { 58 | if err := chromedp.Run(taskCtx, 59 | chromedp.Navigate(generatedURL), 60 | chromedp.WaitVisible(`//*[@href="login/generic_oauth"]`, chromedp.BySearch), 61 | chromedp.Click(`//*[@href="login/generic_oauth"]`, chromedp.BySearch), 62 | ); err != nil { 63 | panic(err) 64 | } 65 | } 66 | 67 | // Give browser time to load 68 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 69 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 70 | 71 | // Fill out OAUTH login page 72 | 73 | if cfg.GoAuth.WaitForPasswordField { 74 | if err := chromedp.Run(taskCtx, 75 | chromedp.WaitVisible(`//input[@name="`+cfg.GoAuth.UsernameField+`"]`, chromedp.BySearch), 76 | chromedp.SendKeys(`//input[@name="`+cfg.GoAuth.UsernameField+`"]`, cfg.Target.Username+kb.Enter, chromedp.BySearch), 77 | chromedp.WaitVisible(`//input[@name="`+cfg.GoAuth.PasswordField+`" and not(@class="`+cfg.GoAuth.WaitForPasswordFieldIgnoreClass+`")]`, chromedp.BySearch), 78 | chromedp.SendKeys(`//input[@name="`+cfg.GoAuth.PasswordField+`"]`, cfg.Target.Password+kb.Enter, chromedp.BySearch), 79 | ); err != nil { 80 | panic(err) 81 | } 82 | } else { 83 | if err := chromedp.Run(taskCtx, 84 | chromedp.WaitVisible(`//input[@name="`+cfg.GoAuth.UsernameField+`"]`, chromedp.BySearch), 85 | chromedp.SendKeys(`//input[@name="`+cfg.GoAuth.UsernameField+`"]`, cfg.Target.Username, chromedp.BySearch), 86 | chromedp.SendKeys(`//input[@name="`+cfg.GoAuth.PasswordField+`"]`, cfg.Target.Password+kb.Enter, chromedp.BySearch), 87 | ); err != nil { 88 | panic(err) 89 | } 90 | } 91 | // blocking wait 92 | for { 93 | messageFromChrome := <-messages 94 | if err := chromedp.Run(taskCtx, 95 | chromedp.Navigate(generatedURL), 96 | ); err != nil { 97 | panic(err) 98 | } 99 | log.Println("Chromium output:", messageFromChrome) 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /pkg/kiosk/grafana_idtoken_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "fmt" 8 | "log" 9 | "os" 10 | 11 | "github.com/chromedp/cdproto/cdp" 12 | "github.com/chromedp/cdproto/fetch" 13 | 14 | "github.com/chromedp/chromedp" 15 | 16 | "google.golang.org/api/idtoken" 17 | ) 18 | 19 | // GrafanaKioskIDToken creates a chrome-based kiosk using a oauth2 authenticated account. 20 | func GrafanaKioskIDToken(cfg *Config, messages chan string) { 21 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 22 | if err != nil { 23 | panic(err) 24 | } 25 | 26 | defer os.RemoveAll(dir) 27 | 28 | opts := generateExecutorOptions(dir, cfg) 29 | 30 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 31 | defer cancel() 32 | 33 | // also set up a custom logger 34 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 35 | defer cancel() 36 | 37 | listenChromeEvents(taskCtx, cfg, targetCrashed) 38 | 39 | // ensure that the browser process is started 40 | if err := chromedp.Run(taskCtx); err != nil { 41 | panic(err) 42 | } 43 | 44 | // Give browser time to load 45 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 46 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 47 | 48 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 49 | 50 | log.Println("Navigating to ", generatedURL) 51 | 52 | log.Printf("Token is using audience %s and reading from %s", cfg.IDToken.Audience, cfg.IDToken.KeyFile) 53 | tokenSource, err := idtoken.NewTokenSource(context.Background(), cfg.IDToken.Audience, idtoken.WithCredentialsFile(cfg.IDToken.KeyFile)) 54 | 55 | if err != nil { 56 | panic(err) 57 | } 58 | 59 | chromedp.ListenTarget(taskCtx, func(ev interface{}) { 60 | //nolint:gocritic // future events can be handled here 61 | switch ev := ev.(type) { 62 | case *fetch.EventRequestPaused: 63 | go func() { 64 | fetchReq := fetch.ContinueRequest(ev.RequestID) 65 | for k, v := range ev.Request.Headers { 66 | fetchReq.Headers = append(fetchReq.Headers, &fetch.HeaderEntry{Name: k, Value: fmt.Sprintf("%v", v)}) 67 | } 68 | token, err := tokenSource.Token() 69 | if err != nil { 70 | panic(fmt.Errorf("idtoken.NewClient: %w", err)) 71 | } 72 | fetchReq.Headers = append(fetchReq.Headers, &fetch.HeaderEntry{Name: "Authorization", Value: "Bearer " + token.AccessToken}) 73 | err = fetchReq.Do(GetExecutor(taskCtx)) 74 | if err != nil { 75 | panic(fmt.Errorf("idtoken.NewClient fetchReq error: %w", err)) 76 | } 77 | }() 78 | } 79 | }) 80 | 81 | if err := chromedp.Run(taskCtx, enableFetch(generatedURL)); err != nil { 82 | panic(err) 83 | } 84 | 85 | // blocking wait 86 | for { 87 | messageFromChrome := <-messages 88 | if err := chromedp.Run(taskCtx, 89 | chromedp.Navigate(generatedURL), 90 | ); err != nil { 91 | panic(err) 92 | } 93 | log.Println("Chromium output:", messageFromChrome) 94 | } 95 | } 96 | 97 | // GetExecutor returns executor for chromedp 98 | func GetExecutor(ctx context.Context) context.Context { 99 | c := chromedp.FromContext(ctx) 100 | 101 | return cdp.WithExecutor(ctx, c.Target) 102 | } 103 | 104 | func enableFetch(url string) chromedp.Tasks { 105 | return chromedp.Tasks{ 106 | fetch.Enable(), 107 | chromedp.Navigate(url), 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /pkg/kiosk/listen_chrome_events.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "strings" 7 | 8 | "github.com/chromedp/cdproto/inspector" 9 | "github.com/chromedp/cdproto/runtime" 10 | "github.com/chromedp/chromedp" 11 | ) 12 | 13 | type chromeEvents int 14 | 15 | const ( 16 | consoleAPICall chromeEvents = 1 << iota 17 | targetCrashed 18 | ) 19 | 20 | func listenChromeEvents(taskCtx context.Context, cfg *Config, events chromeEvents) { 21 | chromedp.ListenTarget(taskCtx, func(ev interface{}) { 22 | switch ev := ev.(type) { 23 | case *runtime.EventConsoleAPICalled: 24 | if events&consoleAPICall != 0 { 25 | log.Printf("console.%s call:", ev.Type) 26 | for _, arg := range ev.Args { 27 | log.Printf(" %s - %s", arg.Type, arg.Value) 28 | if strings.Contains(string(arg.Value), "not correct url correcting") { 29 | log.Printf("playlist may be broken, restart!") 30 | } 31 | } 32 | } 33 | if ev.StackTrace != nil { 34 | log.Printf("console.%s stacktrace:", ev.Type) 35 | for _, arg := range ev.StackTrace.CallFrames { 36 | log.Printf("(%s:%d): %s", arg.URL, arg.LineNumber, arg.FunctionName) 37 | } 38 | } 39 | case *inspector.EventTargetCrashed: 40 | if events&targetCrashed != 0 { 41 | log.Printf("target crashed, reload...") 42 | go func() { 43 | _ = chromedp.Run(taskCtx, chromedp.Reload()) 44 | }() 45 | } 46 | default: 47 | if cfg.General.DebugEnabled { 48 | log.Printf("Unknown Event: %+v", ev) 49 | } 50 | } 51 | }) 52 | } 53 | -------------------------------------------------------------------------------- /pkg/kiosk/local_login.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "context" 5 | "log" 6 | "os" 7 | "strings" 8 | "time" 9 | 10 | "github.com/chromedp/chromedp" 11 | "github.com/chromedp/chromedp/kb" 12 | ) 13 | 14 | // GrafanaKioskLocal creates a chrome-based kiosk using a local grafana-server account. 15 | func GrafanaKioskLocal(cfg *Config, messages chan string) { 16 | dir, err := os.MkdirTemp(os.TempDir(), "chromedp-kiosk") 17 | if err != nil { 18 | panic(err) 19 | } 20 | 21 | log.Println("Using temp dir:", dir) 22 | defer os.RemoveAll(dir) 23 | 24 | opts := generateExecutorOptions(dir, cfg) 25 | 26 | allocCtx, cancel := chromedp.NewExecAllocator(context.Background(), opts...) 27 | defer cancel() 28 | 29 | // also set up a custom logger 30 | taskCtx, cancel := chromedp.NewContext(allocCtx, chromedp.WithLogf(log.Printf)) 31 | defer cancel() 32 | 33 | listenChromeEvents(taskCtx, cfg, targetCrashed) 34 | 35 | // ensure that the browser process is started 36 | if err := chromedp.Run(taskCtx); err != nil { 37 | panic(err) 38 | } 39 | 40 | var generatedURL = GenerateURL(cfg.Target.URL, cfg.General.Mode, cfg.General.AutoFit, cfg.Target.IsPlayList) 41 | 42 | log.Println("Navigating to ", generatedURL) 43 | /* 44 | Launch chrome and login with local user account 45 | 46 | name=user, type=text 47 | id=inputPassword, type=password, name=password 48 | */ 49 | // Give browser time to load 50 | log.Printf("Sleeping %d MS before navigating to url", cfg.General.PageLoadDelayMS) 51 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 52 | 53 | if cfg.GoAuth.AutoLogin { 54 | // if AutoLogin is set, get the base URL and append the local login bypass before navigating to the full url 55 | startIndex := strings.Index(cfg.Target.URL, "://") + 3 56 | endIndex := strings.Index(cfg.Target.URL[startIndex:], "/") + startIndex 57 | baseURL := cfg.Target.URL[:endIndex] 58 | bypassURL := baseURL + "/login/local" 59 | 60 | log.Println("Bypassing autoLogin using URL ", bypassURL) 61 | 62 | if err := chromedp.Run(taskCtx, 63 | chromedp.Navigate(bypassURL), 64 | chromedp.ActionFunc(func(context.Context) error { 65 | log.Printf("Sleeping %d MS before checking for login fields", cfg.General.PageLoadDelayMS) 66 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 67 | return nil 68 | }), 69 | chromedp.WaitVisible(`//input[@name="user"]`, chromedp.BySearch), 70 | chromedp.SendKeys(`//input[@name="user"]`, cfg.Target.Username, chromedp.BySearch), 71 | chromedp.SendKeys(`//input[@name="password"]`, cfg.Target.Password+kb.Enter, chromedp.BySearch), 72 | chromedp.ActionFunc(func(context.Context) error { 73 | log.Printf("Sleeping %d MS before checking for topnav", cfg.General.PageLoadDelayMS) 74 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 75 | return nil 76 | }), 77 | chromedp.WaitVisible(`//img[@alt="User avatar"]`, chromedp.BySearch), 78 | chromedp.ActionFunc(func(context.Context) error { 79 | log.Printf("Sleeping %d MS before navigating to final url", cfg.General.PageLoadDelayMS) 80 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 81 | return nil 82 | }), 83 | chromedp.Navigate(generatedURL), 84 | ); err != nil { 85 | panic(err) 86 | } 87 | } else { 88 | if err := chromedp.Run(taskCtx, 89 | chromedp.ActionFunc(func(context.Context) error { 90 | log.Printf("Sleeping %d MS before navigating to final url", cfg.General.PageLoadDelayMS) 91 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 92 | return nil 93 | }), 94 | chromedp.Navigate(generatedURL), 95 | chromedp.ActionFunc(func(context.Context) error { 96 | log.Printf("Sleeping %d MS before checking for login fields", cfg.General.PageLoadDelayMS) 97 | time.Sleep(time.Duration(cfg.General.PageLoadDelayMS) * time.Millisecond) 98 | return nil 99 | }), 100 | chromedp.WaitVisible(`//input[@name="user"]`, chromedp.BySearch), 101 | chromedp.SendKeys(`//input[@name="user"]`, cfg.Target.Username, chromedp.BySearch), 102 | chromedp.SendKeys(`//input[@name="password"]`, cfg.Target.Password+kb.Enter, chromedp.BySearch), 103 | ); err != nil { 104 | panic(err) 105 | } 106 | } 107 | 108 | // blocking wait 109 | for { 110 | messageFromChrome := <-messages 111 | if err := chromedp.Run(taskCtx, 112 | chromedp.Navigate(generatedURL), 113 | ); err != nil { 114 | panic(err) 115 | } 116 | log.Println("Chromium output:", messageFromChrome) 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /pkg/kiosk/utils.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/url" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/chromedp/chromedp" 11 | ) 12 | 13 | // GenerateURL constructs URL with appropriate parameters for kiosk mode. 14 | func GenerateURL(anURL string, kioskMode string, autoFit bool, isPlayList bool) string { 15 | parsedURI, _ := url.ParseRequestURI(anURL) 16 | parsedQuery, _ := url.ParseQuery(parsedURI.RawQuery) 17 | 18 | switch kioskMode { 19 | case "tv": // TV 20 | parsedQuery.Set("kiosk", "tv") // no sidebar, topnav without buttons 21 | log.Printf("KioskMode: TV") 22 | case "full": // FULLSCREEN 23 | parsedQuery.Set("kiosk", "1") // sidebar and topnav always shown 24 | log.Printf("KioskMode: Fullscreen") 25 | case "disabled": // FULLSCREEN 26 | log.Printf("KioskMode: Disabled") 27 | default: // disabled 28 | parsedQuery.Set("kiosk", "1") // sidebar and topnav always shown 29 | log.Printf("KioskMode: Fullscreen") 30 | } 31 | // a playlist should also go inactive immediately 32 | if isPlayList { 33 | parsedQuery.Set("inactive", "1") 34 | } 35 | parsedURI.RawQuery = parsedQuery.Encode() 36 | // grafana is not parsing autofitpanels that uses an equals sign, so leave it out 37 | if autoFit { 38 | if len(parsedQuery) > 0 { 39 | parsedURI.RawQuery += "&autofitpanels" 40 | } else { 41 | parsedURI.RawQuery += "autofitpanels" 42 | } 43 | } 44 | 45 | return parsedURI.String() 46 | } 47 | 48 | func generateExecutorOptions(dir string, cfg *Config) []chromedp.ExecAllocatorOption { 49 | // agent should not have the v prefix 50 | buildVersion := strings.TrimPrefix(cfg.BuildInfo.Version, "v") 51 | kioskVersion := fmt.Sprintf("GrafanaKiosk/%s (%s %s)", buildVersion, runtime.GOOS, runtime.GOARCH) 52 | userAgent := fmt.Sprintf("Mozilla/5.0 (X11; CrOS armv7l 13597.84.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.36 %s", kioskVersion) 53 | 54 | // See https://peter.sh/experiments/chromium-command-line-switches/ 55 | 56 | // --start-fullscreen 57 | // Specifies if the browser should start in fullscreen mode, like if the user had pressed F11 right after startup. ↪ 58 | // --start-maximized 59 | // Starts the browser maximized, regardless of any previous settings 60 | // --disable-gpu 61 | // Disables GPU hardware acceleration. If software renderer is not in place, then the GPU process won't launch.--disable-gpu 62 | 63 | execAllocatorOption := []chromedp.ExecAllocatorOption{ 64 | chromedp.NoFirstRun, 65 | chromedp.NoDefaultBrowserCheck, 66 | chromedp.Flag("autoplay-policy", "no-user-gesture-required"), 67 | chromedp.Flag("bwsi", true), 68 | chromedp.Flag("check-for-update-interval", "31536000"), 69 | chromedp.Flag("disable-features", "Translate"), 70 | chromedp.Flag("disable-notifications", true), 71 | chromedp.Flag("disable-overlay-scrollbar", true), 72 | chromedp.Flag("hide-scrollbars", true), 73 | chromedp.Flag("disable-search-engine-choice-screen", true), 74 | chromedp.Flag("disable-sync", true), 75 | chromedp.Flag("ignore-certificate-errors", cfg.Target.IgnoreCertificateErrors), 76 | chromedp.Flag("incognito", true), 77 | chromedp.Flag("kiosk", true), 78 | chromedp.Flag("noerrdialogs", true), 79 | chromedp.Flag("start-fullscreen", true), 80 | chromedp.Flag("start-maximized", true), 81 | chromedp.Flag("user-agent", userAgent), 82 | chromedp.Flag("window-position", cfg.General.WindowPosition), 83 | chromedp.UserDataDir(dir), 84 | } 85 | 86 | if !cfg.General.GPUEnabled { 87 | execAllocatorOption = append( 88 | execAllocatorOption, 89 | chromedp.Flag("disable-gpu", cfg.General.GPUEnabled)) 90 | } 91 | if cfg.General.OzonePlatform != "" { 92 | execAllocatorOption = append( 93 | execAllocatorOption, 94 | chromedp.Flag("ozone-platform", cfg.General.OzonePlatform)) 95 | } 96 | if cfg.General.WindowSize != "" { 97 | execAllocatorOption = append( 98 | execAllocatorOption, 99 | chromedp.Flag("window-size", cfg.General.WindowSize)) 100 | } 101 | if cfg.General.ScaleFactor != "" { 102 | execAllocatorOption = append( 103 | execAllocatorOption, 104 | chromedp.Flag("force-device-scale-factor", cfg.General.ScaleFactor)) 105 | } 106 | 107 | return execAllocatorOption 108 | } 109 | -------------------------------------------------------------------------------- /pkg/kiosk/utils_test.go: -------------------------------------------------------------------------------- 1 | package kiosk 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/smartystreets/goconvey/convey" 7 | ) 8 | 9 | // TestGenerateURL 10 | func TestGenerateURL(t *testing.T) { 11 | Convey("Given URL params", t, func() { 12 | Convey("Fullscreen Anonymous Login", func() { 13 | anURL := GenerateURL("https://play.grafana/com", "full", true, false) 14 | So(anURL, ShouldEqual, "https://play.grafana/com?kiosk=1&autofitpanels") 15 | }) 16 | Convey("TV Mode Anonymous Login", func() { 17 | anURL := GenerateURL("https://play.grafana/com", "tv", true, false) 18 | So(anURL, ShouldEqual, "https://play.grafana/com?kiosk=tv&autofitpanels") 19 | }) 20 | Convey("Not Fullscreen Anonymous Login", func() { 21 | anURL := GenerateURL("https://play.grafana/com", "disabled", true, false) 22 | So(anURL, ShouldEqual, "https://play.grafana/com?autofitpanels") 23 | }) 24 | Convey("Default Kiosk Anonymous Login", func() { 25 | anURL := GenerateURL("https://play.grafana/com", "", false, false) 26 | So(anURL, ShouldEqual, "https://play.grafana/com?kiosk=1") 27 | }) 28 | Convey("Default Anonymous Login with autofit", func() { 29 | anURL := GenerateURL("https://play.grafana/com", "disabled", true, false) 30 | So(anURL, ShouldEqual, "https://play.grafana/com?autofitpanels") 31 | }) 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /scripts/grafana-kiosk.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Grafana Kiosk 3 | Documentation=https://github.com/grafana/grafana-kiosk 4 | Documentation=https://grafana.com/blog/2019/05/02/grafana-tutorial-how-to-create-kiosks-to-display-dashboards-on-a-tv 5 | After=network.target 6 | 7 | [Service] 8 | User=pi 9 | Environment="DISPLAY=:0" 10 | Environment="XAUTHORITY=/home/pi/.Xauthority" 11 | ExecStart=/usr/bin/grafana-kiosk --URL -login-method local -username -password -playlist true 12 | 13 | [Install] 14 | WantedBy=graphical.target 15 | -------------------------------------------------------------------------------- /testdata/config-anon.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | general: 3 | kiosk-mode: full 4 | autofit: true 5 | lxde: true 6 | lxde-home: /home/pi 7 | 8 | target: 9 | login-method: anon 10 | username: user 11 | password: changeme 12 | playlist: false 13 | use-mfa: false 14 | URL: https://play.grafana.org 15 | ignore-certificate-errors: false 16 | 17 | goauth: 18 | auto-login: false 19 | fieldname-username: username 20 | fieldname-password: password 21 | -------------------------------------------------------------------------------- /testdata/config-aws.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | general: 3 | kiosk-mode: full 4 | autofit: true 5 | lxde: true 6 | lxde-home: /home/pi 7 | 8 | target: 9 | login-method: aws 10 | username: bkgann@gmail.com 11 | password: changeme 12 | playlist: false 13 | URL: https://g-0469dbd3ca.grafana-workspace.us-east-1.amazonaws.com/d/tmsOtSxZk/amazon-ec2?orgId=1 14 | ignore-certificate-errors: false 15 | -------------------------------------------------------------------------------- /testdata/config-gcom.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | general: 3 | kiosk-mode: full 4 | autofit: true 5 | lxde: true 6 | lxde-home: /home/pi 7 | 8 | target: 9 | login-method: gcom 10 | username: bkgann@yahoo.com 11 | password: changeme 12 | playlist: false 13 | URL: https://bkgann4.grafana.net 14 | ignore-certificate-errors: false 15 | 16 | goauth: 17 | auto-login: false 18 | fieldname-username: username 19 | fieldname-password: password 20 | -------------------------------------------------------------------------------- /testdata/config-local.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | general: 3 | kiosk-mode: full 4 | autofit: true 5 | chromium-disable-update: true 6 | lxde: true 7 | lxde-home: /home/pi 8 | 9 | target: 10 | login-method: local 11 | username: user1 12 | password: changeme 13 | playlist: false 14 | URL: https://monitor.plan3systems.com/grafana/d/BfHbSM0Mk/mining-p1?orgId=1&refresh=1m 15 | ignore-certificate-errors: false 16 | --------------------------------------------------------------------------------