first commit
This commit is contained in:
commit
3ca46c9ea2
19 changed files with 958 additions and 0 deletions
BIN
.assets/icon.png
Normal file
BIN
.assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.7 KiB |
47
.github/workflows/go-cross.yml
vendored
Normal file
47
.github/workflows/go-cross.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
name: Go Matrix
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
cross:
|
||||||
|
name: Go
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
env:
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
go-version: [ 1.19, 1.x ]
|
||||||
|
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# https://github.com/marketplace/actions/setup-go-environment
|
||||||
|
- name: Set up Go ${{ matrix.go-version }}
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ${{ matrix.go-version }}
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/checkout
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/cache
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
# In order:
|
||||||
|
# * Module download cache
|
||||||
|
# * Build cache (Linux)
|
||||||
|
# * Build cache (Mac)
|
||||||
|
# * Build cache (Windows)
|
||||||
|
path: |
|
||||||
|
~/go/pkg/mod
|
||||||
|
~/.cache/go-build
|
||||||
|
~/Library/Caches/go-build
|
||||||
|
%LocalAppData%\go-build
|
||||||
|
key: ${{ runner.os }}-${{ matrix.go-version }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ matrix.go-version }}-go-
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -v -cover ./...
|
||||||
72
.github/workflows/main.yml
vendored
Normal file
72
.github/workflows/main.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
name: Main
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
main:
|
||||||
|
name: Main Process
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GO_VERSION: 1.19
|
||||||
|
GOLANGCI_LINT_VERSION: v1.50.0
|
||||||
|
YAEGI_VERSION: v0.14.2
|
||||||
|
CGO_ENABLED: 0
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: ${{ github.workspace }}/go/src/github.com/${{ github.repository }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/setup-go-environment
|
||||||
|
- name: Set up Go ${{ env.GO_VERSION }}
|
||||||
|
uses: actions/setup-go@v2
|
||||||
|
with:
|
||||||
|
go-version: ${{ env.GO_VERSION }}
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/checkout
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
with:
|
||||||
|
path: go/src/github.com/${{ github.repository }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
# https://github.com/marketplace/actions/cache
|
||||||
|
- name: Cache Go modules
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ${{ github.workspace }}/go/pkg/mod
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-go-
|
||||||
|
|
||||||
|
# https://golangci-lint.run/usage/install#other-ci
|
||||||
|
- name: Install golangci-lint ${{ env.GOLANGCI_LINT_VERSION }}
|
||||||
|
run: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin ${GOLANGCI_LINT_VERSION}
|
||||||
|
|
||||||
|
- name: Install Yaegi ${{ env.YAEGI_VERSION }}
|
||||||
|
run: curl -sfL https://raw.githubusercontent.com/traefik/yaegi/master/install.sh | bash -s -- -b $(go env GOPATH)/bin ${YAEGI_VERSION}
|
||||||
|
|
||||||
|
- name: Setup GOPATH
|
||||||
|
run: go env -w GOPATH=${{ github.workspace }}/go
|
||||||
|
|
||||||
|
- name: Check and get dependencies
|
||||||
|
run: |
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code go.mod
|
||||||
|
# git diff --exit-code go.sum
|
||||||
|
go mod download
|
||||||
|
go mod vendor
|
||||||
|
# git diff --exit-code ./vendor/
|
||||||
|
|
||||||
|
- name: Lint and Tests
|
||||||
|
run: make
|
||||||
|
|
||||||
|
- name: Run tests with Yaegi
|
||||||
|
run: make yaegi_test
|
||||||
|
env:
|
||||||
|
GOPATH: ${{ github.workspace }}/go
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
postgres/
|
||||||
|
go.work
|
||||||
75
.golangci.yml
Normal file
75
.golangci.yml
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
run:
|
||||||
|
timeout: 3m
|
||||||
|
skip-files: []
|
||||||
|
skip-dirs: []
|
||||||
|
|
||||||
|
linters-settings:
|
||||||
|
govet:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- fieldalignment
|
||||||
|
golint:
|
||||||
|
min-confidence: 0
|
||||||
|
gocyclo:
|
||||||
|
min-complexity: 12
|
||||||
|
goconst:
|
||||||
|
min-len: 5
|
||||||
|
min-occurrences: 4
|
||||||
|
misspell:
|
||||||
|
locale: US
|
||||||
|
funlen:
|
||||||
|
lines: -1
|
||||||
|
statements: 50
|
||||||
|
godox:
|
||||||
|
keywords:
|
||||||
|
- FIXME
|
||||||
|
gofumpt:
|
||||||
|
extra-rules: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
enable-all: true
|
||||||
|
disable:
|
||||||
|
- deadcode # deprecated
|
||||||
|
- exhaustivestruct # deprecated
|
||||||
|
- golint # deprecated
|
||||||
|
- ifshort # deprecated
|
||||||
|
- interfacer # deprecated
|
||||||
|
- maligned # deprecated
|
||||||
|
- nosnakecase # deprecated
|
||||||
|
- scopelint # deprecated
|
||||||
|
- scopelint # deprecated
|
||||||
|
- structcheck # deprecated
|
||||||
|
- varcheck # deprecated
|
||||||
|
- sqlclosecheck # not relevant (SQL)
|
||||||
|
- rowserrcheck # not relevant (SQL)
|
||||||
|
- execinquery # not relevant (SQL)
|
||||||
|
- cyclop # duplicate of gocyclo
|
||||||
|
- bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30
|
||||||
|
- dupl
|
||||||
|
- testpackage
|
||||||
|
- tparallel
|
||||||
|
- paralleltest
|
||||||
|
- nlreturn
|
||||||
|
- wsl
|
||||||
|
- exhaustive
|
||||||
|
- exhaustruct
|
||||||
|
- goerr113
|
||||||
|
- wrapcheck
|
||||||
|
- ifshort
|
||||||
|
- noctx
|
||||||
|
- lll
|
||||||
|
- gomnd
|
||||||
|
- forbidigo
|
||||||
|
- varnamelen
|
||||||
|
|
||||||
|
issues:
|
||||||
|
exclude-use-default: false
|
||||||
|
max-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
exclude: []
|
||||||
|
exclude-rules:
|
||||||
|
- path: (.+)_test.go
|
||||||
|
linters:
|
||||||
|
- goconst
|
||||||
|
- funlen
|
||||||
|
- godot
|
||||||
17
.traefik.yml
Normal file
17
.traefik.yml
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
displayName: Umami Feeder
|
||||||
|
type: middleware
|
||||||
|
iconPath: .assets/icon.png
|
||||||
|
|
||||||
|
import: github.com/astappiev/traefik-umami-feeder
|
||||||
|
|
||||||
|
summary: 'Traefik plugin that sends HTTP requests to Umami as pageview events.'
|
||||||
|
|
||||||
|
testData:
|
||||||
|
umamiHost: ""
|
||||||
|
umamiToken: ""
|
||||||
|
umamiUsername: ""
|
||||||
|
umamiPassword: ""
|
||||||
|
websites:
|
||||||
|
domain: websiteId
|
||||||
|
createNewWebsites: false
|
||||||
|
debug: false
|
||||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Oleh Astappiev
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
20
Makefile
Normal file
20
Makefile
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
.PHONY: lint test vendor clean
|
||||||
|
|
||||||
|
export GO111MODULE=on
|
||||||
|
|
||||||
|
default: lint test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
golangci-lint run
|
||||||
|
|
||||||
|
test:
|
||||||
|
go test -v -cover ./...
|
||||||
|
|
||||||
|
yaegi_test:
|
||||||
|
yaegi test -v .
|
||||||
|
|
||||||
|
vendor:
|
||||||
|
go mod vendor
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf ./vendor
|
||||||
10
demo/config.yml
Normal file
10
demo/config.yml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
umami:
|
||||||
|
plugin:
|
||||||
|
umami-feeder:
|
||||||
|
debug: true
|
||||||
|
umamiHost: http://umami:3000
|
||||||
|
umamiUsername: "admin"
|
||||||
|
umamiPassword: "umami"
|
||||||
|
createNewWebsites: true
|
||||||
70
demo/docker-compose.yml
Normal file
70
demo/docker-compose.yml
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:3
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.traefik.service=api@internal"
|
||||||
|
- "traefik.http.routers.traefik.rule=Host(`localhost`)"
|
||||||
|
- "traefik.http.routers.traefik.entrypoints=traefik"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
ports:
|
||||||
|
- target: 8080
|
||||||
|
published: 8080
|
||||||
|
protocol: tcp
|
||||||
|
- target: 8081
|
||||||
|
published: 8081
|
||||||
|
protocol: tcp
|
||||||
|
volumes:
|
||||||
|
# So that Traefik can listen to the Docker events
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
|
# To persist ACME data (certificates, account, etc)
|
||||||
|
- ./traefik.yml:/traefik.yml:ro
|
||||||
|
- ./config.yml:/config/config.yml:ro
|
||||||
|
# mount plugin
|
||||||
|
- ../:/plugins-local/src/github.com/astappiev/traefik-umami-feeder
|
||||||
|
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
whoami:
|
||||||
|
image: containous/whoami
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.services.whoami.loadbalancer.server.port=80"
|
||||||
|
- "traefik.http.routers.whoami.service=whoami"
|
||||||
|
- "traefik.http.routers.whoami.rule=Host(`localhost`)"
|
||||||
|
- "traefik.http.routers.whoami.entrypoints=whoami"
|
||||||
|
# middleware
|
||||||
|
- "traefik.http.routers.whoami.middlewares=umami@file"
|
||||||
|
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
|
||||||
|
umami:
|
||||||
|
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||||
|
depends_on:
|
||||||
|
- postgres
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgres://umami:umami@postgres:5432/umami
|
||||||
|
ports:
|
||||||
|
- target: 3000
|
||||||
|
published: 3000
|
||||||
|
protocol: tcp
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- umami_postgres
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
- POSTGRES_PASSWORD=umami
|
||||||
|
- POSTGRES_DB=umami
|
||||||
|
- POSTGRES_USER=umami
|
||||||
|
networks:
|
||||||
|
- umami_postgres
|
||||||
|
volumes:
|
||||||
|
- ./postgres:/var/lib/postgresql/data
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
umami_postgres:
|
||||||
30
demo/traefik.yml
Normal file
30
demo/traefik.yml
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
log:
|
||||||
|
level: INFO
|
||||||
|
|
||||||
|
# accessLog: {}
|
||||||
|
|
||||||
|
providers:
|
||||||
|
docker:
|
||||||
|
endpoint: "unix:///var/run/docker.sock"
|
||||||
|
exposedByDefault: false
|
||||||
|
network: traefik
|
||||||
|
watch: true
|
||||||
|
file:
|
||||||
|
directory: /config
|
||||||
|
watch: true
|
||||||
|
|
||||||
|
entryPoints:
|
||||||
|
"traefik": # traefik dashboard
|
||||||
|
address: ":8080"
|
||||||
|
"whoami": # http
|
||||||
|
address: ":8081"
|
||||||
|
"umami": # umami dashboard
|
||||||
|
address: ":3000"
|
||||||
|
|
||||||
|
api:
|
||||||
|
dashboard: true
|
||||||
|
|
||||||
|
experimental:
|
||||||
|
localPlugins:
|
||||||
|
umami-feeder:
|
||||||
|
moduleName: "github.com/astappiev/traefik-umami-feeder"
|
||||||
106
forwarding.go
Normal file
106
forwarding.go
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
package traefik_umami_feeder
|
||||||
|
|
||||||
|
// Copied source from
|
||||||
|
// https://github.com/kzmake/traefik-plugin-forward-request/blob/master/util.go
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
xForwardedProto = "x-forwarded-proto"
|
||||||
|
xForwardedFor = "x-forwarded-for"
|
||||||
|
xForwardedHost = "x-forwarded-host"
|
||||||
|
xForwardedPort = "x-forwarded-port"
|
||||||
|
xForwardedURI = "x-forwarded-uri"
|
||||||
|
xForwardedMethod = "x-forwarded-method"
|
||||||
|
|
||||||
|
connection = "connection"
|
||||||
|
keepAlive = "keep-alive"
|
||||||
|
proxyAuthenticate = "proxy-authenticate"
|
||||||
|
proxyAuthorization = "proxy-authorization"
|
||||||
|
te = "te" // canonicalized version of "TE"
|
||||||
|
trailers = "trailers"
|
||||||
|
transferEncoding = "transfer-Encoding"
|
||||||
|
upgrade = "upgrade"
|
||||||
|
)
|
||||||
|
|
||||||
|
var hopHeaders = []string{
|
||||||
|
connection,
|
||||||
|
keepAlive,
|
||||||
|
proxyAuthenticate,
|
||||||
|
proxyAuthorization,
|
||||||
|
te, // canonicalized version of "TE"
|
||||||
|
trailers,
|
||||||
|
transferEncoding,
|
||||||
|
upgrade,
|
||||||
|
}
|
||||||
|
|
||||||
|
func copyHeaders(dst, src http.Header) {
|
||||||
|
for k, vv := range src {
|
||||||
|
for _, v := range vv {
|
||||||
|
dst.Add(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeHeaders(headers http.Header, names ...string) {
|
||||||
|
for _, h := range names {
|
||||||
|
headers.Del(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeXForwardedHeaders(dst http.Header, req *http.Request) {
|
||||||
|
if clientIP, _, err := net.SplitHostPort(req.RemoteAddr); err == nil {
|
||||||
|
if values := req.Header.Values(xForwardedFor); len(values) > 0 {
|
||||||
|
clientIP = strings.Join(values, ", ") + ", " + clientIP
|
||||||
|
}
|
||||||
|
dst.Set(xForwardedFor, clientIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
xfm := req.Header.Get(xForwardedMethod)
|
||||||
|
switch {
|
||||||
|
case xfm != "":
|
||||||
|
dst.Set(xForwardedMethod, xfm)
|
||||||
|
case req.Method != "":
|
||||||
|
dst.Set(xForwardedMethod, req.Method)
|
||||||
|
default:
|
||||||
|
dst.Del(xForwardedMethod)
|
||||||
|
}
|
||||||
|
|
||||||
|
xfp := req.Header.Get(xForwardedProto)
|
||||||
|
switch {
|
||||||
|
case xfp != "":
|
||||||
|
dst.Set(xForwardedProto, xfp)
|
||||||
|
case req.TLS != nil:
|
||||||
|
dst.Set(xForwardedProto, "https")
|
||||||
|
default:
|
||||||
|
dst.Set(xForwardedProto, "http")
|
||||||
|
}
|
||||||
|
|
||||||
|
if xfp := req.Header.Get(xForwardedPort); xfp != "" {
|
||||||
|
dst.Set(xForwardedPort, xfp)
|
||||||
|
}
|
||||||
|
|
||||||
|
xfh := req.Header.Get(xForwardedHost)
|
||||||
|
switch {
|
||||||
|
case xfh != "":
|
||||||
|
dst.Set(xForwardedHost, xfh)
|
||||||
|
case req.Host != "":
|
||||||
|
dst.Set(xForwardedHost, req.Host)
|
||||||
|
default:
|
||||||
|
dst.Del(xForwardedHost)
|
||||||
|
}
|
||||||
|
|
||||||
|
xfu := req.Header.Get(xForwardedURI)
|
||||||
|
switch {
|
||||||
|
case xfu != "":
|
||||||
|
dst.Set(xForwardedURI, xfu)
|
||||||
|
case req.URL.RequestURI() != "":
|
||||||
|
dst.Set(xForwardedURI, req.URL.RequestURI())
|
||||||
|
default:
|
||||||
|
dst.Del(xForwardedURI)
|
||||||
|
}
|
||||||
|
}
|
||||||
3
go.mod
Normal file
3
go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module github.com/astappiev/traefik-umami-feeder
|
||||||
|
|
||||||
|
go 1.19
|
||||||
90
readme.md
Normal file
90
readme.md
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
# Umami Feeder for Traefik
|
||||||
|
|
||||||
|
This plugin for enables [Traefik Reverse Proxy](https://traefik.io/traefik/) to feed [Umami Analytics](https://umami.is)
|
||||||
|
with tracking events.
|
||||||
|
|
||||||
|
It was created as an alternative to [traefik-umami-plugin](https://github.com/1cedsoda/traefik-umami-plugin) and
|
||||||
|
inspired by idea of [Plausible Feeder Traefik Plugin](https://github.com/safing/plausiblefeeder).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- [X] Super easy to setup, one middleware for all websites
|
||||||
|
- [X] Server Side Tracking, no need to add JavaScript to your websites
|
||||||
|
- [X] Fast and private analytics
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
To [add this plugin to traefik](https://plugins.traefik.io/install) reference this repository as a plugin in the static
|
||||||
|
config.
|
||||||
|
The version references a git tag.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
experimental:
|
||||||
|
plugins:
|
||||||
|
umami-feeder:
|
||||||
|
moduleName: github.com/astappiev/traefik-umami-feeder
|
||||||
|
version: v1.0.0 # replace with latest version available
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[experimental.plugins.umami-feeder]
|
||||||
|
moduleName = "github.com/astappiev/traefik-umami-feeder"
|
||||||
|
version = "v1.0.0" # replace with latest version available
|
||||||
|
```
|
||||||
|
|
||||||
|
With the plugin installed, you can configure a middleware in a dynamic configuration such as a `config.yml` or docker
|
||||||
|
labels.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
my-umami-middleware:
|
||||||
|
plugin:
|
||||||
|
umami-feeder:
|
||||||
|
umamiHost: "http://umami:3000"
|
||||||
|
websites:
|
||||||
|
"example.com": "d4617504-241c-4797-8eab-5939b367b3ad"
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[http.middlewares]
|
||||||
|
[http.middlewares.my-umami-middleware.plugin.umami-feeder]
|
||||||
|
umamiHost = "umami:3000"
|
||||||
|
|
||||||
|
[http.middlewares.my-umami-middleware.plugin.umami-feeder.websites]
|
||||||
|
"example.com" = "d4617504-241c-4797-8eab-5939b367b3ad"
|
||||||
|
```
|
||||||
|
|
||||||
|
You have an option to give a list of domains to track (and their website IDs on Umami). \
|
||||||
|
Or, you can give a token and the list will be fetched from Umami. For this, you need either [retrieve the token yourself](https://umami.is/docs/api/authentication), or use
|
||||||
|
username/password instead.
|
||||||
|
|
||||||
|
After that, you need to add the middleware to a [router](https://doc.traefik.io/traefik/routing/routers/#middlewares_1).
|
||||||
|
Remember to reference the
|
||||||
|
correct [provider namespace](https://doc.traefik.io/traefik/providers/overview/#provider-namespace).
|
||||||
|
|
||||||
|
E.g. as Docker labels:
|
||||||
|
```yaml
|
||||||
|
- "traefik.http.routers.whoami.middlewares=my-umami-middleware@file"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, for all routers in a static configuration:
|
||||||
|
```yaml
|
||||||
|
entryPoints:
|
||||||
|
web:
|
||||||
|
http:
|
||||||
|
middlewares:
|
||||||
|
- my-umami-middleware@file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
| key | default | type | description |
|
||||||
|
|---------------------|---------|----------|-------------------------------------------------------------------------------------------------------------|
|
||||||
|
| `umamiHost` | - | `string` | Umami server url, reachable from within traefik (container), e.g. `http://umami:3000` |
|
||||||
|
| `umamiToken` | - | `string` | An API Token, used to automatize work with websites, not needed if you provide `websites` |
|
||||||
|
| `umamiUsername` | - | `string` | An alternative to `umamiToken`, you can provide an username and password |
|
||||||
|
| `umamiPassword` | - | `string` | Only in combination with `umamiUsername` |
|
||||||
|
| `websites` | - | `map` | A map of hostnames and their associated Umami IDs. Can also be used to override or extend fetched websites |
|
||||||
|
| `createNewWebsites` | false | `bool` | If set to `true`, will try to create a new website on Umami, if domain not found there |
|
||||||
|
| `debug` | false | `bool` | Something doesn't work? Set to `true` to see more logs (plugins doesn't have access to Traefik's log level) |
|
||||||
171
umami.go
Normal file
171
umami.go
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
package traefik_umami_feeder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config the plugin configuration.
|
||||||
|
type Config struct {
|
||||||
|
UmamiHost string `json:"umamiHost"`
|
||||||
|
// it is optional, but either UmamiToken or Websites should be set
|
||||||
|
UmamiToken string `json:"umamiToken"`
|
||||||
|
// as an alternative to UmamiToken, you can set UmamiUsername and UmamiPassword to authenticate
|
||||||
|
UmamiUsername string `json:"umamiUsername"`
|
||||||
|
UmamiPassword string `json:"umamiPassword"`
|
||||||
|
// if both UmamiToken and Websites are set, Websites will be used to override the websites in the API
|
||||||
|
Websites map[string]string `json:"websites"`
|
||||||
|
// if createNewWebsites is set to true, the plugin will create new websites in the API, UmamiToken is required
|
||||||
|
CreateNewWebsites bool `json:"createNewWebsites"`
|
||||||
|
Debug bool `json:"debug"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateConfig creates the default plugin configuration.
|
||||||
|
func CreateConfig() *Config {
|
||||||
|
return &Config{
|
||||||
|
UmamiHost: "",
|
||||||
|
UmamiToken: "",
|
||||||
|
UmamiUsername: "",
|
||||||
|
UmamiPassword: "",
|
||||||
|
Websites: map[string]string{},
|
||||||
|
CreateNewWebsites: false,
|
||||||
|
Debug: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UmamiFeeder a UmamiFeeder plugin.
|
||||||
|
type UmamiFeeder struct {
|
||||||
|
next http.Handler
|
||||||
|
name string
|
||||||
|
debug bool
|
||||||
|
logHandler *log.Logger
|
||||||
|
|
||||||
|
UmamiHost string
|
||||||
|
UmamiToken string
|
||||||
|
Websites map[string]string
|
||||||
|
CreateNewWebsites bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// New created a new Demo plugin.
|
||||||
|
func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) {
|
||||||
|
// construct
|
||||||
|
h := &UmamiFeeder{
|
||||||
|
next: next,
|
||||||
|
name: name,
|
||||||
|
debug: config.Debug,
|
||||||
|
logHandler: log.New(os.Stdout, "", 0),
|
||||||
|
|
||||||
|
UmamiHost: config.UmamiHost,
|
||||||
|
UmamiToken: config.UmamiToken,
|
||||||
|
Websites: config.Websites,
|
||||||
|
CreateNewWebsites: config.CreateNewWebsites,
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.UmamiUsername != "" && config.UmamiPassword != "" {
|
||||||
|
token, err := getToken(h.UmamiHost, config.UmamiUsername, config.UmamiPassword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get token: %w", err)
|
||||||
|
}
|
||||||
|
if token == "" {
|
||||||
|
return nil, fmt.Errorf("retrieved token is empty")
|
||||||
|
}
|
||||||
|
h.trace("token received %s", token)
|
||||||
|
h.UmamiToken = token
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.UmamiHost == "" {
|
||||||
|
return nil, fmt.Errorf("`umamiHost` is not set")
|
||||||
|
}
|
||||||
|
if h.UmamiToken == "" && len(h.Websites) == 0 {
|
||||||
|
return nil, fmt.Errorf("either `umamiToken` or `websites` should be set")
|
||||||
|
}
|
||||||
|
if h.UmamiToken == "" && h.CreateNewWebsites {
|
||||||
|
return nil, fmt.Errorf("`umamiToken` is required to create new websites")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.UmamiToken != "" {
|
||||||
|
websites, err := fetchWebsites(h.UmamiHost, h.UmamiToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch websites: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, website := range *websites {
|
||||||
|
if _, ok := h.Websites[website.Domain]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Websites[website.Domain] = website.ID
|
||||||
|
h.trace("fetched websiteId for: %s", website.Domain)
|
||||||
|
}
|
||||||
|
h.log("websites fetched")
|
||||||
|
}
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UmamiFeeder) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
|
if h.shouldBeTracked(req) {
|
||||||
|
go h.trackRequest(req)
|
||||||
|
} else {
|
||||||
|
h.trace("Tracking skipped %v", req.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.next.ServeHTTP(rw, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UmamiFeeder) shouldBeTracked(req *http.Request) bool {
|
||||||
|
if h.CreateNewWebsites {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
hostname := parseDomainFromHost(req.Host)
|
||||||
|
if _, ok := h.Websites[hostname]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UmamiFeeder) trackRequest(req *http.Request) {
|
||||||
|
hostname := parseDomainFromHost(req.Host)
|
||||||
|
websiteId, ok := h.Websites[hostname]
|
||||||
|
if !ok {
|
||||||
|
website, err := createWebsite(h.UmamiHost, h.UmamiToken, hostname)
|
||||||
|
if err != nil {
|
||||||
|
h.log("failed to create website: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Websites[website.Domain] = website.ID
|
||||||
|
websiteId = website.ID
|
||||||
|
h.trace("created website for: %s", website.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
sendBody, sendHeaders := buildSendBody(req, websiteId)
|
||||||
|
h.trace("sending tracking request %s with body %v %v", req.URL, sendBody, sendHeaders)
|
||||||
|
|
||||||
|
_, err := sendRequest(h.UmamiHost+"/api/send", sendBody, sendHeaders)
|
||||||
|
if err != nil {
|
||||||
|
h.trace("failed to send tracking: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UmamiFeeder) log(message string) {
|
||||||
|
if h.logHandler != nil {
|
||||||
|
time := time.Now().Format("2006-01-02T15:04:05Z")
|
||||||
|
h.logHandler.Printf("time=\"%s\" level=info msg=\"[traefik-umami-feeder] %s\"", time, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arguments are handled in the manner of [fmt.Printf].
|
||||||
|
func (h *UmamiFeeder) trace(format string, v ...any) {
|
||||||
|
if h.logHandler != nil && h.debug {
|
||||||
|
time := time.Now().Format("2006-01-02T15:04:05Z")
|
||||||
|
h.logHandler.Printf("time=\"%s\" level=trace msg=\"[traefik-umami-feeder] %s\"", time, fmt.Sprintf(format, v...))
|
||||||
|
}
|
||||||
|
}
|
||||||
72
umami_send.go
Normal file
72
umami_send.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package traefik_umami_feeder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Copied from https://github.com/1cedsoda/traefik-umami-plugin/blob/master/umami_tracking.go
|
||||||
|
// Licensed as Apache-2.0 license
|
||||||
|
|
||||||
|
type SendPayload struct {
|
||||||
|
Website string `json:"website"`
|
||||||
|
Hostname string `json:"hostname"`
|
||||||
|
Language string `json:"language"`
|
||||||
|
Url string `json:"url"`
|
||||||
|
Referer string `json:"referer"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Data map[string]interface{} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendBody struct {
|
||||||
|
Payload SendPayload `json:"payload"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildPayload(req *http.Request, websiteId string) SendPayload {
|
||||||
|
return SendPayload{
|
||||||
|
Website: websiteId,
|
||||||
|
Hostname: parseDomainFromHost(req.Host),
|
||||||
|
Language: parseAcceptLanguage(req.Header.Get("Accept-Language")),
|
||||||
|
Url: req.URL.String(),
|
||||||
|
Referer: req.Referer(),
|
||||||
|
Name: "traefik",
|
||||||
|
Data: map[string]interface{}{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// opts the port from the host.
|
||||||
|
func parseDomainFromHost(host string) string {
|
||||||
|
// check if the host has a port
|
||||||
|
if strings.Contains(host, ":") {
|
||||||
|
host = strings.Split(host, ":")[0]
|
||||||
|
}
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseAcceptLanguagePattern = `([a-zA-Z\-]+)(?:;q=\d\.\d)?(?:,\s)?`
|
||||||
|
|
||||||
|
var parseAcceptLanguageRegexp = regexp.MustCompile(parseAcceptLanguagePattern)
|
||||||
|
|
||||||
|
func parseAcceptLanguage(acceptLanguage string) string {
|
||||||
|
matches := parseAcceptLanguageRegexp.FindAllStringSubmatch(acceptLanguage, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return matches[0][1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSendBody(clientReq *http.Request, websiteId string) (SendBody, http.Header) {
|
||||||
|
body := SendBody{
|
||||||
|
Payload: buildPayload(clientReq, websiteId),
|
||||||
|
Type: "event",
|
||||||
|
}
|
||||||
|
|
||||||
|
var headers = make(http.Header)
|
||||||
|
headers.Set("Content-Type", "application/json")
|
||||||
|
copyHeaders(headers, clientReq.Header)
|
||||||
|
removeHeaders(headers, hopHeaders...)
|
||||||
|
writeXForwardedHeaders(headers, clientReq)
|
||||||
|
return body, headers
|
||||||
|
}
|
||||||
24
umami_token.go
Normal file
24
umami_token.go
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
package traefik_umami_feeder
|
||||||
|
|
||||||
|
type Auth struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthResponse struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func getToken(umamiHost string, umamiUsername string, umamiPassword string) (string, error) {
|
||||||
|
var result AuthResponse
|
||||||
|
err := sendRequestAndParse(umamiHost+"/api/auth/login", Auth{
|
||||||
|
Username: umamiUsername,
|
||||||
|
Password: umamiPassword,
|
||||||
|
}, nil, &result)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.Token, nil
|
||||||
|
}
|
||||||
72
umami_utils.go
Normal file
72
umami_utils.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package traefik_umami_feeder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sendRequest(url string, body interface{}, headers http.Header) (*http.Response, error) {
|
||||||
|
var req *http.Request
|
||||||
|
var err error
|
||||||
|
|
||||||
|
if body != nil {
|
||||||
|
bodyJson, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err = http.NewRequestWithContext(context.Background(), http.MethodPost, url, bytes.NewReader(bodyJson))
|
||||||
|
} else {
|
||||||
|
req, err = http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if headers != nil {
|
||||||
|
req.Header = headers
|
||||||
|
}
|
||||||
|
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{}
|
||||||
|
response, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
status := response.StatusCode
|
||||||
|
if status < 200 || status >= 300 {
|
||||||
|
return nil, fmt.Errorf("request failed with status %d", status)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendRequestAndParse(url string, body interface{}, headers http.Header, value interface{}) error {
|
||||||
|
resp, err := sendRequest(url, body, headers)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = json.Unmarshal(respBody, &value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
52
umami_websites.go
Normal file
52
umami_websites.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package traefik_umami_feeder
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WebsitesResponse struct {
|
||||||
|
Data []Website `json:"data"`
|
||||||
|
Count int `json:"count"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"pageSize"`
|
||||||
|
OrderBy string `json:"orderBy"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Website struct {
|
||||||
|
ID string `json:"id,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Domain string `json:"domain"`
|
||||||
|
CreatedAt time.Time `json:"createdAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func createWebsite(umamiHost string, umamiToken string, websiteDomain string) (*Website, error) {
|
||||||
|
var headers = make(http.Header)
|
||||||
|
headers.Set("Authorization", "Bearer "+umamiToken)
|
||||||
|
|
||||||
|
var result Website
|
||||||
|
err := sendRequestAndParse(umamiHost+"/api/websites", Website{
|
||||||
|
Name: websiteDomain,
|
||||||
|
Domain: websiteDomain,
|
||||||
|
}, headers, &result)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchWebsites(umamiHost string, umamiToken string) (*[]Website, error) {
|
||||||
|
var headers = make(http.Header)
|
||||||
|
headers.Set("Authorization", "Bearer "+umamiToken)
|
||||||
|
|
||||||
|
var result WebsitesResponse
|
||||||
|
err := sendRequestAndParse(umamiHost+"/api/websites?pageSize=200", nil, headers, &result)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result.Data, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue