├── .bingo ├── .gitignore ├── README.md ├── Variables.mk ├── bingo.mod ├── bingo.sum ├── go.mod ├── golangci-lint.mod ├── golangci-lint.sum └── variables.env ├── .dockerignore ├── .editorconfig ├── .github └── workflows │ ├── package.yml │ └── pull-request.yml ├── .gitignore ├── .golangci.yaml ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── contrib ├── allow-filesystem.png ├── debian │ └── control ├── grafana-dashboard.json └── nextcloud-exporter.service ├── go.mod ├── go.sum ├── internal ├── client │ ├── client.go │ └── client_test.go ├── config │ ├── config.go │ ├── config_test.go │ └── testdata │ │ ├── all.yml │ │ ├── authtoken.yml │ │ ├── password │ │ └── passwordfile.yml ├── login │ ├── login.go │ └── login_test.go ├── metrics │ ├── collector.go │ └── info.go └── testutil │ ├── testutil.go │ └── testutil_test.go ├── main.go └── serverinfo ├── parse.go ├── parse_test.go ├── serverinfo.go ├── testdata ├── info.json ├── large-freespace.json ├── na-values.json ├── nc22.json └── negative-space.json ├── url.go └── url_test.go /.bingo/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Ignore everything 3 | * 4 | 5 | # But not these files: 6 | !.gitignore 7 | !*.mod 8 | !*.sum 9 | !README.md 10 | !Variables.mk 11 | !variables.env 12 | 13 | *tmp.mod 14 | -------------------------------------------------------------------------------- /.bingo/README.md: -------------------------------------------------------------------------------- 1 | # Project Development Dependencies. 2 | 3 | This is directory which stores Go modules with pinned buildable package that is used within this repository, managed by https://github.com/bwplotka/bingo. 4 | 5 | * Run `bingo get` to install all tools having each own module file in this directory. 6 | * Run `bingo get ` to install that have own module file in this directory. 7 | * For Makefile: Make sure to put `include .bingo/Variables.mk` in your Makefile, then use $() variable where is the .bingo/.mod. 8 | * For shell: Run `source .bingo/variables.env` to source all environment variable for each tool. 9 | * For go: Import `.bingo/variables.go` to for variable names. 10 | * See https://github.com/bwplotka/bingo or -h on how to add, remove or change binaries dependencies. 11 | 12 | ## Requirements 13 | 14 | * Go 1.14+ 15 | -------------------------------------------------------------------------------- /.bingo/Variables.mk: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. 2 | # All tools are designed to be build inside $GOBIN. 3 | BINGO_DIR := $(dir $(lastword $(MAKEFILE_LIST))) 4 | GOPATH ?= $(shell go env GOPATH) 5 | GOBIN ?= $(firstword $(subst :, ,${GOPATH}))/bin 6 | GO ?= $(shell which go) 7 | 8 | # Below generated variables ensure that every time a tool under each variable is invoked, the correct version 9 | # will be used; reinstalling only if needed. 10 | # For example for bingo variable: 11 | # 12 | # In your main Makefile (for non array binaries): 13 | # 14 | #include .bingo/Variables.mk # Assuming -dir was set to .bingo . 15 | # 16 | #command: $(BINGO) 17 | # @echo "Running bingo" 18 | # @$(BINGO) 19 | # 20 | BINGO := $(GOBIN)/bingo-v0.9.0 21 | $(BINGO): $(BINGO_DIR)/bingo.mod 22 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 23 | @echo "(re)installing $(GOBIN)/bingo-v0.9.0" 24 | @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=bingo.mod -o=$(GOBIN)/bingo-v0.9.0 "github.com/bwplotka/bingo" 25 | 26 | GOLANGCI_LINT := $(GOBIN)/golangci-lint-v1.62.2 27 | $(GOLANGCI_LINT): $(BINGO_DIR)/golangci-lint.mod 28 | @# Install binary/ries using Go 1.14+ build command. This is using bwplotka/bingo-controlled, separate go module with pinned dependencies. 29 | @echo "(re)installing $(GOBIN)/golangci-lint-v1.62.2" 30 | @cd $(BINGO_DIR) && GOWORK=off $(GO) build -mod=mod -modfile=golangci-lint.mod -o=$(GOBIN)/golangci-lint-v1.62.2 "github.com/golangci/golangci-lint/cmd/golangci-lint" 31 | 32 | -------------------------------------------------------------------------------- /.bingo/bingo.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.20 4 | 5 | require github.com/bwplotka/bingo v0.9.0 6 | -------------------------------------------------------------------------------- /.bingo/bingo.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= 2 | github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= 3 | github.com/bwplotka/bingo v0.8.0 h1:Cx9eQb+ed9aU7sbrmZagomKx+wYor9y5z5HM91bvp1U= 4 | github.com/bwplotka/bingo v0.8.0/go.mod h1:eXPFwhZ92mmOUBk6F7aKcAJoq8HX88Ju3wLZKwtNKEw= 5 | github.com/bwplotka/bingo v0.9.0 h1:slnsdJYExR4iRalHR6/ZiYnr9vSazOuFGmc2LdX293g= 6 | github.com/bwplotka/bingo v0.9.0/go.mod h1:GxC/y/xbmOK5P29cn+B3HuOSw0s2gruddT3r+rDizDw= 7 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 8 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 9 | github.com/creack/pty v1.1.15/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= 10 | github.com/efficientgo/core v1.0.0-rc.0 h1:jJoA0N+C4/knWYVZ6GrdHOtDyrg8Y/TR4vFpTaqTsqs= 11 | github.com/efficientgo/core v1.0.0-rc.0/go.mod h1:kQa0V74HNYMfuJH6jiPiwNdpWXl4xd/K4tzlrcvYDQI= 12 | github.com/frankban/quicktest v1.13.1/go.mod h1:NeW+ay9A/U67EYXNFA1nPE8e/tnQv/09mUdL/ijj8og= 13 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 14 | github.com/google/renameio v1.0.1/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk= 15 | github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= 16 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 17 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 18 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 19 | github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 20 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 21 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 22 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 23 | github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= 24 | github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU= 25 | github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= 26 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 27 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 28 | github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 29 | github.com/rogpeppe/go-internal v1.8.1-0.20210923151022-86f73c517451/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= 30 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 31 | github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU= 32 | github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM= 33 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 34 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 35 | golang.org/x/mod v0.5.1 h1:OJxoQ/rynoF0dcCdI7cLPktw/hR2cueqYfjm43oqK38= 36 | golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= 37 | golang.org/x/mod v0.12.0 h1:rmsUpXtvNzj340zd98LZ4KntptpfRHwpFOHG188oHXc= 38 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 39 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 40 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 41 | golang.org/x/sync v0.2.0 h1:PUR+T4wwASmuSTYdKjYHI5TD22Wy5ogLU5qZCOLxBrI= 42 | golang.org/x/sync v0.2.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 43 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 44 | golang.org/x/sys v0.0.0-20210925032602-92d5a993a665/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 45 | golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f h1:rlezHXNlxYWvBCzNses9Dlc7nGFaNMJeqLolcmQSSZY= 46 | golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 47 | golang.org/x/sys v0.8.0 h1:EBmGv8NaZBZTWvrbjNoL6HVt+IVy3QDQpJs7VRIw3tU= 48 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/term v0.0.0-20210916214954-140adaaadfaf/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 50 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY= 51 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 52 | golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= 53 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 54 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 55 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 56 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 59 | gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 60 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 61 | mvdan.cc/editorconfig v0.2.0/go.mod h1:lvnnD3BNdBYkhq+B4uBuFFKatfp02eB6HixDvEz91C0= 62 | mvdan.cc/sh/v3 v3.4.3 h1:zbuKH7YH9cqU6PGajhFFXZY7dhPXcDr55iN/cUAqpuw= 63 | mvdan.cc/sh/v3 v3.4.3/go.mod h1:p/tqPPI4Epfk2rICAe2RoaNd8HBSJ8t9Y2DA9yQlbzY= 64 | mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg= 65 | mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8= 66 | -------------------------------------------------------------------------------- /.bingo/go.mod: -------------------------------------------------------------------------------- 1 | module _ // Fake go.mod auto-created by 'bingo' for go -moddir compatibility with non-Go projects. Commit this file, together with other .mod files. -------------------------------------------------------------------------------- /.bingo/golangci-lint.mod: -------------------------------------------------------------------------------- 1 | module _ // Auto generated by https://github.com/bwplotka/bingo. DO NOT EDIT 2 | 3 | go 1.22.1 4 | 5 | toolchain go1.23.4 6 | 7 | require github.com/golangci/golangci-lint v1.62.2 // cmd/golangci-lint 8 | -------------------------------------------------------------------------------- /.bingo/variables.env: -------------------------------------------------------------------------------- 1 | # Auto generated binary variables helper managed by https://github.com/bwplotka/bingo v0.9. DO NOT EDIT. 2 | # All tools are designed to be build inside $GOBIN. 3 | # Those variables will work only until 'bingo get' was invoked, or if tools were installed via Makefile's Variables.mk. 4 | GOBIN=${GOBIN:=$(go env GOBIN)} 5 | 6 | if [ -z "$GOBIN" ]; then 7 | GOBIN="$(go env GOPATH)/bin" 8 | fi 9 | 10 | 11 | BINGO="${GOBIN}/bingo-v0.9.0" 12 | 13 | GOLANGCI_LINT="${GOBIN}/golangci-lint-v1.62.2" 14 | 15 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.go] 15 | indent_style = tab 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | name: Package 2 | on: 3 | push: 4 | branches: 5 | - master 6 | release: 7 | types: 8 | - published 9 | pull_request: 10 | branches: 11 | - master 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - name: Setup Go 21 | uses: actions/setup-go@v5 22 | with: 23 | go-version-file: go.mod 24 | - name: Build and Test 25 | run: make 26 | docker: 27 | needs: test 28 | runs-on: ubuntu-latest 29 | steps: 30 | - name: Checkout 31 | uses: actions/checkout@v4 32 | with: 33 | fetch-depth: 0 34 | - name: Set up QEMU 35 | uses: docker/setup-qemu-action@v3 36 | - name: Set up Docker Buildx 37 | uses: docker/setup-buildx-action@v3 38 | - name: Login to GitHub Container Registry 39 | uses: docker/login-action@v3 40 | if: ${{ github.event_name != 'pull_request' }} 41 | with: 42 | registry: ghcr.io 43 | username: ${{ github.actor }} 44 | password: ${{ secrets.GITHUB_TOKEN }} 45 | - name: Login to Docker hub 46 | uses: docker/login-action@v3 47 | if: ${{ github.event_name != 'pull_request' }} 48 | with: 49 | username: xperimental 50 | password: ${{ secrets.DOCKER_TOKEN }} 51 | - name: Docker Metadata 52 | id: meta 53 | uses: docker/metadata-action@v5 54 | with: 55 | images: | 56 | ghcr.io/xperimental/nextcloud-exporter 57 | xperimental/nextcloud-exporter 58 | tags: | 59 | type=semver,pattern={{version}} 60 | type=ref,event=branch 61 | type=ref,event=pr 62 | - name: Build and push Docker images 63 | uses: docker/build-push-action@v5 64 | with: 65 | context: . 66 | push: ${{ github.event_name != 'pull_request' }} 67 | tags: ${{ steps.meta.outputs.tags }} 68 | labels: ${{ steps.meta.outputs.labels }} 69 | platforms: linux/amd64,linux/arm64 70 | -------------------------------------------------------------------------------- /.github/workflows/pull-request.yml: -------------------------------------------------------------------------------- 1 | name: Pull-request 2 | on: 3 | pull_request: 4 | branches: 5 | - master 6 | jobs: 7 | lint: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: actions/setup-go@v5 12 | with: 13 | go-version-file: go.mod 14 | - name: golangci-lint 15 | uses: golangci/golangci-lint-action@v4 16 | with: 17 | version: v1.62.2 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | run.sh 2 | nextcloud-exporter 3 | nextcloud-exporter.yml 4 | config.yml 5 | dist/ 6 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - gofumpt 4 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | 10 | ## [0.8.0] - 2024-12-22 11 | 12 | ### Added 13 | 14 | - New metric showing available Nextcloud update (`nextcloud_system_update_available`) 15 | 16 | ### Changed 17 | 18 | - Updated Go runtime and dependencies 19 | 20 | ## [0.7.0] - 2024-03-21 21 | 22 | ### Added 23 | 24 | - Example Grafana Dashboard now included in the repository 25 | 26 | ### Changed 27 | 28 | - App-related metrics (installed, available updates) are opt-in now, mirroring the change in Nextcloud 28 29 | - Updates Go and dependencies 30 | 31 | ### Fixed 32 | 33 | - Error parsing "free space" reported by Nextcloud when number is very large 34 | 35 | ## [0.6.2] - 2023-10-15 36 | 37 | ### Changed 38 | 39 | - Maintenance release, updates Go and dependencies 40 | 41 | ## [0.6.1] - 2023-05-29 42 | 43 | ### Changed 44 | 45 | - `latest` Docker tag now points to most recent release and `master` points to the build from the default branch 46 | 47 | ## [0.6.0] - 2022-10-19 48 | 49 | ### Added 50 | 51 | - New database info metric (`nextcloud_database_info`) containing type and version 52 | - New metrics for hourly and daily active users 53 | - Additional label on `nextcloud_scrape_errors_total` for errors due to rate-limiting 54 | - Additional labels for email and room shares on `nextcloud_shares_total` 55 | 56 | ## [0.5.1] - 2022-04-02 57 | 58 | ### Fixed 59 | 60 | - Updated Prometheus client library for CVE-2022-21698 61 | 62 | ## [0.5.0] - 2022-01-15 63 | 64 | ### Added 65 | 66 | - Flag for showing version information 67 | - Option to disable TLS validation 68 | - Token authentication for Nextcloud 22 and newer 69 | 70 | ### Changed 71 | 72 | - Switched to JSON from XML for getting information from server 73 | - Use different metric for authentication errors 74 | 75 | ## [0.4.0] - 2021-01-21 76 | 77 | ### Added 78 | 79 | - Metrics for installed apps and available updates 80 | 81 | ## [0.3.0] - 2020-06-01 82 | 83 | ### Added 84 | 85 | - Makefile target for building deb 86 | - Login flow for app password 87 | 88 | ### Changed 89 | 90 | - Simpler configuration of server URL 91 | 92 | ### Fixed 93 | 94 | - Error in version information 95 | 96 | ## [0.2.0] - 2020-05-20 97 | 98 | ### Added 99 | 100 | - Version information in binary 101 | - Custom User-Agent header 102 | - systemd service unit 103 | 104 | ### Changed 105 | 106 | - No timestamp in log output 107 | 108 | ## [0.1.0] - 2019-10-12 109 | 110 | - Initial release 111 | 112 | [0.8.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.8.0 113 | [0.7.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.7.0 114 | [0.6.2]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.6.2 115 | [0.6.1]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.6.1 116 | [0.6.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.6.0 117 | [0.5.1]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.5.1 118 | [0.5.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.5.0 119 | [0.4.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.4.0 120 | [0.3.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.3.0 121 | [0.2.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.2.0 122 | [0.1.0]: https://github.com/xperimental/nextcloud-exporter/releases/tag/v0.1.0 123 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM --platform=$BUILDPLATFORM golang:1.23.4-alpine AS builder 2 | 3 | ARG TARGETOS 4 | ARG TARGETARCH 5 | 6 | ENV GOOS=$TARGETOS 7 | ENV GOARCH=$TARGETARCH 8 | 9 | RUN apk add --no-cache make git bash 10 | 11 | WORKDIR /build 12 | 13 | COPY go.mod go.sum /build/ 14 | RUN go mod download 15 | RUN go mod verify 16 | 17 | COPY . /build/ 18 | RUN make build-binary 19 | 20 | FROM --platform=$TARGETPLATFORM busybox 21 | LABEL maintainer="Robert Jacob " 22 | 23 | COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt 24 | COPY --from=builder /build/nextcloud-exporter /bin/nextcloud-exporter 25 | 26 | USER nobody 27 | EXPOSE 9205 28 | 29 | ENTRYPOINT ["/bin/nextcloud-exporter"] 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Robert Jacob 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SHELL := /bin/bash 2 | GO ?= go 3 | GO_CMD := CGO_ENABLED=0 $(GO) 4 | GIT_VERSION := $(shell git describe --tags --dirty) 5 | VERSION := $(GIT_VERSION:v%=%) 6 | GIT_COMMIT := $(shell git rev-parse HEAD) 7 | DOCKER_REPO ?= xperimental/nextcloud-exporter 8 | DOCKER_TAG ?= dev 9 | 10 | .PHONY: all 11 | all: test build-binary 12 | 13 | include .bingo/Variables.mk 14 | 15 | .PHONY: test 16 | test: 17 | $(GO_CMD) test -cover ./... 18 | 19 | .PHONY: lint 20 | lint: $(GOLANGCI_LINT) 21 | $(GOLANGCI_LINT) run 22 | 23 | .PHONY: lint-fix 24 | lint-fix: $(GOLANGCI_LINT) 25 | $(GOLANGCI_LINT) run --fix 26 | 27 | .PHONY: build-binary 28 | build-binary: 29 | $(GO_CMD) build -tags netgo -ldflags "-w -X main.Version=$(VERSION) -X main.GitCommit=$(GIT_COMMIT)" -o nextcloud-exporter . 30 | 31 | .PHONY: deb 32 | deb: build-binary 33 | mkdir -p dist/deb/DEBIAN dist/deb/usr/bin 34 | sed 's/%VERSION%/$(VERSION)/' contrib/debian/control > dist/deb/DEBIAN/control 35 | cp nextcloud-exporter dist/deb/usr/bin/ 36 | fakeroot dpkg-deb --build dist/deb dist 37 | 38 | .PHONY: install 39 | install: 40 | install -D -t $(DESTDIR)/usr/bin/ nextcloud-exporter 41 | install -D -m 0644 -t $(DESTDIR)/lib/systemd/system/ contrib/nextcloud-exporter.service 42 | 43 | .PHONY: image 44 | image: 45 | docker buildx build -t "ghcr.io/$(DOCKER_REPO):$(DOCKER_TAG)" --load . 46 | 47 | .PHONY: all-images 48 | all-images: 49 | docker buildx build -t "ghcr.io/$(DOCKER_REPO):$(DOCKER_TAG)" -t "docker.io/$(DOCKER_REPO):$(DOCKER_TAG)" --platform linux/amd64,linux/arm64 --push . 50 | 51 | .PHONY: tools 52 | tools: $(BINGO) $(GOLANGCI_LINT) 53 | @echo Tools built. 54 | 55 | .PHONY: clean 56 | clean: 57 | rm -f nextcloud-exporter 58 | rm -r dist 59 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # nextcloud-exporter 2 | 3 | A [prometheus](https://prometheus.io) exporter for getting some metrics of a [Nextcloud](https://nextcloud.com/) server instance. 4 | 5 | ## Installation 6 | 7 | ### Docker Image 8 | 9 | The preferred way to use `nextcloud-exporter` is by running the provided Docker image. It is currently provided on Docker Hub and GitHub Container Registry: 10 | 11 | - [`ghcr.io/xperimental/nextcloud-exporter`](https://github.com/xperimental/nextcloud-exporter/pkgs/container/nextcloud-exporter) 12 | - [`xperimental/nextcloud-exporter`](https://hub.docker.com/r/xperimental/nextcloud-exporter/) 13 | 14 | The following tags are available: 15 | 16 | - `x.y.z` pointing to the release with that version 17 | - `latest` pointing to the most recent released version 18 | - `master` pointing to the latest build from the default branch 19 | 20 | ### Pre-built binaries 21 | 22 | The [releases](https://github.com/xperimental/nextcloud-exporter/releases) page contains pre-built binaries for AMD64 and ARM64 linux. 23 | 24 | ### Build from Source 25 | 26 | If you have a recent Go installation (see `go.mod` for the minimum version), Git and GNU Make, the following commands will check out the repository and compile the binary from source: 27 | 28 | ```bash 29 | git clone https://github.com/xperimental/nextcloud-exporter.git 30 | cd nextcloud-exporter 31 | make 32 | ``` 33 | 34 | After this there should be a `nextcloud-exporter` binary in your current directory. 35 | 36 | ## Client credentials 37 | 38 | The exporter supports two different approaches for authenticating with the Nextcloud server: 39 | 40 | - Token authentication (needs Nextcloud 22 or newer) 41 | - Username and password 42 | 43 | If you have Nextcloud 22 then using the token authentication is recommended, because it does not need a normal user account with admin privileges. 44 | 45 | If both a token and username/password are specified in the configuration, the token will take precedence. 46 | 47 | ### Token authentication 48 | 49 | Nextcloud 22 and newer versions support "token authentication" for the serverinfo. That way, accessing this information does not need a normal user account with admin privileges. You can set the token to anything you like, but the recommendation is to set it to a long random number: 50 | 51 | ```bash 52 | # Generate random value (for example using openssl) 53 | TOKEN=$(openssl rand -hex 32) 54 | # Set token (using the occ console application) 55 | occ config:app:set serverinfo token --value "$TOKEN" 56 | ``` 57 | 58 | You can then use this generated token in the exported configuration instead of username and password. 59 | 60 | ### Username and password authentication 61 | 62 | To access the serverinfo API you will need the credentials of an admin user. It is recommended to create a separate user for that purpose. It's also possible for the exporter to generate an "app password", so that the real user password is never saved to the configuration. This also makes the exporter show up in the security panel of the user as a connected application. 63 | 64 | To let the nextcloud-exporter create an app password, start it with the `--login` parameter: 65 | 66 | ```bash 67 | nextcloud-exporter --login --server https://nextcloud.example.com 68 | ``` 69 | 70 | The exporter will generate a login URL that you need to open in your browser. Be sure to login with the correct user if you created a special user for the exporter as the app password will be bound to the logged-in user. Once the access has been granted using the browser the exporter will output the username and password that need to be entered into the configuration. 71 | 72 | When the login process is done, it is possible to disable filesystem access for the generated token in the user's settings: 73 | 74 | ![Allow filesystem access checkbox](contrib/allow-filesystem.png) 75 | 76 | --- 77 | 78 | The interactive login can also be done using a Docker container: 79 | 80 | ```bash 81 | docker run --rm -it ghcr.io/xperimental/nextcloud-exporter --login --server https://nextcloud.example.com 82 | ``` 83 | 84 | The login flow needs at least Nextcloud 16 to work. 85 | 86 | ## Usage 87 | 88 | ```plain 89 | $ nextcloud-exporter --help 90 | Usage of nextcloud-exporter: 91 | -a, --addr string Address to listen on for connections. (default ":9205") 92 | --auth-token string Authentication token. Can replace username and password when using Nextcloud 22 or newer. 93 | -c, --config-file string Path to YAML configuration file. 94 | --enable-info-apps Enable gathering of apps-related metrics. 95 | --enable-info-update Enable gathering of system update-related metrics. 96 | --login Use interactive login to create app password. 97 | -p, --password string Password for connecting to Nextcloud. 98 | -s, --server string URL to Nextcloud server. 99 | -t, --timeout duration Timeout for getting server info document. (default 5s) 100 | --tls-skip-verify Skip certificate verification of Nextcloud server. 101 | -u, --username string Username for connecting to Nextcloud. 102 | -V, --version Show version information and exit. 103 | ``` 104 | 105 | After starting the server will offer the metrics on the `/metrics` endpoint, which can be used as a target for prometheus. 106 | 107 | ### Example Dashboard 108 | 109 | The repository contains an [example Grafana dashboard](contrib/grafana-dashboard.json) that can be imported into Grafana. The dashboard is also available as ID `20716` from the [Grafana Dashboard Exchange](https://grafana.com/grafana/dashboards/20716-nextcloud/). 110 | 111 | ### Configuration methods 112 | 113 | There are three methods of configuring the nextcloud-exporter (higher methods take precedence over lower ones): 114 | 115 | - Environment variables 116 | - Configuration file 117 | - Command-line parameters 118 | 119 | #### Environment variables 120 | 121 | All settings can also be specified through environment variables: 122 | 123 | | Environment variable | Flag equivalent | 124 | |----------------------------:|:---------------------| 125 | | `NEXTCLOUD_SERVER` | --server | 126 | | `NEXTCLOUD_USERNAME` | --username | 127 | | `NEXTCLOUD_PASSWORD` | --password | 128 | | `NEXTCLOUD_AUTH_TOKEN` | --auth-token | 129 | | `NEXTCLOUD_LISTEN_ADDRESS` | --addr | 130 | | `NEXTCLOUD_TIMEOUT` | --timeout | 131 | | `NEXTCLOUD_TLS_SKIP_VERIFY` | --tls-skip-verify | 132 | | `NEXTCLOUD_INFO_APPS` | --enable-info-apps | 133 | | `NEXTCLOUD_INFO_UPDATE` | --enable-info-update | 134 | 135 | #### Configuration file 136 | 137 | The `--config-file` option can be used to read the configuration options from a YAML file: 138 | 139 | ```yaml 140 | # required 141 | server: "https://example.com" 142 | # required for token authentication 143 | authToken: "example-token" 144 | # required for username/password authentication 145 | username: "example" 146 | password: "example" 147 | # optional 148 | listenAddress: ":9205" 149 | timeout: "5s" 150 | tlsSkipVerify: false 151 | info: 152 | apps: false 153 | update: false 154 | ``` 155 | 156 | ### Loading Credentials from Files 157 | 158 | Both the authentication token and the password can optionally be read from a separate file instead of directly from the input methods above. 159 | 160 | This can be achieved by setting the value to the path of the file prefixed with an "@", for example: 161 | 162 | ```bash 163 | # Authentication token 164 | nextcloud-exporter -c config-without-token.yml --auth-token @/path/to/tokenfile 165 | # Password 166 | nextcloud-exporter -c config-without-password.yml -p @/path/to/passwordfile 167 | ``` 168 | 169 | This also works when the password or token is set using one of the other configuration modes (configuration file or environment variables). 170 | 171 | ## Other information 172 | 173 | ### Info URL 174 | 175 | The exporter reads the metrics from the Nextcloud server using its "serverinfo" API. You can find the URL of this API in the administrator settings in the "Monitoring" section. It should look something like this: 176 | 177 | ```plain 178 | https://example.com/ocs/v2.php/apps/serverinfo/api/v1/info 179 | ``` 180 | 181 | The path will be automatically added to the server URL you provide, so in the above example setting `--server https://example.com` would be sufficient. 182 | 183 | If you open this URL in a browser you should see an XML structure with the information that will be used by the exporter. 184 | 185 | ### Scrape configuration 186 | 187 | The exporter will query the nextcloud server every time it is scraped by prometheus. If you want to reduce load on the nextcloud server you need to change the scrape interval accordingly: 188 | 189 | ```yml 190 | scrape_configs: 191 | - job_name: 'nextcloud' 192 | scrape_interval: 90s 193 | static_configs: 194 | - targets: ['localhost:9205'] 195 | ``` 196 | 197 | ### Exported metrics 198 | 199 | These metrics are exported by `nextcloud-exporter`: 200 | 201 | | name | description | 202 | |----------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| 203 | | nextcloud_active_users_daily_total | Number of active users in the last 24 hours | 204 | | nextcloud_active_users_hourly_total | Number of active users in the last hour | 205 | | nextcloud_active_users_total | Number of active users for the last five minutes | 206 | | nextcloud_apps_installed_total | Number of currently installed apps | 207 | | nextcloud_apps_updates_available_total | Number of apps that have available updates | 208 | | nextcloud_database_info | Contains meta information about the database as labels. Value is always 1. | 209 | | nextcloud_database_size_bytes | Size of database in bytes as reported from engine | 210 | | nextcloud_exporter_info | Contains meta information of the exporter. Value is always 1. | 211 | | nextcloud_files_total | Number of files served by the instance | 212 | | nextcloud_free_space_bytes | Free disk space in data directory in bytes | 213 | | nextcloud_php_info | Contains meta information about PHP as labels. Value is always 1. | 214 | | nextcloud_php_memory_limit_bytes | Configured PHP memory limit in bytes | 215 | | nextcloud_php_upload_max_size_bytes | Configured maximum upload size in bytes | 216 | | nextcloud_scrape_errors_total | Counts the number of scrape errors by this collector | 217 | | nextcloud_shares_federated_total | Number of federated shares by direction `sent` / `received` | 218 | | nextcloud_shares_total | Number of shares by type:
`authlink`: shared password protected links
`group`: shared groups
`link`: all shared links
`user`: shared users
`mail`: shared by mail
`room`: shared with room | 219 | | nextcloud_system_info | Contains meta information about Nextcloud as labels. Value is always 1. | 220 | | nextcloud_system_update_available | Contains information whether a system update is available:
`0`: no update available
`1`: nextcloud update available
In case of 1=yes, `available_version` label contains the new version. This metric is only available if activated. | 221 | | nextcloud_up | Indicates if the metrics could be scraped by the exporter:
`1`: successful
`0`: unsuccessful (server down, server/endpoint not reachable, invalid credentials, ...) | 222 | | nextcloud_users_total | Number of users of the instance | 223 | -------------------------------------------------------------------------------- /contrib/allow-filesystem.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xperimental/nextcloud-exporter/00ebdcfb7a733eec5ef7c768c91892091baff6b4/contrib/allow-filesystem.png -------------------------------------------------------------------------------- /contrib/debian/control: -------------------------------------------------------------------------------- 1 | Package: nextcloud-exporter 2 | Architecture: amd64 3 | Version: %VERSION% 4 | Maintainer: Robert Jacob 5 | Homepage: https://github.com/xperimental/nextcloud-exporter 6 | Section: net 7 | Priority: optional 8 | Recommends: prometheus 9 | Description: Prometheus exporter for Nextcloud metrics 10 | The nextcloud-exporter can be used to gather metrics from a Nextcloud server 11 | instance. 12 | . 13 | A Prometheus server is needed to pull and store the metrics generated by the 14 | exporter, but it does not need to be on the same machine. 15 | -------------------------------------------------------------------------------- /contrib/grafana-dashboard.json: -------------------------------------------------------------------------------- 1 | { 2 | "__inputs": [ 3 | { 4 | "name": "DS_LOCAL", 5 | "label": "Prometheus", 6 | "description": "", 7 | "type": "datasource", 8 | "pluginId": "prometheus", 9 | "pluginName": "Prometheus" 10 | } 11 | ], 12 | "__elements": {}, 13 | "__requires": [ 14 | { 15 | "type": "grafana", 16 | "id": "grafana", 17 | "name": "Grafana", 18 | "version": "10.2.3" 19 | }, 20 | { 21 | "type": "panel", 22 | "id": "piechart", 23 | "name": "Pie chart", 24 | "version": "" 25 | }, 26 | { 27 | "type": "datasource", 28 | "id": "prometheus", 29 | "name": "Prometheus", 30 | "version": "1.0.0" 31 | }, 32 | { 33 | "type": "panel", 34 | "id": "stat", 35 | "name": "Stat", 36 | "version": "" 37 | }, 38 | { 39 | "type": "panel", 40 | "id": "timeseries", 41 | "name": "Time series", 42 | "version": "" 43 | } 44 | ], 45 | "annotations": { 46 | "list": [ 47 | { 48 | "$$hashKey": "object:405", 49 | "builtIn": 1, 50 | "datasource": { 51 | "type": "datasource", 52 | "uid": "grafana" 53 | }, 54 | "enable": true, 55 | "hide": true, 56 | "iconColor": "rgba(0, 211, 255, 1)", 57 | "name": "Annotations & Alerts", 58 | "target": { 59 | "limit": 100, 60 | "matchAny": false, 61 | "tags": [], 62 | "type": "dashboard" 63 | }, 64 | "type": "dashboard" 65 | } 66 | ] 67 | }, 68 | "editable": true, 69 | "fiscalYearStartMonth": 0, 70 | "graphTooltip": 2, 71 | "id": null, 72 | "links": [], 73 | "liveNow": false, 74 | "panels": [ 75 | { 76 | "collapsed": false, 77 | "gridPos": { 78 | "h": 1, 79 | "w": 24, 80 | "x": 0, 81 | "y": 0 82 | }, 83 | "id": 5, 84 | "panels": [], 85 | "title": "Users and Files", 86 | "type": "row" 87 | }, 88 | { 89 | "datasource": { 90 | "type": "prometheus", 91 | "uid": "${DS_LOCAL}" 92 | }, 93 | "fieldConfig": { 94 | "defaults": { 95 | "color": { 96 | "mode": "thresholds" 97 | }, 98 | "mappings": [], 99 | "min": 0, 100 | "thresholds": { 101 | "mode": "absolute", 102 | "steps": [ 103 | { 104 | "color": "green", 105 | "value": null 106 | } 107 | ] 108 | }, 109 | "unit": "short" 110 | }, 111 | "overrides": [] 112 | }, 113 | "gridPos": { 114 | "h": 7, 115 | "w": 4, 116 | "x": 0, 117 | "y": 1 118 | }, 119 | "id": 12, 120 | "options": { 121 | "colorMode": "value", 122 | "graphMode": "area", 123 | "justifyMode": "auto", 124 | "orientation": "auto", 125 | "reduceOptions": { 126 | "calcs": [ 127 | "lastNotNull" 128 | ], 129 | "fields": "", 130 | "values": false 131 | }, 132 | "textMode": "auto", 133 | "wideLayout": true 134 | }, 135 | "pluginVersion": "10.2.3", 136 | "targets": [ 137 | { 138 | "datasource": { 139 | "type": "prometheus", 140 | "uid": "${DS_LOCAL}" 141 | }, 142 | "editorMode": "code", 143 | "expr": "sum by(instance) (nextcloud_active_users_daily_total{instance=\"$instance\"})", 144 | "instant": false, 145 | "legendFormat": "__auto", 146 | "range": true, 147 | "refId": "A" 148 | } 149 | ], 150 | "title": "Daily Active Users", 151 | "type": "stat" 152 | }, 153 | { 154 | "datasource": { 155 | "type": "prometheus", 156 | "uid": "${DS_LOCAL}" 157 | }, 158 | "fieldConfig": { 159 | "defaults": { 160 | "color": { 161 | "mode": "palette-classic" 162 | }, 163 | "custom": { 164 | "axisBorderShow": false, 165 | "axisCenteredZero": false, 166 | "axisColorMode": "text", 167 | "axisLabel": "", 168 | "axisPlacement": "auto", 169 | "barAlignment": 0, 170 | "drawStyle": "line", 171 | "fillOpacity": 0, 172 | "gradientMode": "none", 173 | "hideFrom": { 174 | "legend": false, 175 | "tooltip": false, 176 | "viz": false 177 | }, 178 | "insertNulls": false, 179 | "lineInterpolation": "linear", 180 | "lineWidth": 1, 181 | "pointSize": 5, 182 | "scaleDistribution": { 183 | "type": "linear" 184 | }, 185 | "showPoints": "never", 186 | "spanNulls": false, 187 | "stacking": { 188 | "group": "A", 189 | "mode": "none" 190 | }, 191 | "thresholdsStyle": { 192 | "mode": "off" 193 | } 194 | }, 195 | "links": [], 196 | "mappings": [], 197 | "min": 0, 198 | "thresholds": { 199 | "mode": "absolute", 200 | "steps": [ 201 | { 202 | "color": "green", 203 | "value": null 204 | } 205 | ] 206 | }, 207 | "unit": "short" 208 | }, 209 | "overrides": [] 210 | }, 211 | "gridPos": { 212 | "h": 7, 213 | "w": 8, 214 | "x": 4, 215 | "y": 1 216 | }, 217 | "id": 1, 218 | "links": [], 219 | "options": { 220 | "legend": { 221 | "calcs": [], 222 | "displayMode": "list", 223 | "placement": "bottom", 224 | "showLegend": false 225 | }, 226 | "tooltip": { 227 | "mode": "multi", 228 | "sort": "desc" 229 | } 230 | }, 231 | "pluginVersion": "10.2.3", 232 | "targets": [ 233 | { 234 | "datasource": { 235 | "type": "prometheus", 236 | "uid": "${DS_LOCAL}" 237 | }, 238 | "editorMode": "code", 239 | "expr": "sum by(instance) (nextcloud_users_total{instance=\"$instance\"})", 240 | "intervalFactor": 1, 241 | "legendFormat": "total ({{instance}})", 242 | "metric": "", 243 | "range": true, 244 | "refId": "A", 245 | "step": 900 246 | }, 247 | { 248 | "datasource": { 249 | "type": "prometheus", 250 | "uid": "${DS_LOCAL}" 251 | }, 252 | "editorMode": "code", 253 | "expr": "sum by(instance) (nextcloud_active_users_total{instance=\"$instance\"})", 254 | "hide": false, 255 | "intervalFactor": 1, 256 | "legendFormat": "active ({{instance}})", 257 | "metric": "", 258 | "range": true, 259 | "refId": "B", 260 | "step": 900 261 | } 262 | ], 263 | "title": "Users", 264 | "type": "timeseries" 265 | }, 266 | { 267 | "datasource": { 268 | "type": "prometheus", 269 | "uid": "${DS_LOCAL}" 270 | }, 271 | "fieldConfig": { 272 | "defaults": { 273 | "color": { 274 | "mode": "palette-classic" 275 | }, 276 | "custom": { 277 | "axisBorderShow": false, 278 | "axisCenteredZero": false, 279 | "axisColorMode": "text", 280 | "axisLabel": "", 281 | "axisPlacement": "auto", 282 | "barAlignment": 0, 283 | "drawStyle": "line", 284 | "fillOpacity": 0, 285 | "gradientMode": "none", 286 | "hideFrom": { 287 | "legend": false, 288 | "tooltip": false, 289 | "viz": false 290 | }, 291 | "insertNulls": false, 292 | "lineInterpolation": "linear", 293 | "lineWidth": 1, 294 | "pointSize": 5, 295 | "scaleDistribution": { 296 | "type": "linear" 297 | }, 298 | "showPoints": "never", 299 | "spanNulls": false, 300 | "stacking": { 301 | "group": "A", 302 | "mode": "none" 303 | }, 304 | "thresholdsStyle": { 305 | "mode": "off" 306 | } 307 | }, 308 | "links": [], 309 | "mappings": [], 310 | "min": 0, 311 | "thresholds": { 312 | "mode": "absolute", 313 | "steps": [ 314 | { 315 | "color": "green", 316 | "value": null 317 | } 318 | ] 319 | }, 320 | "unit": "short" 321 | }, 322 | "overrides": [] 323 | }, 324 | "gridPos": { 325 | "h": 7, 326 | "w": 12, 327 | "x": 12, 328 | "y": 1 329 | }, 330 | "id": 11, 331 | "links": [], 332 | "options": { 333 | "legend": { 334 | "calcs": [], 335 | "displayMode": "list", 336 | "placement": "bottom", 337 | "showLegend": false 338 | }, 339 | "tooltip": { 340 | "mode": "multi", 341 | "sort": "desc" 342 | } 343 | }, 344 | "pluginVersion": "10.2.3", 345 | "targets": [ 346 | { 347 | "datasource": { 348 | "type": "prometheus", 349 | "uid": "${DS_LOCAL}" 350 | }, 351 | "editorMode": "code", 352 | "expr": "sum by(instance) (nextcloud_files_total{instance=\"$instance\"})", 353 | "intervalFactor": 1, 354 | "legendFormat": "__auto", 355 | "metric": "", 356 | "range": true, 357 | "refId": "B", 358 | "step": 900 359 | } 360 | ], 361 | "title": "Files", 362 | "type": "timeseries" 363 | }, 364 | { 365 | "collapsed": false, 366 | "gridPos": { 367 | "h": 1, 368 | "w": 24, 369 | "x": 0, 370 | "y": 8 371 | }, 372 | "id": 6, 373 | "panels": [], 374 | "title": "Shares", 375 | "type": "row" 376 | }, 377 | { 378 | "datasource": { 379 | "type": "prometheus", 380 | "uid": "${DS_LOCAL}" 381 | }, 382 | "fieldConfig": { 383 | "defaults": { 384 | "color": { 385 | "mode": "palette-classic" 386 | }, 387 | "custom": { 388 | "axisBorderShow": false, 389 | "axisCenteredZero": false, 390 | "axisColorMode": "text", 391 | "axisLabel": "", 392 | "axisPlacement": "auto", 393 | "barAlignment": 0, 394 | "drawStyle": "line", 395 | "fillOpacity": 10, 396 | "gradientMode": "none", 397 | "hideFrom": { 398 | "legend": false, 399 | "tooltip": false, 400 | "viz": false 401 | }, 402 | "insertNulls": false, 403 | "lineInterpolation": "linear", 404 | "lineWidth": 1, 405 | "pointSize": 5, 406 | "scaleDistribution": { 407 | "type": "linear" 408 | }, 409 | "showPoints": "never", 410 | "spanNulls": false, 411 | "stacking": { 412 | "group": "A", 413 | "mode": "none" 414 | }, 415 | "thresholdsStyle": { 416 | "mode": "off" 417 | } 418 | }, 419 | "links": [], 420 | "mappings": [], 421 | "min": 0, 422 | "thresholds": { 423 | "mode": "absolute", 424 | "steps": [ 425 | { 426 | "color": "green", 427 | "value": null 428 | } 429 | ] 430 | }, 431 | "unit": "short" 432 | }, 433 | "overrides": [] 434 | }, 435 | "gridPos": { 436 | "h": 7, 437 | "w": 12, 438 | "x": 0, 439 | "y": 9 440 | }, 441 | "id": 2, 442 | "links": [], 443 | "options": { 444 | "legend": { 445 | "calcs": [], 446 | "displayMode": "list", 447 | "placement": "bottom", 448 | "showLegend": true 449 | }, 450 | "tooltip": { 451 | "mode": "multi", 452 | "sort": "desc" 453 | } 454 | }, 455 | "pluginVersion": "10.2.3", 456 | "targets": [ 457 | { 458 | "datasource": { 459 | "type": "prometheus", 460 | "uid": "${DS_LOCAL}" 461 | }, 462 | "editorMode": "code", 463 | "expr": "sum by(type) (nextcloud_shares_total{instance=\"$instance\"})", 464 | "intervalFactor": 1, 465 | "legendFormat": "{{type}}", 466 | "metric": "", 467 | "range": true, 468 | "refId": "A", 469 | "step": 1800 470 | } 471 | ], 472 | "title": "Shares by Type", 473 | "type": "timeseries" 474 | }, 475 | { 476 | "datasource": { 477 | "type": "prometheus", 478 | "uid": "${DS_LOCAL}" 479 | }, 480 | "fieldConfig": { 481 | "defaults": { 482 | "color": { 483 | "mode": "palette-classic" 484 | }, 485 | "custom": { 486 | "axisBorderShow": false, 487 | "axisCenteredZero": false, 488 | "axisColorMode": "text", 489 | "axisLabel": "", 490 | "axisPlacement": "auto", 491 | "barAlignment": 0, 492 | "drawStyle": "line", 493 | "fillOpacity": 10, 494 | "gradientMode": "none", 495 | "hideFrom": { 496 | "legend": false, 497 | "tooltip": false, 498 | "viz": false 499 | }, 500 | "insertNulls": false, 501 | "lineInterpolation": "linear", 502 | "lineWidth": 1, 503 | "pointSize": 5, 504 | "scaleDistribution": { 505 | "type": "linear" 506 | }, 507 | "showPoints": "never", 508 | "spanNulls": false, 509 | "stacking": { 510 | "group": "A", 511 | "mode": "none" 512 | }, 513 | "thresholdsStyle": { 514 | "mode": "off" 515 | } 516 | }, 517 | "links": [], 518 | "mappings": [], 519 | "min": 0, 520 | "thresholds": { 521 | "mode": "absolute", 522 | "steps": [ 523 | { 524 | "color": "green", 525 | "value": null 526 | } 527 | ] 528 | }, 529 | "unit": "short" 530 | }, 531 | "overrides": [] 532 | }, 533 | "gridPos": { 534 | "h": 7, 535 | "w": 12, 536 | "x": 12, 537 | "y": 9 538 | }, 539 | "id": 3, 540 | "links": [], 541 | "options": { 542 | "legend": { 543 | "calcs": [], 544 | "displayMode": "list", 545 | "placement": "bottom", 546 | "showLegend": true 547 | }, 548 | "tooltip": { 549 | "mode": "multi", 550 | "sort": "desc" 551 | } 552 | }, 553 | "pluginVersion": "10.2.3", 554 | "targets": [ 555 | { 556 | "datasource": { 557 | "type": "prometheus", 558 | "uid": "${DS_LOCAL}" 559 | }, 560 | "editorMode": "code", 561 | "expr": "sum by(direction) (nextcloud_shares_federated_total{instance=\"$instance\"})", 562 | "intervalFactor": 1, 563 | "legendFormat": "{{direction}}", 564 | "metric": "", 565 | "range": true, 566 | "refId": "A", 567 | "step": 1800 568 | } 569 | ], 570 | "title": "Federated Shares", 571 | "type": "timeseries" 572 | }, 573 | { 574 | "collapsed": false, 575 | "gridPos": { 576 | "h": 1, 577 | "w": 24, 578 | "x": 0, 579 | "y": 16 580 | }, 581 | "id": 7, 582 | "panels": [], 583 | "title": "Apps", 584 | "type": "row" 585 | }, 586 | { 587 | "datasource": { 588 | "type": "prometheus", 589 | "uid": "${DS_LOCAL}" 590 | }, 591 | "fieldConfig": { 592 | "defaults": { 593 | "color": { 594 | "mode": "thresholds" 595 | }, 596 | "mappings": [], 597 | "min": 0, 598 | "thresholds": { 599 | "mode": "absolute", 600 | "steps": [ 601 | { 602 | "color": "green", 603 | "value": null 604 | } 605 | ] 606 | }, 607 | "unit": "short" 608 | }, 609 | "overrides": [] 610 | }, 611 | "gridPos": { 612 | "h": 7, 613 | "w": 4, 614 | "x": 0, 615 | "y": 17 616 | }, 617 | "id": 9, 618 | "options": { 619 | "colorMode": "value", 620 | "graphMode": "area", 621 | "justifyMode": "auto", 622 | "orientation": "auto", 623 | "reduceOptions": { 624 | "calcs": [ 625 | "lastNotNull" 626 | ], 627 | "fields": "", 628 | "values": false 629 | }, 630 | "textMode": "auto", 631 | "wideLayout": true 632 | }, 633 | "pluginVersion": "10.2.3", 634 | "targets": [ 635 | { 636 | "datasource": { 637 | "type": "prometheus", 638 | "uid": "${DS_LOCAL}" 639 | }, 640 | "editorMode": "code", 641 | "expr": "sum by(instance) (nextcloud_apps_installed_total{instance=\"$instance\"})", 642 | "instant": false, 643 | "legendFormat": "__auto", 644 | "range": true, 645 | "refId": "A" 646 | } 647 | ], 648 | "title": "Installed Apps", 649 | "type": "stat" 650 | }, 651 | { 652 | "datasource": { 653 | "type": "prometheus", 654 | "uid": "${DS_LOCAL}" 655 | }, 656 | "fieldConfig": { 657 | "defaults": { 658 | "color": { 659 | "mode": "thresholds" 660 | }, 661 | "mappings": [], 662 | "min": 0, 663 | "thresholds": { 664 | "mode": "absolute", 665 | "steps": [ 666 | { 667 | "color": "green", 668 | "value": null 669 | }, 670 | { 671 | "color": "#EAB839", 672 | "value": 1 673 | } 674 | ] 675 | }, 676 | "unit": "short" 677 | }, 678 | "overrides": [] 679 | }, 680 | "gridPos": { 681 | "h": 7, 682 | "w": 4, 683 | "x": 4, 684 | "y": 17 685 | }, 686 | "id": 10, 687 | "options": { 688 | "colorMode": "value", 689 | "graphMode": "area", 690 | "justifyMode": "auto", 691 | "orientation": "auto", 692 | "reduceOptions": { 693 | "calcs": [ 694 | "lastNotNull" 695 | ], 696 | "fields": "", 697 | "values": false 698 | }, 699 | "textMode": "auto", 700 | "wideLayout": true 701 | }, 702 | "pluginVersion": "10.2.3", 703 | "targets": [ 704 | { 705 | "datasource": { 706 | "type": "prometheus", 707 | "uid": "${DS_LOCAL}" 708 | }, 709 | "editorMode": "code", 710 | "expr": "sum by(instance) (nextcloud_apps_updates_available_total{instance=\"$instance\"})", 711 | "instant": false, 712 | "legendFormat": "__auto", 713 | "range": true, 714 | "refId": "A" 715 | } 716 | ], 717 | "title": "Available Updates", 718 | "type": "stat" 719 | }, 720 | { 721 | "collapsed": false, 722 | "gridPos": { 723 | "h": 1, 724 | "w": 24, 725 | "x": 0, 726 | "y": 24 727 | }, 728 | "id": 8, 729 | "panels": [], 730 | "title": "System Information", 731 | "type": "row" 732 | }, 733 | { 734 | "datasource": { 735 | "type": "prometheus", 736 | "uid": "${DS_LOCAL}" 737 | }, 738 | "fieldConfig": { 739 | "defaults": { 740 | "color": { 741 | "mode": "palette-classic" 742 | }, 743 | "custom": { 744 | "axisBorderShow": false, 745 | "axisCenteredZero": false, 746 | "axisColorMode": "text", 747 | "axisLabel": "", 748 | "axisPlacement": "auto", 749 | "barAlignment": 0, 750 | "drawStyle": "line", 751 | "fillOpacity": 10, 752 | "gradientMode": "none", 753 | "hideFrom": { 754 | "legend": false, 755 | "tooltip": false, 756 | "viz": false 757 | }, 758 | "insertNulls": false, 759 | "lineInterpolation": "linear", 760 | "lineWidth": 1, 761 | "pointSize": 5, 762 | "scaleDistribution": { 763 | "type": "linear" 764 | }, 765 | "showPoints": "never", 766 | "spanNulls": false, 767 | "stacking": { 768 | "group": "A", 769 | "mode": "none" 770 | }, 771 | "thresholdsStyle": { 772 | "mode": "off" 773 | } 774 | }, 775 | "links": [], 776 | "mappings": [], 777 | "min": 0, 778 | "thresholds": { 779 | "mode": "absolute", 780 | "steps": [ 781 | { 782 | "color": "green" 783 | } 784 | ] 785 | }, 786 | "unit": "decbytes" 787 | }, 788 | "overrides": [] 789 | }, 790 | "gridPos": { 791 | "h": 7, 792 | "w": 12, 793 | "x": 0, 794 | "y": 25 795 | }, 796 | "id": 4, 797 | "links": [], 798 | "options": { 799 | "legend": { 800 | "calcs": [], 801 | "displayMode": "list", 802 | "placement": "bottom", 803 | "showLegend": false 804 | }, 805 | "tooltip": { 806 | "mode": "multi", 807 | "sort": "desc" 808 | } 809 | }, 810 | "pluginVersion": "10.2.3", 811 | "targets": [ 812 | { 813 | "datasource": { 814 | "type": "prometheus", 815 | "uid": "${DS_LOCAL}" 816 | }, 817 | "editorMode": "code", 818 | "expr": "sum by(instance) (nextcloud_free_space_bytes{instance=\"$instance\"})", 819 | "interval": "", 820 | "intervalFactor": 1, 821 | "legendFormat": "", 822 | "metric": "", 823 | "range": true, 824 | "refId": "A", 825 | "step": 900 826 | } 827 | ], 828 | "title": "Disk Space Left", 829 | "type": "timeseries" 830 | }, 831 | { 832 | "datasource": { 833 | "type": "prometheus", 834 | "uid": "${DS_LOCAL}" 835 | }, 836 | "fieldConfig": { 837 | "defaults": { 838 | "color": { 839 | "mode": "palette-classic" 840 | }, 841 | "custom": { 842 | "hideFrom": { 843 | "legend": false, 844 | "tooltip": false, 845 | "viz": false 846 | } 847 | }, 848 | "mappings": [] 849 | }, 850 | "overrides": [] 851 | }, 852 | "gridPos": { 853 | "h": 7, 854 | "w": 4, 855 | "x": 12, 856 | "y": 25 857 | }, 858 | "id": 13, 859 | "options": { 860 | "legend": { 861 | "displayMode": "table", 862 | "placement": "right", 863 | "showLegend": true, 864 | "values": [ 865 | "percent" 866 | ] 867 | }, 868 | "pieType": "pie", 869 | "reduceOptions": { 870 | "calcs": [ 871 | "lastNotNull" 872 | ], 873 | "fields": "", 874 | "values": false 875 | }, 876 | "tooltip": { 877 | "mode": "single", 878 | "sort": "desc" 879 | } 880 | }, 881 | "targets": [ 882 | { 883 | "datasource": { 884 | "type": "prometheus", 885 | "uid": "${DS_LOCAL}" 886 | }, 887 | "editorMode": "code", 888 | "exemplar": false, 889 | "expr": "sum by(version) (nextcloud_system_info)", 890 | "instant": true, 891 | "legendFormat": "__auto", 892 | "range": false, 893 | "refId": "A" 894 | } 895 | ], 896 | "title": "Nextcloud Versions (all instances)", 897 | "type": "piechart" 898 | }, 899 | { 900 | "datasource": { 901 | "type": "prometheus", 902 | "uid": "${DS_LOCAL}" 903 | }, 904 | "fieldConfig": { 905 | "defaults": { 906 | "color": { 907 | "mode": "palette-classic" 908 | }, 909 | "custom": { 910 | "hideFrom": { 911 | "legend": false, 912 | "tooltip": false, 913 | "viz": false 914 | } 915 | }, 916 | "mappings": [] 917 | }, 918 | "overrides": [] 919 | }, 920 | "gridPos": { 921 | "h": 7, 922 | "w": 4, 923 | "x": 16, 924 | "y": 25 925 | }, 926 | "id": 17, 927 | "options": { 928 | "legend": { 929 | "displayMode": "table", 930 | "placement": "right", 931 | "showLegend": true, 932 | "values": [ 933 | "percent" 934 | ] 935 | }, 936 | "pieType": "pie", 937 | "reduceOptions": { 938 | "calcs": [ 939 | "lastNotNull" 940 | ], 941 | "fields": "", 942 | "values": false 943 | }, 944 | "tooltip": { 945 | "mode": "single", 946 | "sort": "desc" 947 | } 948 | }, 949 | "targets": [ 950 | { 951 | "datasource": { 952 | "type": "prometheus", 953 | "uid": "${DS_LOCAL}" 954 | }, 955 | "editorMode": "code", 956 | "exemplar": false, 957 | "expr": "sum by(version) (nextcloud_exporter_info)", 958 | "instant": true, 959 | "legendFormat": "__auto", 960 | "range": false, 961 | "refId": "A" 962 | } 963 | ], 964 | "title": "Exporter Versions (all instances)", 965 | "type": "piechart" 966 | }, 967 | { 968 | "collapsed": false, 969 | "gridPos": { 970 | "h": 1, 971 | "w": 24, 972 | "x": 0, 973 | "y": 32 974 | }, 975 | "id": 16, 976 | "panels": [], 977 | "title": "Environment Information", 978 | "type": "row" 979 | }, 980 | { 981 | "datasource": { 982 | "type": "prometheus", 983 | "uid": "${DS_LOCAL}" 984 | }, 985 | "fieldConfig": { 986 | "defaults": { 987 | "color": { 988 | "mode": "palette-classic" 989 | }, 990 | "custom": { 991 | "hideFrom": { 992 | "legend": false, 993 | "tooltip": false, 994 | "viz": false 995 | } 996 | }, 997 | "mappings": [] 998 | }, 999 | "overrides": [] 1000 | }, 1001 | "gridPos": { 1002 | "h": 7, 1003 | "w": 4, 1004 | "x": 0, 1005 | "y": 33 1006 | }, 1007 | "id": 14, 1008 | "options": { 1009 | "legend": { 1010 | "displayMode": "table", 1011 | "placement": "right", 1012 | "showLegend": true, 1013 | "values": [ 1014 | "percent" 1015 | ] 1016 | }, 1017 | "pieType": "pie", 1018 | "reduceOptions": { 1019 | "calcs": [ 1020 | "lastNotNull" 1021 | ], 1022 | "fields": "", 1023 | "values": false 1024 | }, 1025 | "tooltip": { 1026 | "mode": "single", 1027 | "sort": "desc" 1028 | } 1029 | }, 1030 | "targets": [ 1031 | { 1032 | "datasource": { 1033 | "type": "prometheus", 1034 | "uid": "${DS_LOCAL}" 1035 | }, 1036 | "editorMode": "code", 1037 | "exemplar": false, 1038 | "expr": "sum by(version) (nextcloud_php_info)", 1039 | "instant": true, 1040 | "legendFormat": "__auto", 1041 | "range": false, 1042 | "refId": "A" 1043 | } 1044 | ], 1045 | "title": "PHP Versions (all instances)", 1046 | "type": "piechart" 1047 | }, 1048 | { 1049 | "datasource": { 1050 | "type": "prometheus", 1051 | "uid": "${DS_LOCAL}" 1052 | }, 1053 | "fieldConfig": { 1054 | "defaults": { 1055 | "color": { 1056 | "mode": "palette-classic" 1057 | }, 1058 | "custom": { 1059 | "hideFrom": { 1060 | "legend": false, 1061 | "tooltip": false, 1062 | "viz": false 1063 | } 1064 | }, 1065 | "mappings": [] 1066 | }, 1067 | "overrides": [] 1068 | }, 1069 | "gridPos": { 1070 | "h": 7, 1071 | "w": 4, 1072 | "x": 4, 1073 | "y": 33 1074 | }, 1075 | "id": 15, 1076 | "options": { 1077 | "legend": { 1078 | "displayMode": "table", 1079 | "placement": "right", 1080 | "showLegend": true, 1081 | "values": [ 1082 | "percent" 1083 | ] 1084 | }, 1085 | "pieType": "pie", 1086 | "reduceOptions": { 1087 | "calcs": [ 1088 | "lastNotNull" 1089 | ], 1090 | "fields": "", 1091 | "values": false 1092 | }, 1093 | "tooltip": { 1094 | "mode": "single", 1095 | "sort": "desc" 1096 | } 1097 | }, 1098 | "targets": [ 1099 | { 1100 | "datasource": { 1101 | "type": "prometheus", 1102 | "uid": "${DS_LOCAL}" 1103 | }, 1104 | "editorMode": "code", 1105 | "exemplar": false, 1106 | "expr": "sum by(type, version) (nextcloud_database_info)", 1107 | "instant": true, 1108 | "legendFormat": "{{type}} {{version}}", 1109 | "range": false, 1110 | "refId": "A" 1111 | } 1112 | ], 1113 | "title": "Databases (all instances)", 1114 | "type": "piechart" 1115 | } 1116 | ], 1117 | "refresh": "5m", 1118 | "schemaVersion": 39, 1119 | "tags": [ 1120 | "prometheus", 1121 | "nextcloud" 1122 | ], 1123 | "templating": { 1124 | "list": [ 1125 | { 1126 | "current": {}, 1127 | "datasource": { 1128 | "type": "prometheus", 1129 | "uid": "${DS_LOCAL}" 1130 | }, 1131 | "definition": "", 1132 | "hide": 0, 1133 | "includeAll": false, 1134 | "label": "Instance", 1135 | "multi": false, 1136 | "name": "instance", 1137 | "options": [], 1138 | "query": "label_values(nextcloud_up, instance)", 1139 | "refresh": 2, 1140 | "regex": "", 1141 | "skipUrlSync": false, 1142 | "sort": 0, 1143 | "tagValuesQuery": "", 1144 | "tagsQuery": "", 1145 | "type": "query", 1146 | "useTags": false 1147 | } 1148 | ] 1149 | }, 1150 | "time": { 1151 | "from": "now-30d", 1152 | "to": "now" 1153 | }, 1154 | "timepicker": { 1155 | "refresh_intervals": [ 1156 | "30s", 1157 | "1m", 1158 | "5m", 1159 | "15m", 1160 | "1h", 1161 | "2h", 1162 | "1d" 1163 | ], 1164 | "time_options": [ 1165 | "5m", 1166 | "15m", 1167 | "1h", 1168 | "6h", 1169 | "12h", 1170 | "24h", 1171 | "2d", 1172 | "7d", 1173 | "30d" 1174 | ] 1175 | }, 1176 | "timezone": "browser", 1177 | "title": "Nextcloud", 1178 | "uid": "cdae9143-9a17-4d37-87da-d34fb9cfd92f", 1179 | "version": 4, 1180 | "weekStart": "" 1181 | } 1182 | -------------------------------------------------------------------------------- /contrib/nextcloud-exporter.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Prometheus exporter for Nextcloud metrics 3 | Documentation=https://github.com/xperimental/nextcloud-exporter 4 | After=network.target nss-lookup.target 5 | 6 | [Service] 7 | Type=simple 8 | ExecStart=/usr/bin/nextcloud-exporter -c /etc/nextcloud-exporter.yml 9 | User=nextcloud-exporter 10 | Group=nextcloud-exporter 11 | PrivateTmp=true 12 | ProtectHome=true 13 | ProtectSystem=full 14 | Restart=on-failure 15 | RestartSec=20 16 | 17 | [Install] 18 | WantedBy=multi-user.target 19 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/xperimental/nextcloud-exporter 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.4 6 | 7 | require ( 8 | github.com/google/go-cmp v0.6.0 9 | github.com/prometheus/client_golang v1.20.5 10 | github.com/sirupsen/logrus v1.9.3 11 | github.com/spf13/pflag v1.0.5 12 | gopkg.in/yaml.v3 v3.0.1 13 | ) 14 | 15 | require ( 16 | github.com/beorn7/perks v1.0.1 // indirect 17 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 18 | github.com/klauspost/compress v1.17.11 // indirect 19 | github.com/kr/text v0.2.0 // indirect 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 21 | github.com/prometheus/client_model v0.6.1 // indirect 22 | github.com/prometheus/common v0.61.0 // indirect 23 | github.com/prometheus/procfs v0.15.1 // indirect 24 | golang.org/x/sys v0.28.0 // indirect 25 | google.golang.org/protobuf v1.36.0 // indirect 26 | ) 27 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 2 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 3 | github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= 4 | github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 5 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 6 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 7 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 12 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 13 | github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= 14 | github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= 15 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 16 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 17 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 18 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 19 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 20 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7kmXYlnfbA6JU= 24 | github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= 25 | github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= 26 | github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= 27 | github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= 28 | github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= 29 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 30 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 31 | github.com/prometheus/common v0.49.0 h1:ToNTdK4zSnPVJmh698mGFkDor9wBI/iGaJy5dbH1EgI= 32 | github.com/prometheus/common v0.49.0/go.mod h1:Kxm+EULxRbUkjGU6WFsQqo3ORzB4tyKvlWFOE9mB2sE= 33 | github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ= 34 | github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s= 35 | github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 36 | github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 37 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 38 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 39 | github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= 40 | github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= 41 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 42 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 43 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 44 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 45 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 46 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 47 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 48 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 49 | golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= 50 | golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 51 | golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= 52 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 53 | google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= 54 | google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 55 | google.golang.org/protobuf v1.36.0 h1:mjIs9gYtt56AzC4ZaffQuh88TZurBGhIJMBZGSxNerQ= 56 | google.golang.org/protobuf v1.36.0/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 57 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 58 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 59 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 60 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 61 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 62 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 63 | -------------------------------------------------------------------------------- /internal/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "crypto/tls" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "time" 9 | 10 | "github.com/xperimental/nextcloud-exporter/serverinfo" 11 | ) 12 | 13 | const ( 14 | nextcloudTokenHeader = "NC-Token" 15 | ) 16 | 17 | var ( 18 | ErrNotAuthorized = errors.New("wrong credentials") 19 | ErrRatelimit = errors.New("too many requests") 20 | ) 21 | 22 | type InfoClient func() (*serverinfo.ServerInfo, error) 23 | 24 | func New(infoURL, username, password, authToken string, timeout time.Duration, userAgent string, tlsSkipVerify bool) InfoClient { 25 | client := &http.Client{ 26 | Timeout: timeout, 27 | Transport: &http.Transport{ 28 | TLSClientConfig: &tls.Config{ 29 | // disable TLS certification verification, if desired 30 | InsecureSkipVerify: tlsSkipVerify, 31 | }, 32 | }, 33 | } 34 | 35 | return func() (*serverinfo.ServerInfo, error) { 36 | req, err := http.NewRequest(http.MethodGet, infoURL, nil) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | if authToken == "" { 42 | req.SetBasicAuth(username, password) 43 | } else { 44 | req.Header.Set(nextcloudTokenHeader, authToken) 45 | } 46 | 47 | req.Header.Set("User-Agent", userAgent) 48 | 49 | res, err := client.Do(req) 50 | if err != nil { 51 | return nil, err 52 | } 53 | defer res.Body.Close() 54 | 55 | switch res.StatusCode { 56 | case http.StatusOK: 57 | break 58 | case http.StatusUnauthorized: 59 | return nil, ErrNotAuthorized 60 | case http.StatusTooManyRequests: 61 | return nil, ErrRatelimit 62 | default: 63 | return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode) 64 | } 65 | 66 | status, err := serverinfo.ParseJSON(res.Body) 67 | if err != nil { 68 | return nil, fmt.Errorf("can not parse server info: %w", err) 69 | } 70 | 71 | return status, nil 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /internal/client/client_test.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | "time" 10 | 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/xperimental/nextcloud-exporter/internal/testutil" 13 | "github.com/xperimental/nextcloud-exporter/serverinfo" 14 | ) 15 | 16 | func TestClient(t *testing.T) { 17 | wantUserAgent := "test-ua" 18 | wantUsername := "test-user" 19 | wantPassword := "test-password" 20 | wantToken := "test-token" 21 | 22 | tt := []struct { 23 | desc string 24 | password string 25 | token string 26 | handler func(t *testing.T) http.Handler 27 | wantInfo *serverinfo.ServerInfo 28 | wantErr error 29 | }{ 30 | { 31 | desc: "password", 32 | password: wantPassword, 33 | token: "", 34 | handler: func(t *testing.T) http.Handler { 35 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 36 | user, password, ok := req.BasicAuth() 37 | if !ok { 38 | t.Error("failed to get authentication header") 39 | } 40 | 41 | if user != wantUsername { 42 | t.Errorf("got username %q, want %q", user, wantUsername) 43 | } 44 | 45 | if password != wantPassword { 46 | t.Errorf("got password %q, want %q", password, wantPassword) 47 | } 48 | 49 | fmt.Fprintln(w, "{}") 50 | }) 51 | }, 52 | wantInfo: &serverinfo.ServerInfo{}, 53 | wantErr: nil, 54 | }, 55 | { 56 | desc: "token", 57 | password: "", 58 | token: wantToken, 59 | handler: func(t *testing.T) http.Handler { 60 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 61 | token := req.Header.Get(nextcloudTokenHeader) 62 | if token != wantToken { 63 | t.Errorf("got token %q, want %q", token, wantToken) 64 | } 65 | 66 | fmt.Fprintln(w, "{}") 67 | }) 68 | }, 69 | wantInfo: &serverinfo.ServerInfo{}, 70 | wantErr: nil, 71 | }, 72 | { 73 | desc: "user-agent", 74 | token: wantToken, 75 | handler: func(t *testing.T) http.Handler { 76 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 77 | ua := req.UserAgent() 78 | if ua != wantUserAgent { 79 | t.Errorf("got user-agent %q, want %q", ua, wantUserAgent) 80 | } 81 | 82 | fmt.Fprintln(w, "{}") 83 | }) 84 | }, 85 | wantInfo: &serverinfo.ServerInfo{}, 86 | wantErr: nil, 87 | }, 88 | { 89 | desc: "auth error", 90 | password: "", 91 | token: "", 92 | handler: func(t *testing.T) http.Handler { 93 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 94 | w.WriteHeader(http.StatusUnauthorized) 95 | }) 96 | }, 97 | wantInfo: nil, 98 | wantErr: ErrNotAuthorized, 99 | }, 100 | { 101 | desc: "simple info", 102 | token: wantToken, 103 | handler: func(t *testing.T) http.Handler { 104 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 105 | fmt.Fprintln(w, `{"ocs": {"meta": {"status": "OK", "statuscode": 200}}}`) 106 | }) 107 | }, 108 | wantInfo: &serverinfo.ServerInfo{ 109 | Meta: serverinfo.Meta{ 110 | Status: "OK", 111 | StatusCode: http.StatusOK, 112 | }, 113 | }, 114 | wantErr: nil, 115 | }, 116 | { 117 | desc: "parse error", 118 | password: "", 119 | token: "", 120 | handler: func(t *testing.T) http.Handler { 121 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 122 | w.WriteHeader(http.StatusOK) 123 | }) 124 | }, 125 | wantInfo: nil, 126 | wantErr: errors.New("can not parse server info: EOF"), 127 | }, 128 | { 129 | desc: "ratelimit", 130 | handler: func(t *testing.T) http.Handler { 131 | return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 132 | w.WriteHeader(http.StatusTooManyRequests) 133 | }) 134 | }, 135 | wantErr: ErrRatelimit, 136 | }, 137 | } 138 | 139 | for _, tc := range tt { 140 | tc := tc 141 | t.Run(tc.desc, func(t *testing.T) { 142 | t.Parallel() 143 | 144 | s := httptest.NewServer(tc.handler(t)) 145 | defer s.Close() 146 | 147 | client := New(s.URL, wantUsername, tc.password, tc.token, time.Second, wantUserAgent, false) 148 | 149 | info, err := client() 150 | 151 | if !testutil.EqualErrorMessage(err, tc.wantErr) { 152 | t.Errorf("got error %q, want %q", err, tc.wantErr) 153 | } 154 | 155 | if err != nil { 156 | return 157 | } 158 | 159 | if diff := cmp.Diff(info, tc.wantInfo); diff != "" { 160 | t.Errorf("info differs: -got+want\n%s", diff) 161 | } 162 | }) 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/spf13/pflag" 12 | "gopkg.in/yaml.v3" 13 | ) 14 | 15 | const ( 16 | envPrefix = "NEXTCLOUD_" 17 | envListenAddress = envPrefix + "LISTEN_ADDRESS" 18 | envTimeout = envPrefix + "TIMEOUT" 19 | envServerURL = envPrefix + "SERVER" 20 | envUsername = envPrefix + "USERNAME" 21 | envPassword = envPrefix + "PASSWORD" 22 | envAuthToken = envPrefix + "AUTH_TOKEN" 23 | envTLSSkipVerify = envPrefix + "TLS_SKIP_VERIFY" 24 | envInfoApps = envPrefix + "INFO_APPS" 25 | envInfoUpdate = envPrefix + "INFO_UPDATE" 26 | ) 27 | 28 | // RunMode signals what the main application should do after parsing the options. 29 | type RunMode int 30 | 31 | const ( 32 | // RunModeExporter is the normal operation as an exporter serving metrics via HTTP. 33 | RunModeExporter RunMode = iota 34 | // RunModeHelp shows information about available options. 35 | RunModeHelp 36 | // RunModeLogin is used to interactively login to a Nextcloud instance. 37 | RunModeLogin 38 | // RunModeVersion shows version information. 39 | RunModeVersion 40 | ) 41 | 42 | func (m RunMode) String() string { 43 | switch m { 44 | case RunModeExporter: 45 | return "exporter" 46 | case RunModeHelp: 47 | return "help" 48 | case RunModeLogin: 49 | return "login" 50 | case RunModeVersion: 51 | return "version" 52 | default: 53 | return "error" 54 | } 55 | } 56 | 57 | // Config contains the configuration options for nextcloud-exporter. 58 | type Config struct { 59 | ListenAddr string `yaml:"listenAddress"` 60 | Timeout time.Duration `yaml:"timeout"` 61 | ServerURL string `yaml:"server"` 62 | Username string `yaml:"username"` 63 | Password string `yaml:"password"` 64 | AuthToken string `yaml:"authToken"` 65 | TLSSkipVerify bool `yaml:"tlsSkipVerify"` 66 | Info InfoConfig `yaml:"info"` 67 | RunMode RunMode 68 | } 69 | 70 | // InfoConfig contains configuration related to what information is read from serverinfo. 71 | type InfoConfig struct { 72 | Apps bool `yaml:"apps"` 73 | Update bool `yaml:"update"` 74 | } 75 | 76 | var ( 77 | errValidateNoServerURL = errors.New("need to set a server URL") 78 | errValidateNoAuth = errors.New("need to either set username/password or a token") 79 | errValidateNoUsername = errors.New("need to provide a username") 80 | errValidateNoPassword = errors.New("need to provide a password") 81 | ) 82 | 83 | // Validate checks if the configuration contains all necessary parameters. 84 | func (c Config) Validate() error { 85 | if len(c.ServerURL) == 0 { 86 | return errValidateNoServerURL 87 | } 88 | 89 | if len(c.AuthToken) == 0 { 90 | if len(c.Username) == 0 && len(c.Password) == 0 { 91 | return errValidateNoAuth 92 | } 93 | 94 | if len(c.Username) == 0 { 95 | return errValidateNoUsername 96 | } 97 | 98 | if len(c.Password) == 0 { 99 | return errValidateNoPassword 100 | } 101 | } 102 | 103 | return nil 104 | } 105 | 106 | // Get loads the configuration. Flags, environment variables and configuration file are considered. 107 | func Get() (Config, error) { 108 | return parseConfig(os.Args, os.Getenv) 109 | } 110 | 111 | func parseConfig(args []string, envFunc func(string) string) (Config, error) { 112 | result, configFile, err := loadConfigFromFlags(args) 113 | if err != nil { 114 | return Config{}, fmt.Errorf("error parsing flags: %w", err) 115 | } 116 | 117 | if configFile != "" { 118 | rawFile, err := loadConfigFromFile(configFile) 119 | if err != nil { 120 | return Config{}, fmt.Errorf("error reading configuration file: %w", err) 121 | } 122 | 123 | result = mergeConfig(result, rawFile) 124 | } 125 | 126 | env, err := loadConfigFromEnv(envFunc) 127 | if err != nil { 128 | return Config{}, fmt.Errorf("error reading environment variables: %w", err) 129 | } 130 | result = mergeConfig(result, env) 131 | 132 | if strings.HasPrefix(result.Password, "@") { 133 | fileName := strings.TrimPrefix(result.Password, "@") 134 | password, err := readPasswordFile(fileName) 135 | if err != nil { 136 | return Config{}, fmt.Errorf("can not read password file: %w", err) 137 | } 138 | 139 | result.Password = password 140 | } 141 | 142 | if strings.HasPrefix(result.AuthToken, "@") { 143 | fileName := strings.TrimPrefix(result.AuthToken, "@") 144 | authToken, err := readPasswordFile(fileName) 145 | if err != nil { 146 | return Config{}, fmt.Errorf("can not read token file: %w", err) 147 | } 148 | 149 | result.AuthToken = authToken 150 | } 151 | 152 | return result, nil 153 | } 154 | 155 | func defaultConfig() Config { 156 | return Config{ 157 | ListenAddr: ":9205", 158 | Timeout: 5 * time.Second, 159 | } 160 | } 161 | 162 | func loadConfigFromFlags(args []string) (result Config, configFile string, err error) { 163 | defaults := defaultConfig() 164 | 165 | flags := pflag.NewFlagSet(args[0], pflag.ContinueOnError) 166 | flags.StringVarP(&configFile, "config-file", "c", "", "Path to YAML configuration file.") 167 | flags.StringVarP(&result.ListenAddr, "addr", "a", defaults.ListenAddr, "Address to listen on for connections.") 168 | flags.DurationVarP(&result.Timeout, "timeout", "t", defaults.Timeout, "Timeout for getting server info document.") 169 | flags.StringVarP(&result.ServerURL, "server", "s", "", "URL to Nextcloud server.") 170 | flags.StringVarP(&result.Username, "username", "u", defaults.Username, "Username for connecting to Nextcloud.") 171 | flags.StringVarP(&result.Password, "password", "p", defaults.Password, "Password for connecting to Nextcloud.") 172 | flags.StringVar(&result.AuthToken, "auth-token", defaults.AuthToken, "Authentication token. Can replace username and password when using Nextcloud 22 or newer.") 173 | flags.BoolVar(&result.TLSSkipVerify, "tls-skip-verify", defaults.TLSSkipVerify, "Skip certificate verification of Nextcloud server.") 174 | flags.BoolVar(&result.Info.Apps, "enable-info-apps", defaults.Info.Apps, "Enable gathering of apps-related metrics.") 175 | flags.BoolVar(&result.Info.Update, "enable-info-update", defaults.Info.Update, "Enable metric showing system update availability.") 176 | modeLogin := flags.Bool("login", false, "Use interactive login to create app password.") 177 | modeVersion := flags.BoolP("version", "V", false, "Show version information and exit.") 178 | 179 | if err := flags.Parse(args[1:]); err != nil { 180 | if err == pflag.ErrHelp { 181 | return Config{ 182 | RunMode: RunModeHelp, 183 | }, "", nil 184 | } 185 | 186 | return Config{}, "", err 187 | } 188 | 189 | if *modeVersion { 190 | return Config{ 191 | RunMode: RunModeVersion, 192 | }, "", nil 193 | } 194 | 195 | if *modeLogin { 196 | result.RunMode = RunModeLogin 197 | } 198 | 199 | return result, configFile, nil 200 | } 201 | 202 | func loadConfigFromFile(fileName string) (Config, error) { 203 | file, err := os.Open(fileName) 204 | if err != nil { 205 | return Config{}, err 206 | } 207 | 208 | var result Config 209 | if err := yaml.NewDecoder(file).Decode(&result); err != nil { 210 | return Config{}, err 211 | } 212 | 213 | return result, nil 214 | } 215 | 216 | func loadConfigFromEnv(getEnv func(string) string) (Config, error) { 217 | tlsSkipVerify := false 218 | if rawValue := getEnv(envTLSSkipVerify); rawValue != "" { 219 | value, err := strconv.ParseBool(rawValue) 220 | if err != nil { 221 | return Config{}, fmt.Errorf("can not parse value for %q: %s", envTLSSkipVerify, rawValue) 222 | } 223 | tlsSkipVerify = value 224 | } 225 | 226 | infoApps := false 227 | if rawValue := getEnv(envInfoApps); rawValue != "" { 228 | value, err := strconv.ParseBool(rawValue) 229 | if err != nil { 230 | return Config{}, fmt.Errorf("can not parse value for %q: %s", envInfoApps, rawValue) 231 | } 232 | infoApps = value 233 | } 234 | 235 | infoUpdate := false 236 | if rawValue := getEnv(envInfoUpdate); rawValue != "" { 237 | value, err := strconv.ParseBool(rawValue) 238 | if err != nil { 239 | return Config{}, fmt.Errorf("can not parse value for %q: %s", envInfoUpdate, rawValue) 240 | } 241 | infoUpdate = value 242 | } 243 | 244 | result := Config{ 245 | ListenAddr: getEnv(envListenAddress), 246 | ServerURL: getEnv(envServerURL), 247 | Username: getEnv(envUsername), 248 | Password: getEnv(envPassword), 249 | AuthToken: getEnv(envAuthToken), 250 | TLSSkipVerify: tlsSkipVerify, 251 | Info: InfoConfig{ 252 | Apps: infoApps, 253 | Update: infoUpdate, 254 | }, 255 | } 256 | 257 | if raw := getEnv(envTimeout); raw != "" { 258 | value, err := time.ParseDuration(raw) 259 | if err != nil { 260 | return Config{}, err 261 | } 262 | 263 | result.Timeout = value 264 | } 265 | 266 | return result, nil 267 | } 268 | 269 | func mergeConfig(base, override Config) Config { 270 | result := base 271 | if override.ListenAddr != "" { 272 | result.ListenAddr = override.ListenAddr 273 | } 274 | 275 | if override.ServerURL != "" { 276 | result.ServerURL = override.ServerURL 277 | } 278 | 279 | if override.Username != "" { 280 | result.Username = override.Username 281 | } 282 | 283 | if override.Password != "" { 284 | result.Password = override.Password 285 | } 286 | 287 | if override.AuthToken != "" { 288 | result.AuthToken = override.AuthToken 289 | } 290 | 291 | if override.Timeout != 0 { 292 | result.Timeout = override.Timeout 293 | } 294 | 295 | if override.TLSSkipVerify { 296 | result.TLSSkipVerify = override.TLSSkipVerify 297 | } 298 | 299 | if override.Info.Apps { 300 | result.Info.Apps = override.Info.Apps 301 | } 302 | 303 | if override.Info.Update { 304 | result.Info.Update = override.Info.Update 305 | } 306 | 307 | return result 308 | } 309 | 310 | func readPasswordFile(fileName string) (string, error) { 311 | bytes, err := os.ReadFile(fileName) 312 | if err != nil { 313 | return "", err 314 | } 315 | 316 | return strings.TrimSuffix(string(bytes), "\n"), nil 317 | } 318 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | "time" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/xperimental/nextcloud-exporter/internal/testutil" 10 | ) 11 | 12 | func testEnv(env map[string]string) func(string) string { 13 | return func(key string) string { 14 | return env[key] 15 | } 16 | } 17 | 18 | func TestConfig(t *testing.T) { 19 | defaults := defaultConfig() 20 | tt := []struct { 21 | desc string 22 | args []string 23 | env map[string]string 24 | wantErr error 25 | wantConfig Config 26 | }{ 27 | { 28 | desc: "flags", 29 | args: []string{ 30 | "test", 31 | "--addr", 32 | "127.0.0.1:9205", 33 | "--timeout", 34 | "30s", 35 | "--server", 36 | "http://localhost", 37 | "--username", 38 | "testuser", 39 | "--password", 40 | "testpass", 41 | }, 42 | env: map[string]string{}, 43 | wantErr: nil, 44 | wantConfig: Config{ 45 | ListenAddr: "127.0.0.1:9205", 46 | Timeout: 30 * time.Second, 47 | ServerURL: "http://localhost", 48 | Username: "testuser", 49 | Password: "testpass", 50 | TLSSkipVerify: false, 51 | }, 52 | }, 53 | { 54 | desc: "password from file", 55 | args: []string{ 56 | "test", 57 | "--server", 58 | "http://localhost", 59 | "--username", 60 | "testuser", 61 | "--password", 62 | "@testdata/password", 63 | }, 64 | env: map[string]string{}, 65 | wantErr: nil, 66 | wantConfig: Config{ 67 | ListenAddr: defaults.ListenAddr, 68 | Timeout: defaults.Timeout, 69 | ServerURL: "http://localhost", 70 | Username: "testuser", 71 | Password: "testpass", 72 | TLSSkipVerify: false, 73 | }, 74 | }, 75 | { 76 | desc: "config from file", 77 | args: []string{ 78 | "test", 79 | "--config-file", 80 | "testdata/all.yml", 81 | }, 82 | env: map[string]string{}, 83 | wantErr: nil, 84 | wantConfig: Config{ 85 | ListenAddr: "127.0.0.10:9205", 86 | Timeout: 10 * time.Second, 87 | ServerURL: "http://localhost", 88 | Username: "testuser", 89 | Password: "testpass", 90 | TLSSkipVerify: false, 91 | }, 92 | }, 93 | { 94 | desc: "config and password from file", 95 | args: []string{ 96 | "test", 97 | "--config-file", 98 | "testdata/passwordfile.yml", 99 | }, 100 | env: map[string]string{}, 101 | wantErr: nil, 102 | wantConfig: Config{ 103 | ListenAddr: "127.0.0.10:9205", 104 | Timeout: 10 * time.Second, 105 | ServerURL: "http://localhost", 106 | Username: "testuser", 107 | Password: "testpass", 108 | TLSSkipVerify: false, 109 | }, 110 | }, 111 | { 112 | desc: "don't check tls certificates", 113 | args: []string{ 114 | "test", 115 | "--tls-skip-verify", 116 | "true", 117 | }, 118 | env: map[string]string{}, 119 | wantErr: nil, 120 | wantConfig: Config{ 121 | ListenAddr: ":9205", 122 | Timeout: 5 * time.Second, 123 | ServerURL: "", 124 | Username: "", 125 | Password: "", 126 | TLSSkipVerify: true, 127 | }, 128 | }, 129 | { 130 | desc: "env config", 131 | args: []string{ 132 | "test", 133 | }, 134 | env: map[string]string{ 135 | envListenAddress: "127.0.0.11:9205", 136 | envTimeout: "15s", 137 | envServerURL: "http://localhost", 138 | envUsername: "testuser", 139 | envPassword: "testpass", 140 | envTLSSkipVerify: "true", 141 | }, 142 | wantErr: nil, 143 | wantConfig: Config{ 144 | ListenAddr: "127.0.0.11:9205", 145 | Timeout: 15 * time.Second, 146 | ServerURL: "http://localhost", 147 | Username: "testuser", 148 | Password: "testpass", 149 | TLSSkipVerify: true, 150 | }, 151 | }, 152 | { 153 | desc: "minimal env", 154 | args: []string{ 155 | "test", 156 | }, 157 | env: map[string]string{ 158 | envServerURL: "http://localhost", 159 | envUsername: "testuser", 160 | envPassword: "testpass", 161 | }, 162 | wantErr: nil, 163 | wantConfig: Config{ 164 | ListenAddr: defaults.ListenAddr, 165 | Timeout: defaults.Timeout, 166 | ServerURL: "http://localhost", 167 | Username: "testuser", 168 | Password: "testpass", 169 | TLSSkipVerify: false, 170 | }, 171 | }, 172 | { 173 | desc: "auth token env, skip apps", 174 | args: []string{ 175 | "test", 176 | }, 177 | env: map[string]string{ 178 | envServerURL: "http://localhost", 179 | envAuthToken: "auth-token", 180 | envInfoApps: "true", 181 | }, 182 | wantErr: nil, 183 | wantConfig: Config{ 184 | ListenAddr: defaults.ListenAddr, 185 | Timeout: defaults.Timeout, 186 | ServerURL: "http://localhost", 187 | AuthToken: "auth-token", 188 | Info: InfoConfig{ 189 | Apps: true, 190 | }, 191 | }, 192 | }, 193 | { 194 | desc: "token file", 195 | args: []string{ 196 | "test", 197 | "--config-file", 198 | "testdata/authtoken.yml", 199 | }, 200 | env: map[string]string{}, 201 | wantErr: nil, 202 | wantConfig: Config{ 203 | ListenAddr: defaults.ListenAddr, 204 | Timeout: defaults.Timeout, 205 | ServerURL: "http://localhost", 206 | Username: "", 207 | Password: "", 208 | AuthToken: "auth-token", 209 | TLSSkipVerify: false, 210 | }, 211 | }, 212 | { 213 | desc: "show help", 214 | args: []string{ 215 | "test", 216 | "--help", 217 | }, 218 | env: map[string]string{}, 219 | wantErr: nil, 220 | wantConfig: Config{ 221 | RunMode: RunModeHelp, 222 | }, 223 | }, 224 | { 225 | desc: "show version", 226 | args: []string{ 227 | "test", 228 | "--version", 229 | }, 230 | env: map[string]string{}, 231 | wantErr: nil, 232 | wantConfig: Config{ 233 | RunMode: RunModeVersion, 234 | }, 235 | }, 236 | { 237 | desc: "login mode", 238 | args: []string{ 239 | "test", 240 | "--login", 241 | "--server", 242 | "http://localhost", 243 | }, 244 | env: map[string]string{}, 245 | wantErr: nil, 246 | wantConfig: Config{ 247 | ListenAddr: defaults.ListenAddr, 248 | Timeout: defaults.Timeout, 249 | ServerURL: "http://localhost", 250 | RunMode: RunModeLogin, 251 | }, 252 | }, 253 | { 254 | desc: "wrongflag", 255 | args: []string{ 256 | "test", 257 | "--unknown", 258 | }, 259 | env: map[string]string{}, 260 | wantErr: errors.New("error parsing flags: unknown flag: --unknown"), 261 | }, 262 | { 263 | desc: "env wrong duration", 264 | args: []string{ 265 | "test", 266 | }, 267 | env: map[string]string{ 268 | "NEXTCLOUD_TIMEOUT": "unknown", 269 | }, 270 | wantErr: errors.New("error reading environment variables: time: invalid duration \"unknown\""), 271 | }, 272 | { 273 | desc: "password from file error", 274 | args: []string{ 275 | "test", 276 | "--server", 277 | "http://localhost", 278 | "--password", 279 | "@testdata/notfound", 280 | }, 281 | env: map[string]string{}, 282 | wantErr: errors.New("can not read password file: open testdata/notfound: no such file or directory"), 283 | }, 284 | { 285 | desc: "config from file error", 286 | args: []string{ 287 | "test", 288 | "--config-file", 289 | "testdata/notfound.yml", 290 | }, 291 | env: map[string]string{}, 292 | wantErr: errors.New("error reading configuration file: open testdata/notfound.yml: no such file or directory"), 293 | }, 294 | { 295 | desc: "fail parsing tlsSkipVerify env", 296 | args: []string{ 297 | "test", 298 | }, 299 | env: map[string]string{ 300 | envTLSSkipVerify: "invalid", 301 | }, 302 | wantErr: errors.New(`error reading environment variables: can not parse value for "NEXTCLOUD_TLS_SKIP_VERIFY": invalid`), 303 | }, 304 | { 305 | desc: "fail parsing infoSkipApps env", 306 | args: []string{ 307 | "test", 308 | }, 309 | env: map[string]string{ 310 | envInfoApps: "invalid", 311 | }, 312 | wantErr: errors.New(`error reading environment variables: can not parse value for "NEXTCLOUD_INFO_APPS": invalid`), 313 | }, 314 | } 315 | 316 | for _, tc := range tt { 317 | tc := tc 318 | t.Run(tc.desc, func(t *testing.T) { 319 | t.Parallel() 320 | 321 | config, err := parseConfig(tc.args, testEnv(tc.env)) 322 | 323 | if !testutil.EqualErrorMessage(err, tc.wantErr) { 324 | t.Errorf("got error %q, want %q", err, tc.wantErr) 325 | } 326 | 327 | if err != nil { 328 | return 329 | } 330 | 331 | if diff := cmp.Diff(config, tc.wantConfig); diff != "" { 332 | t.Errorf("config differs: -got +want\n%s", diff) 333 | } 334 | }) 335 | } 336 | } 337 | 338 | func TestConfigValidate(t *testing.T) { 339 | tt := []struct { 340 | desc string 341 | config Config 342 | wantErr error 343 | }{ 344 | { 345 | desc: "minimal", 346 | config: Config{ 347 | ServerURL: "https://example.com", 348 | Username: "exporter", 349 | Password: "testpass", 350 | }, 351 | wantErr: nil, 352 | }, 353 | { 354 | desc: "auth token", 355 | config: Config{ 356 | ServerURL: "https://example.com", 357 | AuthToken: "auth-token", 358 | }, 359 | wantErr: nil, 360 | }, 361 | { 362 | desc: "no url", 363 | config: Config{ 364 | Username: "exporter", 365 | Password: "testpass", 366 | }, 367 | wantErr: errValidateNoServerURL, 368 | }, 369 | { 370 | desc: "auth help", 371 | config: Config{ 372 | ServerURL: "https://example.com", 373 | }, 374 | wantErr: errValidateNoAuth, 375 | }, 376 | { 377 | desc: "no username", 378 | config: Config{ 379 | ServerURL: "https://example.com", 380 | Password: "testpass", 381 | }, 382 | wantErr: errValidateNoUsername, 383 | }, 384 | { 385 | desc: "no password", 386 | config: Config{ 387 | ServerURL: "https://example.com", 388 | Username: "exporter", 389 | }, 390 | wantErr: errValidateNoPassword, 391 | }, 392 | } 393 | 394 | for _, tc := range tt { 395 | tc := tc 396 | t.Run(tc.desc, func(t *testing.T) { 397 | t.Parallel() 398 | 399 | err := tc.config.Validate() 400 | 401 | if !testutil.EqualErrorMessage(err, tc.wantErr) { 402 | t.Errorf("got error %q, want %q", err, tc.wantErr) 403 | } 404 | }) 405 | } 406 | } 407 | -------------------------------------------------------------------------------- /internal/config/testdata/all.yml: -------------------------------------------------------------------------------- 1 | listenAddress: 127.0.0.10:9205 2 | timeout: 10s 3 | server: http://localhost 4 | username: testuser 5 | password: testpass 6 | -------------------------------------------------------------------------------- /internal/config/testdata/authtoken.yml: -------------------------------------------------------------------------------- 1 | server: http://localhost 2 | authToken: auth-token 3 | -------------------------------------------------------------------------------- /internal/config/testdata/password: -------------------------------------------------------------------------------- 1 | testpass 2 | -------------------------------------------------------------------------------- /internal/config/testdata/passwordfile.yml: -------------------------------------------------------------------------------- 1 | listenAddress: 127.0.0.10:9205 2 | timeout: 10s 3 | server: http://localhost 4 | username: testuser 5 | password: "@testdata/password" 6 | -------------------------------------------------------------------------------- /internal/login/login.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "crypto/tls" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strconv" 10 | "strings" 11 | "time" 12 | 13 | "github.com/sirupsen/logrus" 14 | ) 15 | 16 | const ( 17 | statusPath = "/status.php" 18 | minimumMajorVersion = 16 19 | 20 | loginPath = "/index.php/login/v2" 21 | 22 | pollInterval = time.Second 23 | contentType = "application/x-www-form-urlencoded" 24 | ) 25 | 26 | type loginInfo struct { 27 | LoginURL string `json:"login"` 28 | PollInfo pollInfo `json:"poll"` 29 | } 30 | 31 | type pollInfo struct { 32 | Token string `json:"token"` 33 | Endpoint string `json:"endpoint"` 34 | } 35 | 36 | type passwordInfo struct { 37 | Server string `json:"server"` 38 | LoginName string `json:"loginName"` 39 | AppPassword string `json:"appPassword"` 40 | } 41 | 42 | // Login contains the login information gathered during the login session. 43 | type Login struct { 44 | Username string 45 | Password string 46 | } 47 | 48 | // Client can be used to start an interactive login session with a Nextcloud server. 49 | type Client struct { 50 | log logrus.FieldLogger 51 | userAgent string 52 | serverURL string 53 | 54 | client *http.Client 55 | sleepFunc func() 56 | } 57 | 58 | // Init creates a new LoginClient. The session can then be started using StartInteractive. 59 | func Init(log logrus.FieldLogger, userAgent, serverURL string, tlsSkipVerify bool) *Client { 60 | return &Client{ 61 | log: log, 62 | userAgent: userAgent, 63 | serverURL: serverURL, 64 | 65 | client: &http.Client{ 66 | Timeout: 30 * time.Second, 67 | Transport: &http.Transport{ 68 | TLSClientConfig: &tls.Config{ 69 | InsecureSkipVerify: tlsSkipVerify, 70 | }, 71 | }, 72 | }, 73 | sleepFunc: func() { time.Sleep(pollInterval) }, 74 | } 75 | } 76 | 77 | // StartInteractive starts an interactive login session for the Nextcloud server and user. 78 | // The end-result of this is an app-password for the exporter which should be used instead of a user password. 79 | func (c *Client) StartInteractive() error { 80 | version, err := c.getMajorVersion() 81 | if err != nil { 82 | return fmt.Errorf("error getting version: %w", err) 83 | } 84 | 85 | if version < minimumMajorVersion { 86 | return fmt.Errorf("Nextcloud version too old for login: %d Minimum: %d", version, minimumMajorVersion) 87 | } 88 | 89 | info, err := c.getLoginInfo() 90 | if err != nil { 91 | return fmt.Errorf("error getting login info: %w", err) 92 | } 93 | c.log.Infof("Please open this URL in a browser: %s", info.LoginURL) 94 | c.log.Infoln("Waiting for login ... (Ctrl-C to abort)") 95 | 96 | login, err := c.pollLogin(info.PollInfo) 97 | if err != nil { 98 | return fmt.Errorf("error during poll: %w", err) 99 | } 100 | 101 | c.log.Infof("Username: %s", login.Username) 102 | c.log.Infof("Password: %s", login.Password) 103 | return nil 104 | } 105 | 106 | func (c *Client) doRequest(method, url string, body io.Reader) (*http.Response, error) { 107 | req, err := http.NewRequest(method, url, body) 108 | if err != nil { 109 | return nil, fmt.Errorf("can not create request: %w", err) 110 | } 111 | req.Header.Set("User-Agent", c.userAgent) 112 | 113 | if body != nil { 114 | req.Header.Set("Content-Type", contentType) 115 | } 116 | 117 | res, err := c.client.Do(req) 118 | if err != nil { 119 | return nil, fmt.Errorf("error connecting: %w", err) 120 | } 121 | 122 | return res, nil 123 | } 124 | 125 | func (c *Client) getMajorVersion() (int, error) { 126 | statusURL := c.serverURL + statusPath 127 | res, err := c.doRequest(http.MethodGet, statusURL, nil) 128 | if err != nil { 129 | return 0, fmt.Errorf("error connecting: %w", err) 130 | } 131 | defer res.Body.Close() 132 | 133 | if res.StatusCode != http.StatusOK { 134 | return 0, fmt.Errorf("non-ok status: %d", res.StatusCode) 135 | } 136 | 137 | var status struct { 138 | Version string `json:"version"` 139 | } 140 | if err := json.NewDecoder(res.Body).Decode(&status); err != nil { 141 | return 0, fmt.Errorf("error decoding status: %w", err) 142 | } 143 | 144 | tokens := strings.SplitN(status.Version, ".", 2) 145 | version, err := strconv.Atoi(tokens[0]) 146 | if err != nil { 147 | return 0, fmt.Errorf("can not parse %q as version: %w", status.Version, err) 148 | } 149 | 150 | return version, nil 151 | } 152 | 153 | func (c *Client) getLoginInfo() (loginInfo, error) { 154 | loginURL := c.serverURL + loginPath 155 | res, err := c.doRequest(http.MethodPost, loginURL, nil) 156 | if err != nil { 157 | return loginInfo{}, fmt.Errorf("error connecting: %w", err) 158 | } 159 | defer res.Body.Close() 160 | 161 | if res.StatusCode != http.StatusOK { 162 | return loginInfo{}, fmt.Errorf("non-ok status: %d", res.StatusCode) 163 | } 164 | 165 | var result loginInfo 166 | if err := json.NewDecoder(res.Body).Decode(&result); err != nil { 167 | return loginInfo{}, fmt.Errorf("error decoding login info: %w", err) 168 | } 169 | 170 | return result, nil 171 | } 172 | 173 | func (c *Client) pollLogin(info pollInfo) (Login, error) { 174 | body := fmt.Sprintf("token=%s", info.Token) 175 | c.log.Debugf("poll endpoint: %s", info.Endpoint) 176 | 177 | for { 178 | c.sleepFunc() 179 | reader := strings.NewReader(body) 180 | res, err := c.doRequest(http.MethodPost, info.Endpoint, reader) 181 | if err != nil { 182 | continue 183 | } 184 | defer res.Body.Close() 185 | 186 | if res.StatusCode != http.StatusOK { 187 | c.log.Debugf("poll status: %d", res.StatusCode) 188 | continue 189 | } 190 | 191 | var password passwordInfo 192 | if err := json.NewDecoder(res.Body).Decode(&password); err != nil { 193 | return Login{}, fmt.Errorf("error decoding password info: %w", err) 194 | } 195 | 196 | return Login{ 197 | Username: password.LoginName, 198 | Password: password.AppPassword, 199 | }, nil 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /internal/login/login_test.go: -------------------------------------------------------------------------------- 1 | package login 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/google/go-cmp/cmp" 11 | "github.com/sirupsen/logrus" 12 | "github.com/xperimental/nextcloud-exporter/internal/testutil" 13 | ) 14 | 15 | func testClient(url string) *Client { 16 | return &Client{ 17 | log: logrus.New(), 18 | client: &http.Client{}, 19 | serverURL: url, 20 | sleepFunc: func() {}, 21 | } 22 | } 23 | 24 | func testHandler(status int, body string) http.Handler { 25 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 26 | w.WriteHeader(status) 27 | fmt.Fprintln(w, body) 28 | }) 29 | } 30 | 31 | func TestGetMajorVersion(t *testing.T) { 32 | tt := []struct { 33 | desc string 34 | testHandler http.Handler 35 | wantErr error 36 | wantVersion int 37 | }{ 38 | { 39 | desc: "success", 40 | testHandler: testHandler(http.StatusOK, `{"version": "18.0.2"}`), 41 | wantVersion: 18, 42 | }, 43 | { 44 | desc: "parse error", 45 | testHandler: testHandler(http.StatusOK, ``), 46 | wantErr: errors.New("error decoding status: EOF"), 47 | }, 48 | { 49 | desc: "error version", 50 | testHandler: testHandler(http.StatusOK, `{"version": "unparseable"}`), 51 | wantErr: errors.New(`can not parse "unparseable" as version: strconv.Atoi: parsing "unparseable": invalid syntax`), 52 | }, 53 | { 54 | desc: "error http", 55 | testHandler: testHandler(http.StatusInternalServerError, "test error"), 56 | wantErr: errors.New("non-ok status: 500"), 57 | }, 58 | } 59 | 60 | for _, tc := range tt { 61 | tc := tc 62 | t.Run(tc.desc, func(t *testing.T) { 63 | t.Parallel() 64 | 65 | s := httptest.NewServer(tc.testHandler) 66 | defer s.Close() 67 | c := testClient(s.URL) 68 | 69 | version, err := c.getMajorVersion() 70 | 71 | if !testutil.EqualErrorMessage(err, tc.wantErr) { 72 | t.Errorf("got error %q, want %q", err, tc.wantErr) 73 | } 74 | 75 | if err != nil { 76 | return 77 | } 78 | 79 | if version != tc.wantVersion { 80 | t.Errorf("got version %d, want %d", version, tc.wantVersion) 81 | } 82 | }) 83 | } 84 | } 85 | 86 | func TestGetLoginInfo(t *testing.T) { 87 | tt := []struct { 88 | desc string 89 | testHandler http.Handler 90 | wantErr error 91 | wantInfo loginInfo 92 | }{ 93 | { 94 | desc: "success", 95 | testHandler: testHandler(http.StatusOK, `{"login": "http://localhost/login", "poll": {"token": "token", "endpoint": "http://localhost/poll"}}`), 96 | wantInfo: loginInfo{ 97 | LoginURL: "http://localhost/login", 98 | PollInfo: pollInfo{ 99 | Token: "token", 100 | Endpoint: "http://localhost/poll", 101 | }, 102 | }, 103 | }, 104 | { 105 | desc: "parse error", 106 | testHandler: testHandler(http.StatusOK, ``), 107 | wantErr: errors.New("error decoding login info: EOF"), 108 | }, 109 | { 110 | desc: "error http", 111 | testHandler: testHandler(http.StatusInternalServerError, "test error"), 112 | wantErr: errors.New("non-ok status: 500"), 113 | }, 114 | } 115 | 116 | for _, tc := range tt { 117 | tc := tc 118 | t.Run(tc.desc, func(t *testing.T) { 119 | t.Parallel() 120 | 121 | s := httptest.NewServer(tc.testHandler) 122 | defer s.Close() 123 | c := testClient(s.URL) 124 | 125 | info, err := c.getLoginInfo() 126 | 127 | if !testutil.EqualErrorMessage(err, tc.wantErr) { 128 | t.Errorf("got error %q, want %q", err, tc.wantErr) 129 | } 130 | 131 | if err != nil { 132 | return 133 | } 134 | 135 | if diff := cmp.Diff(info, tc.wantInfo); diff != "" { 136 | t.Errorf("info differs: -got +want\n%s", diff) 137 | } 138 | }) 139 | } 140 | } 141 | 142 | func TestPollPassword(t *testing.T) { 143 | tt := []struct { 144 | desc string 145 | testHandler http.Handler 146 | pollInfo pollInfo 147 | wantErr error 148 | wantLogin Login 149 | }{ 150 | { 151 | desc: "success", 152 | testHandler: testHandler(http.StatusOK, `{"loginName": "username", "appPassword": "password"}`), 153 | wantLogin: Login{ 154 | Username: "username", 155 | Password: "password", 156 | }, 157 | }, 158 | { 159 | desc: "parse error", 160 | testHandler: testHandler(http.StatusOK, ``), 161 | wantErr: errors.New("error decoding password info: EOF"), 162 | }, 163 | } 164 | 165 | for _, tc := range tt { 166 | tc := tc 167 | t.Run(tc.desc, func(t *testing.T) { 168 | t.Parallel() 169 | 170 | s := httptest.NewServer(tc.testHandler) 171 | defer s.Close() 172 | tc.pollInfo.Endpoint = s.URL 173 | 174 | c := testClient("") 175 | login, err := c.pollLogin(tc.pollInfo) 176 | 177 | if !testutil.EqualErrorMessage(err, tc.wantErr) { 178 | t.Errorf("got error %q, want %q", err, tc.wantErr) 179 | } 180 | 181 | if err != nil { 182 | return 183 | } 184 | 185 | if diff := cmp.Diff(login, tc.wantLogin); diff != "" { 186 | t.Errorf("login differs: -got +want\n%s", diff) 187 | } 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /internal/metrics/collector.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/prometheus/client_golang/prometheus" 7 | "github.com/sirupsen/logrus" 8 | "github.com/xperimental/nextcloud-exporter/internal/client" 9 | "github.com/xperimental/nextcloud-exporter/serverinfo" 10 | ) 11 | 12 | const ( 13 | metricPrefix = "nextcloud_" 14 | 15 | labelErrorCauseOther = "other" 16 | labelErrorCauseAuth = "auth" 17 | labelErrorCauseRatelimit = "ratelimit" 18 | ) 19 | 20 | var ( 21 | systemInfoDesc = prometheus.NewDesc( 22 | metricPrefix+"system_info", 23 | "Contains meta information about Nextcloud as labels. Value is always 1.", 24 | []string{"version"}, nil) 25 | systemUpdateAvailableDesc = prometheus.NewDesc( 26 | metricPrefix+"system_update_available", 27 | "Contains information whether a system update is available (0 = no, 1 = yes). The available_version label contains the latest available nextcloud version, whereas the version label contains the current installed nextcloud version.", 28 | []string{"version", "available_version"}, nil) 29 | appsInstalledDesc = prometheus.NewDesc( 30 | metricPrefix+"apps_installed_total", 31 | "Number of currently installed apps", 32 | nil, nil) 33 | appsUpdatesDesc = prometheus.NewDesc( 34 | metricPrefix+"apps_updates_available_total", 35 | "Number of apps that have available updates", 36 | nil, nil) 37 | usersDesc = prometheus.NewDesc( 38 | metricPrefix+"users_total", 39 | "Number of users of the instance.", 40 | nil, nil) 41 | filesDesc = prometheus.NewDesc( 42 | metricPrefix+"files_total", 43 | "Number of files served by the instance.", 44 | nil, nil) 45 | freeSpaceDesc = prometheus.NewDesc( 46 | metricPrefix+"free_space_bytes", 47 | "Free disk space in data directory in bytes.", 48 | nil, nil) 49 | sharesDesc = prometheus.NewDesc( 50 | metricPrefix+"shares_total", 51 | "Number of shares by type.", 52 | []string{"type"}, nil) 53 | federationsDesc = prometheus.NewDesc( 54 | metricPrefix+"shares_federated_total", 55 | "Number of federated shares by direction.", 56 | []string{"direction"}, nil) 57 | activeUsersDesc = prometheus.NewDesc( 58 | metricPrefix+"active_users_total", 59 | "Number of active users for the last five minutes.", 60 | nil, nil) 61 | hourlyActiveUsersDesc = prometheus.NewDesc( 62 | metricPrefix+"active_users_hourly_total", 63 | "Number of active users in the last hour.", 64 | nil, nil) 65 | dailyActiveUsersDesc = prometheus.NewDesc( 66 | metricPrefix+"active_users_daily_total", 67 | "Number of active users in the last 24 hours.", 68 | nil, nil) 69 | phpInfoDesc = prometheus.NewDesc( 70 | metricPrefix+"php_info", 71 | "Contains meta information about PHP as labels. Value is always 1.", 72 | []string{"version"}, nil) 73 | phpMemoryLimitDesc = prometheus.NewDesc( 74 | metricPrefix+"php_memory_limit_bytes", 75 | "Configured PHP memory limit in bytes.", 76 | nil, nil) 77 | phpMaxUploadSizeDesc = prometheus.NewDesc( 78 | metricPrefix+"php_upload_max_size_bytes", 79 | "Configured maximum upload size in bytes.", 80 | nil, nil) 81 | databaseInfoDesc = prometheus.NewDesc( 82 | metricPrefix+"database_info", 83 | "Contains meta information about the database as labels. Value is always 1.", 84 | []string{"version", "type"}, nil) 85 | databaseSizeDesc = prometheus.NewDesc( 86 | metricPrefix+"database_size_bytes", 87 | "Size of database in bytes as reported from engine.", 88 | nil, nil) 89 | ) 90 | 91 | type nextcloudCollector struct { 92 | log logrus.FieldLogger 93 | infoClient client.InfoClient 94 | appsMetrics bool 95 | updateMetrics bool 96 | 97 | upMetric prometheus.Gauge 98 | scrapeErrorsMetric *prometheus.CounterVec 99 | } 100 | 101 | func RegisterCollector(log logrus.FieldLogger, infoClient client.InfoClient, appsMetrics bool, updateMetrics bool) error { 102 | c := &nextcloudCollector{ 103 | log: log, 104 | infoClient: infoClient, 105 | appsMetrics: appsMetrics, 106 | updateMetrics: updateMetrics, 107 | 108 | upMetric: prometheus.NewGauge(prometheus.GaugeOpts{ 109 | Name: metricPrefix + "up", 110 | Help: "Indicates if the metrics could be scraped by the exporter.", 111 | }), 112 | scrapeErrorsMetric: prometheus.NewCounterVec(prometheus.CounterOpts{ 113 | Name: metricPrefix + "scrape_errors_total", 114 | Help: "Counts the number of scrape errors by this collector.", 115 | }, []string{"cause"}), 116 | } 117 | 118 | return prometheus.Register(c) 119 | } 120 | 121 | func (c *nextcloudCollector) Describe(ch chan<- *prometheus.Desc) { 122 | c.upMetric.Describe(ch) 123 | c.scrapeErrorsMetric.Describe(ch) 124 | ch <- usersDesc 125 | ch <- filesDesc 126 | ch <- freeSpaceDesc 127 | ch <- sharesDesc 128 | ch <- federationsDesc 129 | ch <- activeUsersDesc 130 | ch <- hourlyActiveUsersDesc 131 | ch <- dailyActiveUsersDesc 132 | } 133 | 134 | func (c *nextcloudCollector) Collect(ch chan<- prometheus.Metric) { 135 | if err := c.collectNextcloud(ch); err != nil { 136 | c.log.Errorf("Error during scrape: %s", err) 137 | 138 | cause := labelErrorCauseOther 139 | if err == client.ErrNotAuthorized { 140 | cause = labelErrorCauseAuth 141 | } else if err == client.ErrRatelimit { 142 | cause = labelErrorCauseRatelimit 143 | } 144 | c.scrapeErrorsMetric.WithLabelValues(cause).Inc() 145 | c.upMetric.Set(0) 146 | } else { 147 | c.upMetric.Set(1) 148 | } 149 | 150 | c.upMetric.Collect(ch) 151 | c.scrapeErrorsMetric.Collect(ch) 152 | } 153 | 154 | func (c *nextcloudCollector) collectNextcloud(ch chan<- prometheus.Metric) error { 155 | status, err := c.infoClient() 156 | if err != nil { 157 | return err 158 | } 159 | 160 | return readMetrics(ch, status, c.appsMetrics, c.updateMetrics) 161 | } 162 | 163 | func readMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo, appsMetrics bool, updateMetrics bool) error { 164 | if err := collectSimpleMetrics(ch, status, appsMetrics); err != nil { 165 | return err 166 | } 167 | 168 | if updateMetrics { 169 | if err := collectUpdate(ch, status); err != nil { 170 | return err 171 | } 172 | } 173 | 174 | if err := collectShares(ch, status.Data.Nextcloud.Shares); err != nil { 175 | return err 176 | } 177 | 178 | if err := collectFederatedShares(ch, status.Data.Nextcloud.Shares); err != nil { 179 | return err 180 | } 181 | 182 | systemInfo := []string{ 183 | status.Data.Nextcloud.System.Version, 184 | } 185 | if err := collectInfoMetric(ch, systemInfoDesc, systemInfo); err != nil { 186 | return err 187 | } 188 | 189 | phpInfo := []string{ 190 | status.Data.Server.PHP.Version, 191 | } 192 | if err := collectInfoMetric(ch, phpInfoDesc, phpInfo); err != nil { 193 | return err 194 | } 195 | 196 | databaseInfo := []string{ 197 | status.Data.Server.Database.Version, 198 | status.Data.Server.Database.Type, 199 | } 200 | if err := collectInfoMetric(ch, databaseInfoDesc, databaseInfo); err != nil { 201 | return err 202 | } 203 | 204 | return nil 205 | } 206 | 207 | type simpleMetric struct { 208 | desc *prometheus.Desc 209 | value float64 210 | } 211 | 212 | func collectSimpleMetrics(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo, appsMetrics bool) error { 213 | metrics := []simpleMetric{ 214 | { 215 | desc: usersDesc, 216 | value: float64(status.Data.Nextcloud.Storage.Users), 217 | }, 218 | { 219 | desc: filesDesc, 220 | value: float64(status.Data.Nextcloud.Storage.Files), 221 | }, 222 | { 223 | desc: freeSpaceDesc, 224 | value: status.Data.Nextcloud.System.FreeSpace, 225 | }, 226 | { 227 | desc: activeUsersDesc, 228 | value: float64(status.Data.ActiveUsers.Last5Minutes), 229 | }, 230 | { 231 | desc: hourlyActiveUsersDesc, 232 | value: float64(status.Data.ActiveUsers.LastHour), 233 | }, 234 | { 235 | desc: dailyActiveUsersDesc, 236 | value: float64(status.Data.ActiveUsers.LastDay), 237 | }, 238 | { 239 | desc: phpMemoryLimitDesc, 240 | value: float64(status.Data.Server.PHP.MemoryLimit), 241 | }, 242 | { 243 | desc: phpMaxUploadSizeDesc, 244 | value: float64(status.Data.Server.PHP.UploadMaxFilesize), 245 | }, 246 | { 247 | desc: databaseSizeDesc, 248 | value: float64(status.Data.Server.Database.Size), 249 | }, 250 | } 251 | 252 | if appsMetrics { 253 | metrics = append(metrics, []simpleMetric{ 254 | { 255 | desc: appsInstalledDesc, 256 | value: float64(status.Data.Nextcloud.System.Apps.Installed), 257 | }, 258 | { 259 | desc: appsUpdatesDesc, 260 | value: float64(status.Data.Nextcloud.System.Apps.AvailableUpdates), 261 | }, 262 | }...) 263 | } 264 | 265 | for _, m := range metrics { 266 | metric, err := prometheus.NewConstMetric(m.desc, prometheus.GaugeValue, m.value) 267 | if err != nil { 268 | return fmt.Errorf("error creating metric for %s: %w", m.desc, err) 269 | } 270 | ch <- metric 271 | } 272 | 273 | return nil 274 | } 275 | 276 | func collectUpdate(ch chan<- prometheus.Metric, status *serverinfo.ServerInfo) error { 277 | systemInfo := status.Data.Nextcloud.System 278 | updateAvailableValue := 0.0 279 | 280 | // Fix small bug: its indicated as "true" even if there is no real update available. 281 | if systemInfo.Update.Available && systemInfo.Version != systemInfo.Update.AvailableVersion { 282 | updateAvailableValue = 1.0 283 | } 284 | 285 | metric, err := prometheus.NewConstMetric(systemUpdateAvailableDesc, prometheus.GaugeValue, updateAvailableValue, systemInfo.Version, systemInfo.Update.AvailableVersion) 286 | if err != nil { 287 | return fmt.Errorf("error creating metric for %s: %w", systemUpdateAvailableDesc, err) 288 | } 289 | ch <- metric 290 | 291 | return nil 292 | } 293 | 294 | func collectShares(ch chan<- prometheus.Metric, shares serverinfo.Shares) error { 295 | values := make(map[string]float64) 296 | values["user"] = float64(shares.SharesUser) 297 | values["group"] = float64(shares.SharesGroups) 298 | values["authlink"] = float64(shares.SharesLink - shares.SharesLinkNoPassword) 299 | values["link"] = float64(shares.SharesLink) 300 | values["mail"] = float64(shares.SharesMail) 301 | values["room"] = float64(shares.SharesRoom) 302 | 303 | return collectMap(ch, sharesDesc, values) 304 | } 305 | 306 | func collectFederatedShares(ch chan<- prometheus.Metric, shares serverinfo.Shares) error { 307 | values := make(map[string]float64) 308 | values["sent"] = float64(shares.FedSent) 309 | values["received"] = float64(shares.FedReceived) 310 | 311 | return collectMap(ch, federationsDesc, values) 312 | } 313 | 314 | func collectMap(ch chan<- prometheus.Metric, desc *prometheus.Desc, labelValueMap map[string]float64) error { 315 | for k, v := range labelValueMap { 316 | metric, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, v, k) 317 | if err != nil { 318 | return fmt.Errorf("error creating shares metric for %s: %w", k, err) 319 | } 320 | ch <- metric 321 | } 322 | 323 | return nil 324 | } 325 | 326 | func collectInfoMetric(ch chan<- prometheus.Metric, desc *prometheus.Desc, labelValues []string) error { 327 | metric, err := prometheus.NewConstMetric(desc, prometheus.GaugeValue, 1, labelValues...) 328 | if err != nil { 329 | return err 330 | } 331 | 332 | ch <- metric 333 | return nil 334 | } 335 | -------------------------------------------------------------------------------- /internal/metrics/info.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | ) 6 | 7 | func RegisterInfoMetric(version, gitCommit string) error { 8 | infoMetric := prometheus.NewGauge(prometheus.GaugeOpts{ 9 | Name: metricPrefix + "exporter_info", 10 | Help: "Information about the nextcloud-exporter.", 11 | ConstLabels: prometheus.Labels{ 12 | "version": version, 13 | "commit": gitCommit, 14 | }, 15 | }) 16 | infoMetric.Set(1) 17 | 18 | return prometheus.Register(infoMetric) 19 | } 20 | -------------------------------------------------------------------------------- /internal/testutil/testutil.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // EqualErrorMessage compares two errors by just comparing their messages. 8 | func EqualErrorMessage(a, b error) bool { 9 | aMsg := "" 10 | if a != nil { 11 | aMsg = a.Error() 12 | } 13 | 14 | bMsg := "" 15 | if b != nil { 16 | bMsg = b.Error() 17 | } 18 | 19 | return strings.Compare(aMsg, bMsg) == 0 20 | } 21 | -------------------------------------------------------------------------------- /internal/testutil/testutil_test.go: -------------------------------------------------------------------------------- 1 | package testutil 2 | 3 | import ( 4 | "errors" 5 | "testing" 6 | ) 7 | 8 | type testError struct{} 9 | 10 | func (e testError) Error() string { 11 | return "test message" 12 | } 13 | 14 | func TestEqualErrorMessage(t *testing.T) { 15 | tt := []struct { 16 | desc string 17 | a error 18 | b error 19 | wantEqual bool 20 | }{ 21 | { 22 | desc: "two nil", 23 | a: nil, 24 | b: nil, 25 | wantEqual: true, 26 | }, 27 | { 28 | desc: "a not nil", 29 | a: errors.New("error A"), 30 | b: nil, 31 | wantEqual: false, 32 | }, 33 | { 34 | desc: "b not nil", 35 | a: nil, 36 | b: errors.New("error B"), 37 | wantEqual: false, 38 | }, 39 | { 40 | desc: "both not nil", 41 | a: errors.New("error A"), 42 | b: errors.New("error B"), 43 | wantEqual: false, 44 | }, 45 | { 46 | desc: "equal message, same type", 47 | a: errors.New("test message"), 48 | b: errors.New("test message"), 49 | wantEqual: true, 50 | }, 51 | { 52 | desc: "equal message, different type", 53 | a: errors.New("test message"), 54 | b: testError{}, 55 | wantEqual: true, 56 | }, 57 | } 58 | 59 | for _, tc := range tt { 60 | tc := tc 61 | t.Run(tc.desc, func(t *testing.T) { 62 | t.Parallel() 63 | 64 | equal := EqualErrorMessage(tc.a, tc.b) 65 | if equal != tc.wantEqual { 66 | t.Errorf("got equal %v, want %v", equal, tc.wantEqual) 67 | } 68 | }) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/prometheus/client_golang/prometheus/promhttp" 9 | "github.com/sirupsen/logrus" 10 | "github.com/xperimental/nextcloud-exporter/internal/client" 11 | "github.com/xperimental/nextcloud-exporter/internal/config" 12 | "github.com/xperimental/nextcloud-exporter/internal/login" 13 | "github.com/xperimental/nextcloud-exporter/internal/metrics" 14 | "github.com/xperimental/nextcloud-exporter/serverinfo" 15 | ) 16 | 17 | var ( 18 | // Version contains the version as set during the build. 19 | Version = "" 20 | 21 | // GitCommit contains the git commit hash set during the build. 22 | GitCommit = "" 23 | 24 | log = &logrus.Logger{ 25 | Out: os.Stderr, 26 | Formatter: &logrus.TextFormatter{ 27 | DisableTimestamp: true, 28 | }, 29 | Hooks: make(logrus.LevelHooks), 30 | Level: logrus.InfoLevel, 31 | ExitFunc: os.Exit, 32 | ReportCaller: false, 33 | } 34 | ) 35 | 36 | func main() { 37 | cfg, err := config.Get() 38 | if err != nil { 39 | log.Fatalf("Error loading configuration: %s", err) 40 | } 41 | 42 | if cfg.RunMode == config.RunModeHelp { 43 | return 44 | } 45 | 46 | if cfg.RunMode == config.RunModeVersion { 47 | fmt.Println(Version) 48 | return 49 | } 50 | 51 | log.Infof("nextcloud-exporter %s", Version) 52 | userAgent := fmt.Sprintf("nextcloud-exporter/%s", Version) 53 | 54 | if cfg.RunMode == config.RunModeLogin { 55 | if cfg.ServerURL == "" { 56 | log.Fatalf("Need to specify --server for login.") 57 | } 58 | loginClient := login.Init(log, userAgent, cfg.ServerURL, cfg.TLSSkipVerify) 59 | 60 | log.Infof("Starting interactive login on: %s", cfg.ServerURL) 61 | if err := loginClient.StartInteractive(); err != nil { 62 | log.Fatalf("Error during login: %s", err) 63 | } 64 | return 65 | } 66 | 67 | if err := cfg.Validate(); err != nil { 68 | log.Fatalf("Invalid configuration: %s", err) 69 | } 70 | 71 | if cfg.AuthToken == "" { 72 | log.Infof("Nextcloud server: %s User: %s", cfg.ServerURL, cfg.Username) 73 | } else { 74 | log.Infof("Nextcloud server: %s Authentication using token.", cfg.ServerURL) 75 | } 76 | 77 | infoURL := serverinfo.InfoURL(cfg.ServerURL, !cfg.Info.Apps, !cfg.Info.Update) 78 | 79 | if cfg.TLSSkipVerify { 80 | log.Warn("HTTPS certificate verification is disabled.") 81 | } 82 | 83 | infoClient := client.New(infoURL, cfg.Username, cfg.Password, cfg.AuthToken, cfg.Timeout, userAgent, cfg.TLSSkipVerify) 84 | if err := metrics.RegisterCollector(log, infoClient, cfg.Info.Apps, cfg.Info.Update); err != nil { 85 | log.Fatalf("Failed to register collector: %s", err) 86 | } 87 | 88 | if err := metrics.RegisterInfoMetric(Version, GitCommit); err != nil { 89 | log.Fatalf("Failed to register info metric: %s", err) 90 | } 91 | 92 | http.Handle("/metrics", promhttp.Handler()) 93 | http.Handle("/", http.RedirectHandler("/metrics", http.StatusFound)) 94 | 95 | log.Infof("Listen on %s...", cfg.ListenAddr) 96 | log.Fatal(http.ListenAndServe(cfg.ListenAddr, nil)) 97 | } 98 | -------------------------------------------------------------------------------- /serverinfo/parse.go: -------------------------------------------------------------------------------- 1 | package serverinfo 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | ) 7 | 8 | // ParseJSON reads ServerInfo from a Reader in JSON format. 9 | func ParseJSON(r io.Reader) (*ServerInfo, error) { 10 | result := struct { 11 | ServerInfo ServerInfo `json:"ocs"` 12 | }{} 13 | if err := json.NewDecoder(r).Decode(&result); err != nil { 14 | return nil, err 15 | } 16 | 17 | return &result.ServerInfo, nil 18 | } 19 | -------------------------------------------------------------------------------- /serverinfo/parse_test.go: -------------------------------------------------------------------------------- 1 | package serverinfo 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestParseJSON(t *testing.T) { 9 | inputFiles := []string{ 10 | "info.json", 11 | "negative-space.json", 12 | "na-values.json", 13 | "nc22.json", 14 | "large-freespace.json", 15 | } 16 | 17 | for _, inputFile := range inputFiles { 18 | inputFile := inputFile 19 | t.Run(inputFile, func(t *testing.T) { 20 | t.Parallel() 21 | 22 | reader, err := os.Open("testdata/" + inputFile) 23 | if err != nil { 24 | t.Fatalf("error opening test data: %s", err) 25 | } 26 | 27 | if _, err := ParseJSON(reader); err != nil { 28 | t.Errorf("got error %q", err) 29 | } 30 | }) 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /serverinfo/serverinfo.go: -------------------------------------------------------------------------------- 1 | package serverinfo 2 | 3 | import ( 4 | "encoding/json" 5 | "encoding/xml" 6 | "fmt" 7 | "strconv" 8 | ) 9 | 10 | // ServerInfo contains the complete data received from the server. 11 | type ServerInfo struct { 12 | Meta Meta `json:"meta"` 13 | Data Data `json:"data"` 14 | } 15 | 16 | // Meta contains meta information about the result. 17 | type Meta struct { 18 | Status string `json:"status"` 19 | StatusCode int `json:"statuscode"` 20 | Message string `json:"message"` 21 | } 22 | 23 | // Data contains the status information about the instance. 24 | type Data struct { 25 | Nextcloud Nextcloud `json:"nextcloud"` 26 | Server Server `json:"server"` 27 | ActiveUsers ActiveUsers `json:"activeUsers"` 28 | } 29 | 30 | // Nextcloud contains information about the nextcloud installation. 31 | type Nextcloud struct { 32 | System System `json:"system"` 33 | Storage Storage `json:"storage"` 34 | Shares Shares `json:"shares"` 35 | } 36 | 37 | // System contains nextcloud configuration and system information. 38 | type System struct { 39 | Version string `json:"version"` 40 | Theme string `json:"theme"` 41 | EnableAvatars bool `json:"enable_avatars"` 42 | EnablePreviews bool `json:"enable_previews"` 43 | MemcacheLocal string `json:"memcache.local"` 44 | MemcacheDistributed string `json:"memcache.distributed"` 45 | MemcacheLocking string `json:"memcache.locking"` 46 | FilelockingEnabled bool `json:"filelocking.enabled"` 47 | Debug bool `json:"debug"` 48 | FreeSpace float64 `json:"freespace"` 49 | Apps Apps `json:"apps"` 50 | Update Update `json:"update"` 51 | } 52 | 53 | const boolYes = "yes" 54 | 55 | func (s *System) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error { 56 | var raw struct { 57 | Version string `xml:"version"` 58 | Theme string `xml:"theme"` 59 | EnableAvatars string `xml:"enable_avatars"` 60 | EnablePreviews string `xml:"enable_previews"` 61 | MemcacheLocal string `xml:"memcache.local"` 62 | MemcacheDistributed string `xml:"memcache.distributed"` 63 | MemcacheLocking string `xml:"memcache.locking"` 64 | FilelockingEnabled string `xml:"filelocking.enabled"` 65 | Debug string `xml:"debug"` 66 | FreeSpace float64 `xml:"freespace"` 67 | Apps Apps `xml:"apps"` 68 | Update Update `xml:"update"` 69 | } 70 | if err := d.DecodeElement(&raw, &start); err != nil { 71 | return err 72 | } 73 | s.Version = raw.Version 74 | s.Theme = raw.Theme 75 | s.EnableAvatars = raw.EnableAvatars == boolYes 76 | s.EnablePreviews = raw.EnablePreviews == boolYes 77 | s.MemcacheLocal = raw.MemcacheLocal 78 | s.MemcacheDistributed = raw.MemcacheDistributed 79 | s.MemcacheLocking = raw.MemcacheLocking 80 | s.FilelockingEnabled = raw.FilelockingEnabled == boolYes 81 | s.Debug = raw.Debug == boolYes 82 | s.FreeSpace = raw.FreeSpace 83 | s.Apps = raw.Apps 84 | s.Update = raw.Update 85 | return nil 86 | } 87 | 88 | func (s *System) UnmarshalJSON(data []byte) error { 89 | var raw struct { 90 | Version string `json:"version"` 91 | Theme string `json:"theme"` 92 | EnableAvatars string `json:"enable_avatars"` 93 | EnablePreviews string `json:"enable_previews"` 94 | MemcacheLocal string `json:"memcache.local"` 95 | MemcacheDistributed string `json:"memcache.distributed"` 96 | MemcacheLocking string `json:"memcache.locking"` 97 | FilelockingEnabled string `json:"filelocking.enabled"` 98 | Debug string `json:"debug"` 99 | FreeSpace float64 `json:"freespace"` 100 | Apps Apps `json:"apps"` 101 | Update Update `json:"update"` 102 | } 103 | if err := json.Unmarshal(data, &raw); err != nil { 104 | return err 105 | } 106 | s.Version = raw.Version 107 | s.Theme = raw.Theme 108 | s.EnableAvatars = raw.EnableAvatars == boolYes 109 | s.EnablePreviews = raw.EnablePreviews == boolYes 110 | s.MemcacheLocal = raw.MemcacheLocal 111 | s.MemcacheDistributed = raw.MemcacheDistributed 112 | s.MemcacheLocking = raw.MemcacheLocking 113 | s.FilelockingEnabled = raw.FilelockingEnabled == boolYes 114 | s.Debug = raw.Debug == boolYes 115 | s.FreeSpace = raw.FreeSpace 116 | s.Apps = raw.Apps 117 | s.Update = raw.Update 118 | return nil 119 | } 120 | 121 | // Apps contains information about installed apps and updates. 122 | type Apps struct { 123 | Installed uint `json:"num_installed"` 124 | AvailableUpdates uint `json:"num_updates_available"` 125 | } 126 | 127 | // Update contains information about Nextcloud system updates. 128 | type Update struct { 129 | Available bool `json:"available"` 130 | AvailableVersion string `json:"available_version"` 131 | } 132 | 133 | // Storage contains information about the nextcloud storage system. 134 | type Storage struct { 135 | Users uint `json:"num_users"` 136 | Files uint `json:"num_files"` 137 | Storages uint `json:"num_storages"` 138 | StoragesLocal uint `json:"num_storages_local"` 139 | StoragesHome uint `json:"num_storages_home"` 140 | StoragesOther uint `json:"num_storages_other"` 141 | } 142 | 143 | // Shares contains information about nextcloud shares. 144 | type Shares struct { 145 | SharesTotal uint `json:"num_shares"` 146 | SharesUser uint `json:"num_shares_user"` 147 | SharesGroups uint `json:"num_shares_groups"` 148 | SharesLink uint `json:"num_shares_link"` 149 | SharesLinkNoPassword uint `json:"num_shares_link_no_password"` 150 | SharesMail uint `json:"num_shares_mail"` 151 | SharesRoom uint `json:"num_shares_room"` 152 | FedSent uint `json:"num_fed_shares_sent"` 153 | FedReceived uint `json:"num_fed_shares_received"` 154 | // 2 155 | // 4 156 | // 2 157 | // 2 158 | // 3 159 | // 1 160 | } 161 | 162 | // Server contains information about the servers running nextcloud. 163 | type Server struct { 164 | Webserver string `json:"webserver"` 165 | PHP PHP `json:"php"` 166 | Database Database `json:"database"` 167 | } 168 | 169 | // PHP contains information about the PHP installation. 170 | type PHP struct { 171 | Version string `json:"version"` 172 | MemoryLimit int64 `json:"memory_limit"` 173 | MaxExecutionTime uint `json:"max_execution_time"` 174 | UploadMaxFilesize int64 `json:"upload_max_filesize"` 175 | } 176 | 177 | // Database contains information about the database used by nextcloud. 178 | type Database struct { 179 | Type string `json:"type"` 180 | Version string `json:"version"` 181 | Size uint64 `json:"size"` 182 | } 183 | 184 | func (d *Database) UnmarshalJSON(data []byte) error { 185 | var raw struct { 186 | Type string `json:"type"` 187 | Version string `json:"version"` 188 | Size interface{} `json:"size"` 189 | } 190 | 191 | if err := json.Unmarshal(data, &raw); err != nil { 192 | return err 193 | } 194 | 195 | d.Type = raw.Type 196 | d.Version = raw.Version 197 | 198 | switch rawSize := raw.Size.(type) { 199 | case float64: 200 | if rawSize < 0 { 201 | return fmt.Errorf("negative value for database.size: %f", rawSize) 202 | } 203 | d.Size = uint64(rawSize) 204 | case string: 205 | parsedSize, err := strconv.ParseUint(rawSize, 10, 64) 206 | if err != nil { 207 | return fmt.Errorf("can not parse database.size %q: %w", rawSize, err) 208 | } 209 | d.Size = parsedSize 210 | default: 211 | return fmt.Errorf("unexpected type for database.size: %t", rawSize) 212 | } 213 | 214 | return nil 215 | } 216 | 217 | // ActiveUsers contains statistics about the active users. 218 | type ActiveUsers struct { 219 | Last5Minutes uint `json:"last5minutes"` 220 | LastHour uint `json:"last1hour"` 221 | LastDay uint `json:"last24hours"` 222 | } 223 | -------------------------------------------------------------------------------- /serverinfo/testdata/info.json: -------------------------------------------------------------------------------- 1 | { 2 | "ocs": { 3 | "meta": { 4 | "status": "ok", 5 | "statuscode": 200, 6 | "message": "OK" 7 | }, 8 | "data": { 9 | "nextcloud": { 10 | "system": { 11 | "version": "21.0.3.1", 12 | "theme": "", 13 | "enable_avatars": "yes", 14 | "enable_previews": "yes", 15 | "memcache.local": "\\OC\\Memcache\\APCu", 16 | "memcache.distributed": "none", 17 | "filelocking.enabled": "yes", 18 | "memcache.locking": "\\OC\\Memcache\\Redis", 19 | "debug": "no", 20 | "freespace": 7635480576, 21 | "cpuload": [ 22 | 0.08, 23 | 0.05, 24 | 0.06 25 | ], 26 | "mem_total": 1986232, 27 | "mem_free": 1285532, 28 | "swap_total": 0, 29 | "swap_free": 0, 30 | "apps": { 31 | "num_installed": 42, 32 | "num_updates_available": 0, 33 | "app_updates": [] 34 | } 35 | }, 36 | "storage": { 37 | "num_users": 4, 38 | "num_files": 148948, 39 | "num_storages": 32, 40 | "num_storages_local": 3, 41 | "num_storages_home": 4, 42 | "num_storages_other": 25 43 | }, 44 | "shares": { 45 | "num_shares": 10, 46 | "num_shares_user": 0, 47 | "num_shares_groups": 2, 48 | "num_shares_link": 4, 49 | "num_shares_mail": 1, 50 | "num_shares_room": 0, 51 | "num_shares_link_no_password": 4, 52 | "num_fed_shares_sent": 0, 53 | "num_fed_shares_received": 0, 54 | "permissions_3_1": "2", 55 | "permissions_3_17": "1", 56 | "permissions_4_17": "1", 57 | "permissions_1_31": "2", 58 | "permissions_2_31": "3", 59 | "permissions_3_31": "1" 60 | } 61 | }, 62 | "server": { 63 | "webserver": "Apache\/2.4.41 (Ubuntu)", 64 | "php": { 65 | "version": "7.4.3", 66 | "memory_limit": 536870912, 67 | "max_execution_time": 3600, 68 | "upload_max_filesize": 2097152, 69 | "opcache": { 70 | "opcache_enabled": true, 71 | "cache_full": false, 72 | "restart_pending": false, 73 | "restart_in_progress": false, 74 | "memory_usage": { 75 | "used_memory": 35866872, 76 | "free_memory": 98334320, 77 | "wasted_memory": 16536, 78 | "current_wasted_percentage": 0.012320280075073242 79 | }, 80 | "interned_strings_usage": { 81 | "buffer_size": 6291008, 82 | "used_memory": 4225688, 83 | "free_memory": 2065320, 84 | "number_of_strings": 68439 85 | }, 86 | "opcache_statistics": { 87 | "num_cached_scripts": 1818, 88 | "num_cached_keys": 3489, 89 | "max_cached_keys": 16229, 90 | "hits": 2725757, 91 | "start_time": 1627817478, 92 | "last_restart_time": 0, 93 | "oom_restarts": 0, 94 | "hash_restarts": 0, 95 | "manual_restarts": 0, 96 | "misses": 1830, 97 | "blacklist_misses": 0, 98 | "blacklist_miss_ratio": 0, 99 | "opcache_hit_rate": 99.93290773126576 100 | } 101 | }, 102 | "apcu": { 103 | "cache": { 104 | "num_slots": 4099, 105 | "ttl": 0, 106 | "num_hits": 175992, 107 | "num_misses": 1948, 108 | "num_inserts": 2009, 109 | "num_entries": 599, 110 | "expunges": 0, 111 | "start_time": 1627817478, 112 | "mem_size": 295024, 113 | "memory_type": "mmap" 114 | }, 115 | "sma": { 116 | "num_seg": 1, 117 | "seg_size": 33554312, 118 | "avail_mem": 33206176 119 | } 120 | } 121 | }, 122 | "database": { 123 | "type": "mysql", 124 | "version": "10.5.11", 125 | "size": 59457536 126 | } 127 | }, 128 | "activeUsers": { 129 | "last5minutes": 1, 130 | "last1hour": 1, 131 | "last24hours": 2 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /serverinfo/testdata/large-freespace.json: -------------------------------------------------------------------------------- 1 | { 2 | "ocs": { 3 | "meta": { 4 | "status": "ok", 5 | "statuscode": 200, 6 | "message": "OK" 7 | }, 8 | "data": { 9 | "nextcloud": { 10 | "system": { 11 | "version": "21.0.3.1", 12 | "theme": "", 13 | "enable_avatars": "yes", 14 | "enable_previews": "yes", 15 | "memcache.local": "\\OC\\Memcache\\APCu", 16 | "memcache.distributed": "none", 17 | "filelocking.enabled": "yes", 18 | "memcache.locking": "\\OC\\Memcache\\Redis", 19 | "debug": "no", 20 | "freespace": 9.2233720360673E+18, 21 | "cpuload": [ 22 | 0.08, 23 | 0.05, 24 | 0.06 25 | ], 26 | "mem_total": 1986232, 27 | "mem_free": 1285532, 28 | "swap_total": 0, 29 | "swap_free": 0, 30 | "apps": { 31 | "num_installed": 42, 32 | "num_updates_available": 0, 33 | "app_updates": [] 34 | } 35 | }, 36 | "storage": { 37 | "num_users": 4, 38 | "num_files": 148948, 39 | "num_storages": 32, 40 | "num_storages_local": 3, 41 | "num_storages_home": 4, 42 | "num_storages_other": 25 43 | }, 44 | "shares": { 45 | "num_shares": 10, 46 | "num_shares_user": 0, 47 | "num_shares_groups": 2, 48 | "num_shares_link": 4, 49 | "num_shares_mail": 1, 50 | "num_shares_room": 0, 51 | "num_shares_link_no_password": 4, 52 | "num_fed_shares_sent": 0, 53 | "num_fed_shares_received": 0, 54 | "permissions_3_1": "2", 55 | "permissions_3_17": "1", 56 | "permissions_4_17": "1", 57 | "permissions_1_31": "2", 58 | "permissions_2_31": "3", 59 | "permissions_3_31": "1" 60 | } 61 | }, 62 | "server": { 63 | "webserver": "Apache\/2.4.41 (Ubuntu)", 64 | "php": { 65 | "version": "7.4.3", 66 | "memory_limit": 536870912, 67 | "max_execution_time": 3600, 68 | "upload_max_filesize": 2097152, 69 | "opcache": { 70 | "opcache_enabled": true, 71 | "cache_full": false, 72 | "restart_pending": false, 73 | "restart_in_progress": false, 74 | "memory_usage": { 75 | "used_memory": 35866872, 76 | "free_memory": 98334320, 77 | "wasted_memory": 16536, 78 | "current_wasted_percentage": 0.012320280075073242 79 | }, 80 | "interned_strings_usage": { 81 | "buffer_size": 6291008, 82 | "used_memory": 4225688, 83 | "free_memory": 2065320, 84 | "number_of_strings": 68439 85 | }, 86 | "opcache_statistics": { 87 | "num_cached_scripts": 1818, 88 | "num_cached_keys": 3489, 89 | "max_cached_keys": 16229, 90 | "hits": 2725757, 91 | "start_time": 1627817478, 92 | "last_restart_time": 0, 93 | "oom_restarts": 0, 94 | "hash_restarts": 0, 95 | "manual_restarts": 0, 96 | "misses": 1830, 97 | "blacklist_misses": 0, 98 | "blacklist_miss_ratio": 0, 99 | "opcache_hit_rate": 99.93290773126576 100 | } 101 | }, 102 | "apcu": { 103 | "cache": { 104 | "num_slots": 4099, 105 | "ttl": 0, 106 | "num_hits": 175992, 107 | "num_misses": 1948, 108 | "num_inserts": 2009, 109 | "num_entries": 599, 110 | "expunges": 0, 111 | "start_time": 1627817478, 112 | "mem_size": 295024, 113 | "memory_type": "mmap" 114 | }, 115 | "sma": { 116 | "num_seg": 1, 117 | "seg_size": 33554312, 118 | "avail_mem": 33206176 119 | } 120 | } 121 | }, 122 | "database": { 123 | "type": "mysql", 124 | "version": "10.5.11", 125 | "size": 59457536 126 | } 127 | }, 128 | "activeUsers": { 129 | "last5minutes": 1, 130 | "last1hour": 1, 131 | "last24hours": 2 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /serverinfo/testdata/na-values.json: -------------------------------------------------------------------------------- 1 | { 2 | "ocs": { 3 | "meta": { 4 | "status": "ok", 5 | "statuscode": 200, 6 | "message": "OK" 7 | }, 8 | "data": { 9 | "nextcloud": { 10 | "system": { 11 | "version": "21.0.3.1", 12 | "theme": "", 13 | "enable_avatars": "yes", 14 | "enable_previews": "yes", 15 | "memcache.local": "\\OC\\Memcache\\APCu", 16 | "memcache.distributed": "none", 17 | "filelocking.enabled": "yes", 18 | "memcache.locking": "\\OC\\Memcache\\Redis", 19 | "debug": "no", 20 | "freespace": 7635480576, 21 | "cpuload": [ 22 | 0.08, 23 | 0.05, 24 | 0.06 25 | ], 26 | "mem_total": "N/A", 27 | "mem_free": "N/A", 28 | "swap_total": "N/A", 29 | "swap_free": "N/A", 30 | "apps": { 31 | "num_installed": 42, 32 | "num_updates_available": 0, 33 | "app_updates": [] 34 | } 35 | }, 36 | "storage": { 37 | "num_users": 4, 38 | "num_files": 148948, 39 | "num_storages": 32, 40 | "num_storages_local": 3, 41 | "num_storages_home": 4, 42 | "num_storages_other": 25 43 | }, 44 | "shares": { 45 | "num_shares": 10, 46 | "num_shares_user": 0, 47 | "num_shares_groups": 2, 48 | "num_shares_link": 4, 49 | "num_shares_mail": 1, 50 | "num_shares_room": 0, 51 | "num_shares_link_no_password": 4, 52 | "num_fed_shares_sent": 0, 53 | "num_fed_shares_received": 0, 54 | "permissions_3_1": "2", 55 | "permissions_3_17": "1", 56 | "permissions_4_17": "1", 57 | "permissions_1_31": "2", 58 | "permissions_2_31": "3", 59 | "permissions_3_31": "1" 60 | } 61 | }, 62 | "server": { 63 | "webserver": "Apache\/2.4.41 (Ubuntu)", 64 | "php": { 65 | "version": "7.4.3", 66 | "memory_limit": 536870912, 67 | "max_execution_time": 3600, 68 | "upload_max_filesize": 2097152, 69 | "opcache": { 70 | "opcache_enabled": true, 71 | "cache_full": false, 72 | "restart_pending": false, 73 | "restart_in_progress": false, 74 | "memory_usage": { 75 | "used_memory": 35866872, 76 | "free_memory": 98334320, 77 | "wasted_memory": 16536, 78 | "current_wasted_percentage": 0.012320280075073242 79 | }, 80 | "interned_strings_usage": { 81 | "buffer_size": 6291008, 82 | "used_memory": 4225688, 83 | "free_memory": 2065320, 84 | "number_of_strings": 68439 85 | }, 86 | "opcache_statistics": { 87 | "num_cached_scripts": 1818, 88 | "num_cached_keys": 3489, 89 | "max_cached_keys": 16229, 90 | "hits": 2725757, 91 | "start_time": 1627817478, 92 | "last_restart_time": 0, 93 | "oom_restarts": 0, 94 | "hash_restarts": 0, 95 | "manual_restarts": 0, 96 | "misses": 1830, 97 | "blacklist_misses": 0, 98 | "blacklist_miss_ratio": 0, 99 | "opcache_hit_rate": 99.93290773126576 100 | } 101 | }, 102 | "apcu": { 103 | "cache": { 104 | "num_slots": 4099, 105 | "ttl": 0, 106 | "num_hits": 175992, 107 | "num_misses": 1948, 108 | "num_inserts": 2009, 109 | "num_entries": 599, 110 | "expunges": 0, 111 | "start_time": 1627817478, 112 | "mem_size": 295024, 113 | "memory_type": "mmap" 114 | }, 115 | "sma": { 116 | "num_seg": 1, 117 | "seg_size": 33554312, 118 | "avail_mem": 33206176 119 | } 120 | } 121 | }, 122 | "database": { 123 | "type": "mysql", 124 | "version": "10.5.11", 125 | "size": 59457536 126 | } 127 | }, 128 | "activeUsers": { 129 | "last5minutes": 1, 130 | "last1hour": 1, 131 | "last24hours": 2 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /serverinfo/testdata/nc22.json: -------------------------------------------------------------------------------- 1 | { 2 | "ocs": { 3 | "meta": { 4 | "status": "ok", 5 | "statuscode": 200, 6 | "message": "OK" 7 | }, 8 | "data": { 9 | "nextcloud": { 10 | "system": { 11 | "version": "22.2.0.2", 12 | "theme": "", 13 | "enable_avatars": "yes", 14 | "enable_previews": "yes", 15 | "memcache.local": "\\OC\\Memcache\\Redis", 16 | "memcache.distributed": "\\OC\\Memcache\\Redis", 17 | "filelocking.enabled": "yes", 18 | "memcache.locking": "\\OC\\Memcache\\Redis", 19 | "debug": "no", 20 | "freespace": 12975042, 21 | "cpuload": [ 22 | 0.8, 23 | 0.4, 24 | 0.3 25 | ], 26 | "mem_total": 394078, 27 | "mem_free": 184536, 28 | "swap_total": 52428, 29 | "swap_free": 3960, 30 | "apps": { 31 | "num_installed": 4, 32 | "num_updates_available": 0, 33 | "app_updates": [] 34 | } 35 | }, 36 | "storage": { 37 | "num_users": 3, 38 | "num_files": 412, 39 | "num_storages": 6, 40 | "num_storages_local": 4, 41 | "num_storages_home": 3, 42 | "num_storages_other": 2 43 | }, 44 | "shares": { 45 | "num_shares": 8, 46 | "num_shares_user": 4, 47 | "num_shares_groups": 2, 48 | "num_shares_link": 7, 49 | "num_shares_mail": 0, 50 | "num_shares_room": 0, 51 | "num_shares_link_no_password": 7, 52 | "num_fed_shares_sent": 1, 53 | "num_fed_shares_received": 2, 54 | "permissions_0_1": "3", 55 | "permissions_3_1": "43", 56 | "permissions_1_15": "1", 57 | "permissions_2_15": "1", 58 | "permissions_3_15": "3", 59 | "permissions_3_17": "27", 60 | "permissions_0_31": "1", 61 | "permissions_1_31": "1", 62 | "permissions_2_31": "5", 63 | "permissions_3_31": "2", 64 | "permissions_6_31": "1" 65 | } 66 | }, 67 | "server": { 68 | "webserver": "nginx\/1.14.0", 69 | "php": { 70 | "version": "7.4.0", 71 | "memory_limit": 26843545, 72 | "max_execution_time": 360, 73 | "upload_max_filesize": 1677721, 74 | "opcache": { 75 | "opcache_enabled": true, 76 | "cache_full": false, 77 | "restart_pending": false, 78 | "restart_in_progress": false, 79 | "memory_usage": { 80 | "used_memory": 3928244, 81 | "free_memory": 9490738, 82 | "wasted_memory": 2789, 83 | "current_wasted_percentage": 0.020 84 | }, 85 | "interned_strings_usage": { 86 | "buffer_size": 629100, 87 | "used_memory": 489804, 88 | "free_memory": 139296, 89 | "number_of_strings": 7795 90 | }, 91 | "opcache_statistics": { 92 | "num_cached_scripts": 209, 93 | "num_cached_keys": 399, 94 | "max_cached_keys": 1622, 95 | "hits": 391187, 96 | "start_time": 1634933931, 97 | "last_restart_time": 0, 98 | "oom_restarts": 0, 99 | "hash_restarts": 0, 100 | "manual_restarts": 0, 101 | "misses": 210, 102 | "blacklist_misses": 0, 103 | "blacklist_miss_ratio": 0, 104 | "opcache_hit_rate": 99.994 105 | } 106 | }, 107 | "apcu": { 108 | "cache": { 109 | "num_slots": 409, 110 | "ttl": 0, 111 | "num_hits": 0, 112 | "num_misses": 0, 113 | "num_inserts": 0, 114 | "num_entries": 0, 115 | "expunges": 0, 116 | "start_time": 1634933931, 117 | "mem_size": 0, 118 | "memory_type": "mmap" 119 | }, 120 | "sma": { 121 | "num_seg": 1, 122 | "seg_size": 335543, 123 | "avail_mem": 335213 124 | } 125 | } 126 | }, 127 | "database": { 128 | "type": "mysql", 129 | "version": "10.5.12", 130 | "size": "30638080" 131 | } 132 | }, 133 | "activeUsers": { 134 | "last5minutes": 3, 135 | "last1hour": 3, 136 | "last24hours": 3 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /serverinfo/testdata/negative-space.json: -------------------------------------------------------------------------------- 1 | { 2 | "ocs": { 3 | "meta": { 4 | "status": "ok", 5 | "statuscode": 200, 6 | "message": "OK" 7 | }, 8 | "data": { 9 | "nextcloud": { 10 | "system": { 11 | "version": "21.0.3.1", 12 | "theme": "", 13 | "enable_avatars": "yes", 14 | "enable_previews": "yes", 15 | "memcache.local": "\\OC\\Memcache\\APCu", 16 | "memcache.distributed": "none", 17 | "filelocking.enabled": "yes", 18 | "memcache.locking": "\\OC\\Memcache\\Redis", 19 | "debug": "no", 20 | "freespace": -2, 21 | "cpuload": [ 22 | 0.08, 23 | 0.05, 24 | 0.06 25 | ], 26 | "mem_total": 1986232, 27 | "mem_free": 1285532, 28 | "swap_total": 0, 29 | "swap_free": 0, 30 | "apps": { 31 | "num_installed": 42, 32 | "num_updates_available": 0, 33 | "app_updates": [] 34 | } 35 | }, 36 | "storage": { 37 | "num_users": 4, 38 | "num_files": 148948, 39 | "num_storages": 32, 40 | "num_storages_local": 3, 41 | "num_storages_home": 4, 42 | "num_storages_other": 25 43 | }, 44 | "shares": { 45 | "num_shares": 10, 46 | "num_shares_user": 0, 47 | "num_shares_groups": 2, 48 | "num_shares_link": 4, 49 | "num_shares_mail": 1, 50 | "num_shares_room": 0, 51 | "num_shares_link_no_password": 4, 52 | "num_fed_shares_sent": 0, 53 | "num_fed_shares_received": 0, 54 | "permissions_3_1": "2", 55 | "permissions_3_17": "1", 56 | "permissions_4_17": "1", 57 | "permissions_1_31": "2", 58 | "permissions_2_31": "3", 59 | "permissions_3_31": "1" 60 | } 61 | }, 62 | "server": { 63 | "webserver": "Apache\/2.4.41 (Ubuntu)", 64 | "php": { 65 | "version": "7.4.3", 66 | "memory_limit": 536870912, 67 | "max_execution_time": 3600, 68 | "upload_max_filesize": 2097152, 69 | "opcache": { 70 | "opcache_enabled": true, 71 | "cache_full": false, 72 | "restart_pending": false, 73 | "restart_in_progress": false, 74 | "memory_usage": { 75 | "used_memory": 35866872, 76 | "free_memory": 98334320, 77 | "wasted_memory": 16536, 78 | "current_wasted_percentage": 0.012320280075073242 79 | }, 80 | "interned_strings_usage": { 81 | "buffer_size": 6291008, 82 | "used_memory": 4225688, 83 | "free_memory": 2065320, 84 | "number_of_strings": 68439 85 | }, 86 | "opcache_statistics": { 87 | "num_cached_scripts": 1818, 88 | "num_cached_keys": 3489, 89 | "max_cached_keys": 16229, 90 | "hits": 2725757, 91 | "start_time": 1627817478, 92 | "last_restart_time": 0, 93 | "oom_restarts": 0, 94 | "hash_restarts": 0, 95 | "manual_restarts": 0, 96 | "misses": 1830, 97 | "blacklist_misses": 0, 98 | "blacklist_miss_ratio": 0, 99 | "opcache_hit_rate": 99.93290773126576 100 | } 101 | }, 102 | "apcu": { 103 | "cache": { 104 | "num_slots": 4099, 105 | "ttl": 0, 106 | "num_hits": 175992, 107 | "num_misses": 1948, 108 | "num_inserts": 2009, 109 | "num_entries": 599, 110 | "expunges": 0, 111 | "start_time": 1627817478, 112 | "mem_size": 295024, 113 | "memory_type": "mmap" 114 | }, 115 | "sma": { 116 | "num_seg": 1, 117 | "seg_size": 33554312, 118 | "avail_mem": 33206176 119 | } 120 | } 121 | }, 122 | "database": { 123 | "type": "mysql", 124 | "version": "10.5.11", 125 | "size": 59457536 126 | } 127 | }, 128 | "activeUsers": { 129 | "last5minutes": 1, 130 | "last1hour": 1, 131 | "last24hours": 2 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /serverinfo/url.go: -------------------------------------------------------------------------------- 1 | package serverinfo 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | const ( 8 | infoPathFormat = "%s/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=%v&skipUpdate=%v" 9 | ) 10 | 11 | // InfoURL constructs the URL of the info endpoint from the server base URL and optional parameters. 12 | func InfoURL(serverURL string, skipApps bool, skipUpdate bool) string { 13 | return fmt.Sprintf(infoPathFormat, serverURL, skipApps, skipUpdate) 14 | } 15 | -------------------------------------------------------------------------------- /serverinfo/url_test.go: -------------------------------------------------------------------------------- 1 | package serverinfo 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestInfoURL(t *testing.T) { 8 | tt := []struct { 9 | desc string 10 | serverURL string 11 | skipApps bool 12 | skipUpdate bool 13 | wantURL string 14 | }{ 15 | { 16 | desc: "do not skip apps and do not skip update (implicit)", 17 | serverURL: "https://nextcloud.example.com", 18 | wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=false&skipUpdate=false", 19 | }, 20 | { 21 | desc: "skip apps", 22 | serverURL: "https://nextcloud.example.com", 23 | skipApps: true, 24 | wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=true&skipUpdate=false", 25 | }, 26 | { 27 | desc: "do not skip update", 28 | serverURL: "https://nextcloud.example.com", 29 | skipUpdate: false, 30 | wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=false&skipUpdate=false", 31 | }, 32 | { 33 | desc: "skip update", 34 | serverURL: "https://nextcloud.example.com", 35 | skipUpdate: true, 36 | wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=false&skipUpdate=true", 37 | }, 38 | { 39 | desc: "do not skip update and do not skip apps (explicit)", 40 | serverURL: "https://nextcloud.example.com", 41 | skipApps: false, 42 | skipUpdate: false, 43 | wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=false&skipUpdate=false", 44 | }, 45 | { 46 | desc: "skip update and skip apps", 47 | serverURL: "https://nextcloud.example.com", 48 | skipApps: true, 49 | skipUpdate: true, 50 | wantURL: "https://nextcloud.example.com/ocs/v2.php/apps/serverinfo/api/v1/info?format=json&skipApps=true&skipUpdate=true", 51 | }, 52 | } 53 | 54 | for _, tc := range tt { 55 | tc := tc 56 | t.Run(tc.desc, func(t *testing.T) { 57 | t.Parallel() 58 | 59 | url := InfoURL(tc.serverURL, tc.skipApps, tc.skipUpdate) 60 | if url != tc.wantURL { 61 | t.Errorf("got url %q, want %q", url, tc.wantURL) 62 | } 63 | }) 64 | } 65 | } 66 | --------------------------------------------------------------------------------