Release to master

This commit is contained in:
Daniel 2021-02-11 11:09:06 +01:00
commit d825f813ba
57 changed files with 3495 additions and 670 deletions

87
.github/workflows/go.yml vendored Normal file
View file

@ -0,0 +1,87 @@
name: Go
on:
push:
branches:
- master
- develop
pull_request:
branches:
- master
- develop
jobs:
lint:
name: Linter
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '^1.15'
# nektos/act does not have sudo install but we need it on GH actions so
# try to install it.
- name: Install sudo
run: bash -c "apt-get update || true ; apt-get install sudo || true"
env:
DEBIAN_FRONTEND: noninteractive
- name: Install git and gcc
run: sudo bash -c "apt-get update && apt-get install -y git gcc libc6-dev"
env:
DEBIAN_FRONTEND: noninteractive
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v2
with:
version: v1.29
only-new-issues: true
args: -c ./.golangci.yml
skip-go-installation: true
- name: Get dependencies
run: go mod download
- name: Run go vet
run: go vet ./...
- name: Install golint
run: bash -c "GOBIN=$(pwd) go get -u golang.org/x/lint/golint"
- name: Run golint
run: ./golint -set_exit_status -min_confidence 1.0 ./...
- name: Run gofmt
run: bash -c "test -z $(gofmt -s -l .)"
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- uses: actions/setup-go@v2
with:
go-version: '^1.15'
# nektos/act does not have sudo install but we need it on GH actions so
# try to install it.
- name: Install sudo
run: bash -c "apt-get update || true ; apt-get install sudo || true"
env:
DEBIAN_FRONTEND: noninteractive
- name: Install git and gcc
run: sudo bash -c "apt-get update && apt-get install -y git gcc libc6-dev"
env:
DEBIAN_FRONTEND: noninteractive
- name: Get dependencies
run: go mod download
- name: Test
run: ./test --test-only

1
.gitignore vendored
View file

@ -2,4 +2,5 @@ portbase
apitest
misc
go.mod.*
vendor

View file

@ -8,6 +8,8 @@ linters:
- whitespace
- wsl
- gomnd
- goerr113
- testpackage
linters-settings:
godox:

View file

@ -17,7 +17,7 @@ branches:
install:
- go get -d -u github.com/golang/dep
- go install github.com/golang/dep/cmd/dep
- dep ensure
- go mod download
- ./test install
script: ./test --scripted

400
Gopkg.lock generated
View file

@ -1,400 +0,0 @@
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
[[projects]]
branch = "master"
digest = "1:9cac35beaf0b218e41036e92ec8f664146227a6549a3bd307bb7b223cb40313b"
name = "github.com/AndreasBriese/bbloom"
packages = ["."]
pruneopts = ""
revision = "46b345b51c9667fcbaad862a370d73bd7aa802b6"
[[projects]]
digest = "1:6146fda730c18186631e91e818d995e759e7cbe27644d6871ccd469f6865c686"
name = "github.com/StackExchange/wmi"
packages = ["."]
pruneopts = ""
revision = "cbe66965904dbe8a6cd589e2298e5d8b986bd7dd"
version = "1.1.0"
[[projects]]
digest = "1:3b186fcaa1c0b374249e76601cd4d5c8c7f0712f8ad419f2e8288c7b9e7ed520"
name = "github.com/aead/serpent"
packages = ["."]
pruneopts = ""
revision = "fba169763ea663f7496376e5cdf709e4c7504704"
version = "v0.1"
[[projects]]
digest = "1:5680f8c40e48f07cb77aece3165a866aaf8276305258b3b70db8ec7ad6ddb78d"
name = "github.com/armon/go-radix"
packages = ["."]
pruneopts = ""
revision = "1a2de0c21c94309923825da3df33a4381872c795"
version = "v1.0.0"
[[projects]]
branch = "master"
digest = "1:baf770c4efa1883bb5e444614e85b8028bbad33913aca290a43298f65d9df485"
name = "github.com/bluele/gcache"
packages = ["."]
pruneopts = ""
revision = "bc40bd6538339bd4be8cb0b48fc33fdf012ddb6e"
[[projects]]
digest = "1:d25acc7560ed91f825cb9b01a1e945bb1117cdcaba19077137e2d9ffc9cf6d05"
name = "github.com/cespare/xxhash"
packages = ["."]
pruneopts = ""
revision = "d7df74196a9e781ede915320c11c378c1b2f3a1f"
version = "v2.1.1"
[[projects]]
digest = "1:0deddd908b6b4b768cfc272c16ee61e7088a60f7fe2f06c547bd3d8e1f8b8e77"
name = "github.com/davecgh/go-spew"
packages = ["spew"]
pruneopts = ""
revision = "8991bc29aa16c548c550c7ff78260e27b9ab7c73"
version = "v1.1.1"
[[projects]]
digest = "1:238e2deec0c24dca94461b2f72fe05eba40063b653677e60901af56a6fd5148b"
name = "github.com/dgraph-io/badger"
packages = [
".",
"options",
"pb",
"skl",
"table",
"trie",
"y",
]
pruneopts = ""
revision = "8760a5018cd670a5cecc79a7e927ecaf47acc434"
version = "v1.6.1"
[[projects]]
digest = "1:1d9a7f60f0ff31ba3336a9c1349c212e7076845b537c053a1703a26730145167"
name = "github.com/dgraph-io/ristretto"
packages = ["z"]
pruneopts = ""
revision = "dbc185e050f48c5190a78b4a07829d9c10afca21"
version = "v0.0.2"
[[projects]]
digest = "1:f1a75a8e00244e5ea77ff274baa9559eb877437b240ee7b278f3fc560d9f08bf"
name = "github.com/dustin/go-humanize"
packages = ["."]
pruneopts = ""
revision = "9f541cc9db5d55bce703bd99987c9d5cb8eea45e"
version = "v1.0.0"
[[projects]]
digest = "1:b6581f9180e0f2d5549280d71819ab951db9d511478c87daca95669589d505c0"
name = "github.com/go-ole/go-ole"
packages = [
".",
"oleutil",
]
pruneopts = ""
revision = "97b6244175ae18ea6eef668034fd6565847501c9"
version = "v1.2.4"
[[projects]]
digest = "1:fd608a9f543f30dd5a086b2cdd0bce71b65bca62798ff7ce21348806cdf1412d"
name = "github.com/gofrs/uuid"
packages = ["."]
pruneopts = ""
revision = "abfe1881e60ef34074c1b8d8c63b42565c356ed6"
version = "v3.3.0"
[[projects]]
digest = "1:02c7a8570f619bdb5620e8f6a16407455c8e63433d8a9e829f618813dc7f89a5"
name = "github.com/golang/protobuf"
packages = ["proto"]
pruneopts = ""
revision = "d04d7b157bb510b1e0c10132224b616ac0e26b17"
version = "v1.4.2"
[[projects]]
digest = "1:20dc576ad8f98fe64777c62f090a9b37dd67c62b23fe42b429c2c41936aa8a9c"
name = "github.com/google/renameio"
packages = ["."]
pruneopts = ""
revision = "f0e32980c006571efd537032e5f9cd8c1a92819e"
version = "v0.1.0"
[[projects]]
digest = "1:fbab76ba211c99fcd45a481a32530efc229f3510fd94889f361dcaf13ff05fe0"
name = "github.com/gorilla/mux"
packages = ["."]
pruneopts = ""
revision = "75dcda0896e109a2a22c9315bca3bb21b87b2ba5"
version = "v1.7.4"
[[projects]]
digest = "1:5122b0b5a933bd0e16b887aa7844933c8a09fb5ba9b5f5651253cb5336c6462a"
name = "github.com/gorilla/websocket"
packages = ["."]
pruneopts = ""
revision = "b65e62901fc1c0d968042419e74789f6af455eb9"
version = "v1.4.2"
[[projects]]
digest = "1:eaed935e3637c60ad9897e54ab3419c18b91775d6e3af339dec54aeefb48b8d6"
name = "github.com/hashicorp/errwrap"
packages = ["."]
pruneopts = ""
revision = "7b00e5db719c64d14dd0caaacbd13e76254d02c0"
version = "v1.1.0"
[[projects]]
digest = "1:c6e569ffa34fcd24febd3562bff0520a104d15d1a600199cb3141debf2e58c89"
name = "github.com/hashicorp/go-multierror"
packages = ["."]
pruneopts = ""
revision = "2004d9dba6b07a5b8d133209244f376680f9d472"
version = "v1.1.0"
[[projects]]
digest = "1:2f0c811248aeb64978037b357178b1593372439146bda860cb16f2c80785ea93"
name = "github.com/hashicorp/go-version"
packages = ["."]
pruneopts = ""
revision = "ac23dc3fea5d1a983c43f6a0f6e2c13f0195d8bd"
version = "v1.2.0"
[[projects]]
digest = "1:870d441fe217b8e689d7949fef6e43efbc787e50f200cb1e70dbca9204a1d6be"
name = "github.com/inconshreveable/mousetrap"
packages = ["."]
pruneopts = ""
revision = "76626ae9c91c4f2a10f34cad8ce83ea42c93bb75"
version = "v1.0"
[[projects]]
digest = "1:c45802472e0c06928cd997661f2af610accd85217023b1d5f6331bebce0671d3"
name = "github.com/pkg/errors"
packages = ["."]
pruneopts = ""
revision = "614d223910a179a466c1767a985424175c39b465"
version = "v0.9.1"
[[projects]]
digest = "1:256484dbbcd271f9ecebc6795b2df8cad4c458dd0f5fd82a8c2fa0c29f233411"
name = "github.com/pmezard/go-difflib"
packages = ["difflib"]
pruneopts = ""
revision = "792786c7400a136282c1664665ae0a8db921c6c2"
version = "v1.0.0"
[[projects]]
digest = "1:90da107a52bdacf25384ae6fc2889df9cf3c956ecde1561825b13ba70a8283b5"
name = "github.com/seehuhn/fortuna"
packages = ["."]
pruneopts = ""
revision = "6f13c9c4f925cf5dd33c070a2f0fbe596385917b"
version = "v1.0.1"
[[projects]]
digest = "1:bf9a02016247817c7ca7dc0d42ee608ffdef20de1474f6d676cfd80a64068a38"
name = "github.com/seehuhn/sha256d"
packages = ["."]
pruneopts = ""
revision = "4a65999787a5902349359436a10df5fe59a10a64"
version = "v1.0.0"
[[projects]]
digest = "1:16f319cf21ddf49f27b3a2093d68316840dc25ec5c2a0a431a4a4fc01ea707e2"
name = "github.com/shirou/gopsutil"
packages = [
"cpu",
"host",
"internal/common",
"mem",
"net",
"process",
]
pruneopts = ""
revision = "a81cf97fce2300934e6c625b9917103346c26ba3"
version = "v2.20.4"
[[projects]]
digest = "1:bff75d4f1a2d2c4b8f4b46ff5ac230b80b5fa49276f615900cba09fe4c97e66e"
name = "github.com/spf13/cobra"
packages = ["."]
pruneopts = ""
revision = "a684a6d7f5e37385d954dd3b5a14fc6912c6ab9d"
version = "v1.0.0"
[[projects]]
digest = "1:688428eeb1ca80d92599eb3254bdf91b51d7e232fead3a73844c1f201a281e51"
name = "github.com/spf13/pflag"
packages = ["."]
pruneopts = ""
revision = "2e9d26c8c37aae03e3f9d4e90b7116f5accb7cab"
version = "v1.0.5"
[[projects]]
digest = "1:83fd2513b9f6ae0997bf646db6b74e9e00131e31002116fda597175f25add42d"
name = "github.com/stretchr/testify"
packages = [
"assert",
"require",
]
pruneopts = ""
revision = "f654a9112bbeac49ca2cd45bfbe11533c4666cf8"
version = "v1.6.1"
[[projects]]
branch = "master"
digest = "1:86e6712cfd4070a2120c03fcec41cfcbbc51813504a74e28d74479edfaf669ee"
name = "github.com/tevino/abool"
packages = ["."]
pruneopts = ""
revision = "9b9efcf221b50905aab9bbabd3daed56dc10f339"
[[projects]]
digest = "1:029181b60f6ea672544d102f81d7d9508a45a70e17ce8a649535959e0249d56b"
name = "github.com/tidwall/gjson"
packages = ["."]
pruneopts = ""
revision = "f042915ca17de35980544c91ab2c8ceb73b682f2"
version = "v1.6.0"
[[projects]]
digest = "1:72511ec1089fee111c995492d1d390a38ac7ab888aabdb1188985f2a1719c599"
name = "github.com/tidwall/match"
packages = ["."]
pruneopts = ""
revision = "33827db735fff6510490d69a8622612558a557ed"
version = "v1.0.1"
[[projects]]
digest = "1:3d4deb9e8160077721a79fc1b5a6ce27016c98f9f8e631090d5af8f50120ddf0"
name = "github.com/tidwall/pretty"
packages = ["."]
pruneopts = ""
revision = "b2475501f89994f7ea30b3c94ba86b49079961fe"
version = "v1.0.1"
[[projects]]
digest = "1:b749bf81c4c595c1218489d2fa4d90e4a953a5fd11420722a2f57c0c5beecb92"
name = "github.com/tidwall/sjson"
packages = ["."]
pruneopts = ""
revision = "11cb24d8421de3e1bb5c5efb066a03037150568d"
version = "v1.1.1"
[[projects]]
digest = "1:2a591e844f6019284ded9f5095e53764c3d69bb8e7d1cf2b318f1b7e25864508"
name = "go.etcd.io/bbolt"
packages = ["."]
pruneopts = ""
revision = "68cc10a767ea1c6b9e8dcb9847317ff192d6d974"
version = "v1.3.4"
[[projects]]
branch = "master"
digest = "1:305d718b88fcd3b251b910416367de49af1e7944a9a17efabedab5f0ba7745de"
name = "golang.org/x/net"
packages = [
"internal/timeseries",
"trace",
]
pruneopts = ""
revision = "0ba52f642ac2f9371a88bfdde41f4b4e195a37c0"
[[projects]]
branch = "master"
digest = "1:10d47e7094ce8dd202cca920e4c58a68ba1d113908c30fb0cc8590b7d333a348"
name = "golang.org/x/sync"
packages = ["errgroup"]
pruneopts = ""
revision = "67f06af15bc961c363a7260195bcd53487529a21"
[[projects]]
branch = "master"
digest = "1:bf837d996e7dfe7b819cbe53c8c9733e93228577f0561e43996b9ef0ea8a68a9"
name = "golang.org/x/sys"
packages = [
"internal/unsafeheader",
"unix",
"windows",
]
pruneopts = ""
revision = "05986578812163b26672dabd9b425240ae2bb0ad"
[[projects]]
digest = "1:f3bbd8ea54cde834a67fc50f27cbdf35eb950225953fb304891be068ba82d163"
name = "google.golang.org/protobuf"
packages = [
"encoding/prototext",
"encoding/protowire",
"internal/descfmt",
"internal/descopts",
"internal/detrand",
"internal/encoding/defval",
"internal/encoding/messageset",
"internal/encoding/tag",
"internal/encoding/text",
"internal/errors",
"internal/fieldnum",
"internal/fieldsort",
"internal/filedesc",
"internal/filetype",
"internal/flags",
"internal/genname",
"internal/impl",
"internal/mapsort",
"internal/pragma",
"internal/set",
"internal/strs",
"internal/version",
"proto",
"reflect/protoreflect",
"reflect/protoregistry",
"runtime/protoiface",
"runtime/protoimpl",
]
pruneopts = ""
revision = "d165be301fb1e13390ad453281ded24385fd8ebc"
version = "v1.23.0"
[[projects]]
branch = "v3"
digest = "1:0aa137e32b369fbb8c0f4d579e653c72e3431b6cc8cb3c19d6a21a14209031fd"
name = "gopkg.in/yaml.v3"
packages = ["."]
pruneopts = ""
revision = "a5ece683394c3b88d90572e44d36c93aea492c2c"
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
input-imports = [
"github.com/aead/serpent",
"github.com/armon/go-radix",
"github.com/bluele/gcache",
"github.com/davecgh/go-spew/spew",
"github.com/dgraph-io/badger",
"github.com/gofrs/uuid",
"github.com/google/renameio",
"github.com/gorilla/mux",
"github.com/gorilla/websocket",
"github.com/hashicorp/go-multierror",
"github.com/hashicorp/go-version",
"github.com/seehuhn/fortuna",
"github.com/shirou/gopsutil/host",
"github.com/spf13/cobra",
"github.com/stretchr/testify/assert",
"github.com/stretchr/testify/require",
"github.com/tevino/abool",
"github.com/tidwall/gjson",
"github.com/tidwall/sjson",
"go.etcd.io/bbolt",
"golang.org/x/sync/errgroup",
"golang.org/x/sys/windows",
]
solver-name = "gps-cdcl"
solver-version = 1

View file

@ -1,25 +0,0 @@
# Gopkg.toml example
#
# Refer to https://golang.github.io/dep/docs/Gopkg.toml.html
# for detailed Gopkg.toml documentation.
#
# required = ["github.com/user/thing/cmd/thing"]
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
#
# [[constraint]]
# name = "github.com/user/project"
# version = "1.0.0"
#
# [[constraint]]
# name = "github.com/user/project2"
# branch = "dev"
# source = "github.com/myfork/project2"
#
# [[override]]
# name = "github.com/x/y"
# version = "2.4.0"
#
# [prune]
# non-go = false
# go-tests = true
# unused-packages = true

30
api/auth_wrapper.go Normal file
View file

@ -0,0 +1,30 @@
package api
import "net/http"
// WrapInAuthHandler wraps a simple http.HandlerFunc into a handler that
// exposes the required API permissions for this handler.
func WrapInAuthHandler(fn http.HandlerFunc, read, write Permission) http.Handler {
return &wrappedAuthenticatedHandler{
HandlerFunc: fn,
read: read,
write: write,
}
}
type wrappedAuthenticatedHandler struct {
http.HandlerFunc
read Permission
write Permission
}
// ReadPermission returns the read permission for the handler.
func (wah *wrappedAuthenticatedHandler) ReadPermission(r *http.Request) Permission {
return wah.read
}
// WritePermission returns the write permission for the handler.
func (wah *wrappedAuthenticatedHandler) WritePermission(r *http.Request) Permission {
return wah.write
}

View file

@ -4,47 +4,128 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/tevino/abool"
"github.com/safing/portbase/modules"
"github.com/safing/portbase/log"
"github.com/safing/portbase/rng"
)
const (
sessionCookieName = "Portmaster-API-Token"
sessionCookieTTL = 5 * time.Minute
)
var (
validTokens = make(map[string]time.Time)
validTokensLock sync.Mutex
apiKeys = make(map[string]*AuthToken)
apiKeysLock sync.Mutex
authFnLock sync.Mutex
authFn Authenticator
authFnSet = abool.New()
authFn AuthenticatorFunc
// ErrAPIAccessDeniedMessage should be returned by Authenticator functions in
// order to signify a blocked request, including a error message for the user.
sessions = make(map[string]*session)
sessionsLock sync.Mutex
// ErrAPIAccessDeniedMessage should be wrapped by errors returned by
// AuthenticatorFunc in order to signify a blocked request, including a error
// message for the user. This is an empty message on purpose, as to allow the
// function to define the full text of the error shown to the user.
ErrAPIAccessDeniedMessage = errors.New("")
)
const (
cookieName = "Portmaster-API-Token"
// Permission defines an API requests permission.
type Permission int8
cookieTTL = 5 * time.Minute
const (
// NotFound declares that the operation does not exist.
NotFound Permission = -2
// Dynamic declares that the operation requires permission to be processed,
// but anyone can execute the operation, as it reacts to permissions itself.
Dynamic Permission = -1
// NotSupported declares that the operation is not supported.
NotSupported Permission = 0
// PermitAnyone declares that anyone can execute the operation without any
// authentication.
PermitAnyone Permission = 1
// PermitUser declares that the operation may be executed by authenticated
// third party applications that are categorized as representing a simple
// user and is limited in access.
PermitUser Permission = 2
// PermitAdmin declares that the operation may be executed by authenticated
// third party applications that are categorized as representing an
// administrator and has broad in access.
PermitAdmin Permission = 3
// PermitSelf declares that the operation may only be executed by the
// software itself and its own (first party) components.
PermitSelf Permission = 4
)
// Authenticator is a function that can be set as the authenticator for the API endpoint. If none is set, all requests will be permitted.
type Authenticator func(ctx context.Context, s *http.Server, r *http.Request) (err error)
// AuthenticatorFunc is a function that can be set as the authenticator for the
// API endpoint. If none is set, all requests will have full access.
// The returned AuthToken represents the permissions that the request has.
type AuthenticatorFunc func(r *http.Request, s *http.Server) (*AuthToken, error)
// AuthToken represents either a set of required or granted permissions.
// All attributes must be set when the struct is built and must not be changed
// later. Functions may be called at any time.
// The Write permission implicitly also includes reading.
type AuthToken struct {
Read Permission
Write Permission
}
type session struct {
sync.Mutex
token *AuthToken
validUntil time.Time
}
// Expired returns whether the session has expired.
func (sess *session) Expired() bool {
sess.Lock()
defer sess.Unlock()
return time.Now().After(sess.validUntil)
}
// Refresh refreshes the validity of the session with the given TTL.
func (sess *session) Refresh(ttl time.Duration) {
sess.Lock()
defer sess.Unlock()
sess.validUntil = time.Now().Add(ttl)
}
// AuthenticatedHandler defines the handler interface to specify custom
// permission for an API handler. The returned permission is the required
// permission for the request to proceed.
type AuthenticatedHandler interface {
ReadPermission(*http.Request) Permission
WritePermission(*http.Request) Permission
}
// SetAuthenticator sets an authenticator function for the API endpoint. If none is set, all requests will be permitted.
func SetAuthenticator(fn Authenticator) error {
func SetAuthenticator(fn AuthenticatorFunc) error {
if module.Online() {
return ErrAuthenticationImmutable
}
authFnLock.Lock()
defer authFnLock.Unlock()
if authFn != nil {
if !authFnSet.SetToIf(false, true) {
return ErrAuthenticationAlreadySet
}
@ -52,94 +133,397 @@ func SetAuthenticator(fn Authenticator) error {
return nil
}
func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tracer := log.Tracer(r.Context())
func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler http.Handler) *AuthToken {
tracer := log.Tracer(r.Context())
// get authenticator
authFnLock.Lock()
authenticator := authFn
authFnLock.Unlock()
// Check if request is read only.
readRequest := isReadMethod(r.Method)
// permit if no authenticator set
if authenticator == nil {
next.ServeHTTP(w, r)
return
// Get required permission for target handler.
requiredPermission := PermitSelf
if authdHandler, ok := targetHandler.(AuthenticatedHandler); ok {
if readRequest {
requiredPermission = authdHandler.ReadPermission(r)
} else {
requiredPermission = authdHandler.WritePermission(r)
}
}
// Check if we need to do any authentication at all.
switch requiredPermission {
case NotFound:
// Not found.
tracer.Trace("api: authenticated handler reported: not found")
http.Error(w, "Not found.", http.StatusNotFound)
return nil
case NotSupported:
// A read or write permission can be marked as not supported.
tracer.Trace("api: authenticated handler reported: not supported")
http.Error(w, "Method not allowed.", http.StatusMethodNotAllowed)
return nil
case PermitAnyone:
// Don't process permissions, as we don't need them.
tracer.Tracef("api: granted %s access to public handler", r.RemoteAddr)
return &AuthToken{
Read: PermitAnyone,
Write: PermitAnyone,
}
case Dynamic:
// Continue processing permissions, but treat as PermitAnyone.
requiredPermission = PermitAnyone
}
// The required permission must match the request permission values after
// handling the specials.
if requiredPermission < PermitAnyone || requiredPermission > PermitSelf {
tracer.Warningf(
"api: handler returned invalid permission: %s (%d)",
requiredPermission,
requiredPermission,
)
http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError)
return nil
}
// Authenticate request.
token, handled := checkAuth(w, r, requiredPermission > PermitAnyone)
switch {
case handled:
return nil
case token == nil:
// Use default permissions.
token = &AuthToken{
Read: PermitAnyone,
Write: PermitAnyone,
}
}
// Get effective permission for request.
var requestPermission Permission
if readRequest {
requestPermission = token.Read
} else {
requestPermission = token.Write
}
// Check for valid request permission.
if requestPermission < PermitAnyone || requestPermission > PermitSelf {
tracer.Warningf(
"api: authenticator returned invalid permission: %s (%d)",
requestPermission,
requestPermission,
)
http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError)
return nil
}
// Check permission.
if requestPermission < requiredPermission {
// If the token is strictly public, return an authentication request.
if token.Read == PermitAnyone && token.Write == PermitAnyone {
w.Header().Set("WWW-Authenticate", "Bearer realm=Portmaster API")
http.Error(w, "Authorization required.", http.StatusUnauthorized)
return nil
}
// check existing auth cookie
c, err := r.Cookie(cookieName)
if err == nil {
// get token
validTokensLock.Lock()
validUntil, valid := validTokens[c.Value]
validTokensLock.Unlock()
// Otherwise just inform of insufficient permissions.
http.Error(w, "Insufficient permissions.", http.StatusForbidden)
return nil
}
// check if token is valid
if valid && time.Now().Before(validUntil) {
tracer.Tracef("api: auth token %s is valid, refreshing", c.Value)
// refresh cookie
validTokensLock.Lock()
validTokens[c.Value] = time.Now().Add(cookieTTL)
validTokensLock.Unlock()
// continue
next.ServeHTTP(w, r)
return
}
tracer.Tracef("api: granted %s access to protected handler", r.RemoteAddr)
tracer.Tracef("api: provided auth token %s is invalid", c.Value)
}
// get auth decision
err = authenticator(r.Context(), server, r)
if err != nil {
if errors.Is(err, ErrAPIAccessDeniedMessage) {
tracer.Warningf("api: denying api access to %s", r.RemoteAddr)
http.Error(w, err.Error(), http.StatusForbidden)
} else {
tracer.Warningf("api: authenticator failed: %s", err)
http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError)
}
return
}
// generate new token
token, err := rng.Bytes(32) // 256 bit
if err != nil {
tracer.Warningf("api: failed to generate random token: %s", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
tokenString := base64.RawURLEncoding.EncodeToString(token)
// write new cookie
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: tokenString,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
MaxAge: int(cookieTTL.Seconds()),
})
// save cookie
validTokensLock.Lock()
validTokens[tokenString] = time.Now().Add(cookieTTL)
validTokensLock.Unlock()
// serve
tracer.Tracef("api: granted %s, assigned auth token %s", r.RemoteAddr, tokenString)
next.ServeHTTP(w, r)
})
// Make a copy of the AuthToken in order mitigate the handler poisoning the
// token, as changes would apply to future requests.
return &AuthToken{
Read: token.Read,
Write: token.Write,
}
}
func cleanAuthTokens(_ context.Context, _ *modules.Task) error {
validTokensLock.Lock()
defer validTokensLock.Unlock()
func checkAuth(w http.ResponseWriter, r *http.Request, authRequired bool) (token *AuthToken, handled bool) {
// Return highest possible permissions in dev mode.
if devMode() {
return &AuthToken{
Read: PermitSelf,
Write: PermitSelf,
}, false
}
now := time.Now()
for token, validUntil := range validTokens {
if now.After(validUntil) {
delete(validTokens, token)
// Check for valid API key.
token = checkAPIKey(r)
if token != nil {
return token, false
}
// Check for valid session cookie.
token = checkSessionCookie(r)
if token != nil {
return token, false
}
// Check if an external authentication method is available.
if !authFnSet.IsSet() {
return nil, false
}
// Authenticate externally.
token, err := authFn(r, server)
if err != nil {
// Check if the authentication process failed internally.
if !errors.Is(err, ErrAPIAccessDeniedMessage) {
log.Tracer(r.Context()).Errorf("api: authenticator failed: %s", err)
http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError)
return nil, true
}
// Return authentication failure message if authentication is required.
if authRequired {
log.Tracer(r.Context()).Warningf("api: denying api access to %s", r.RemoteAddr)
http.Error(w, err.Error(), http.StatusForbidden)
return nil, true
}
return nil, false
}
// Abort if no token is returned.
if token == nil {
return nil, false
}
// Create session cookie for authenticated request.
err = createSession(w, r, token)
if err != nil {
log.Tracer(r.Context()).Warningf("api: failed to create session: %s", err)
}
return token, false
}
func checkAPIKey(r *http.Request) *AuthToken {
// Get API key from request.
key := r.Header.Get("Authorization")
if key == "" {
return nil
}
// Parse API key.
switch {
case strings.HasPrefix(key, "Bearer "):
key = strings.TrimPrefix(key, "Bearer ")
case strings.HasPrefix(key, "Basic "):
user, pass, _ := r.BasicAuth()
key = user + pass
default:
log.Tracer(r.Context()).Tracef(
"api: provided api key type %s is unsupported", strings.Split(key, " ")[0],
)
return nil
}
apiKeysLock.Lock()
defer apiKeysLock.Unlock()
// Check if the provided API key exists.
token, ok := apiKeys[key]
if !ok {
log.Tracer(r.Context()).Tracef(
"api: provided api key %s... is unknown", key[:4],
)
return nil
}
return token
}
func updateAPIKeys(_ context.Context, _ interface{}) error {
apiKeysLock.Lock()
defer apiKeysLock.Unlock()
log.Debug("api: importing possibly updated API keys from config")
// Delete current keys.
for k := range apiKeys {
delete(apiKeys, k)
}
// Parse new keys.
for _, key := range configuredAPIKeys() {
u, err := url.Parse(key)
if err != nil {
log.Errorf("api: failed to parse configured API key %s: %s", key, err)
continue
}
if u.Path == "" {
log.Errorf("api: malformed API key %s: missing path section", key)
continue
}
// Create token with default permissions.
token := &AuthToken{
Read: PermitAnyone,
Write: PermitAnyone,
}
// Update with configured permissions.
q := u.Query()
// Parse read permission.
readPermission, err := parseAPIPermission(q.Get("read"))
if err != nil {
log.Errorf("api: invalid API key %s: %s", key, err)
continue
}
token.Read = readPermission
// Parse write permission.
writePermission, err := parseAPIPermission(q.Get("write"))
if err != nil {
log.Errorf("api: invalid API key %s: %s", key, err)
continue
}
token.Write = writePermission
// Save token.
apiKeys[u.Path] = token
}
return nil
}
func checkSessionCookie(r *http.Request) *AuthToken {
// Get session cookie from request.
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil
}
// Check if session cookie is registered.
sessionsLock.Lock()
sess, ok := sessions[c.Value]
sessionsLock.Unlock()
if !ok {
log.Tracer(r.Context()).Tracef("api: provided session cookie %s is unknown", c.Value)
return nil
}
// Check if session is still valid.
if sess.Expired() {
log.Tracer(r.Context()).Tracef("api: provided session cookie %s has expired", c.Value)
return nil
}
// Refresh session and return.
sess.Refresh(sessionCookieTTL)
log.Tracer(r.Context()).Tracef("api: session cookie %s is valid, refreshing", c.Value)
return sess.token
}
func createSession(w http.ResponseWriter, r *http.Request, token *AuthToken) error {
// Generate new session key.
secret, err := rng.Bytes(32) // 256 bit
if err != nil {
return err
}
sessionKey := base64.RawURLEncoding.EncodeToString(secret)
// Set token cookie in response.
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: sessionKey,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
})
// Create session.
sess := &session{
token: token,
}
sess.Refresh(sessionCookieTTL)
// Save session.
sessionsLock.Lock()
defer sessionsLock.Unlock()
sessions[sessionKey] = sess
log.Tracer(r.Context()).Debug("api: issued session cookie")
return nil
}
func cleanSessions(_ context.Context, _ *modules.Task) error {
sessionsLock.Lock()
defer sessionsLock.Unlock()
for sessionKey, sess := range sessions {
if sess.Expired() {
delete(sessions, sessionKey)
}
}
return nil
}
func deleteSession(sessionKey string) {
sessionsLock.Lock()
defer sessionsLock.Unlock()
delete(sessions, sessionKey)
}
func isReadMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
return true
default:
return false
}
}
func parseAPIPermission(s string) (Permission, error) {
switch strings.ToLower(s) {
case "", "anyone":
return PermitAnyone, nil
case "user":
return PermitUser, nil
case "admin":
return PermitAdmin, nil
default:
return PermitAnyone, fmt.Errorf("invalid permission: %s", s)
}
}
func (p Permission) String() string {
switch p {
case NotSupported:
return "NotSupported"
case Dynamic:
return "Dynamic"
case PermitAnyone:
return "PermitAnyone"
case PermitUser:
return "PermitUser"
case PermitAdmin:
return "PermitAdmin"
case PermitSelf:
return "PermitSelf"
case NotFound:
return "NotFound"
default:
return "Unknown"
}
}
// Role returns a string representation of the permission role.
func (p Permission) Role() string {
switch p {
case PermitAnyone:
return "Anyone"
case PermitUser:
return "User"
case PermitAdmin:
return "Admin"
case PermitSelf:
return "Self"
default:
return "Invalid"
}
}

195
api/authentication_test.go Normal file
View file

@ -0,0 +1,195 @@
package api
import (
"errors"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/assert"
)
var (
testToken = new(AuthToken)
)
func testAuthenticator(r *http.Request, s *http.Server) (*AuthToken, error) {
switch {
case testToken.Read == -127 || testToken.Write == -127:
return nil, errors.New("test error")
case testToken.Read == -128 || testToken.Write == -128:
return nil, fmt.Errorf("%wdenied", ErrAPIAccessDeniedMessage)
default:
return testToken, nil
}
}
type testAuthHandler struct {
Read Permission
Write Permission
}
func (ah *testAuthHandler) ReadPermission(r *http.Request) Permission {
return ah.Read
}
func (ah *testAuthHandler) WritePermission(r *http.Request) Permission {
return ah.Write
}
func (ah *testAuthHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if request is as expected.
ar := GetAPIRequest(r)
switch {
case ar == nil:
http.Error(w, "ar == nil", http.StatusInternalServerError)
case ar.AuthToken == nil:
http.Error(w, "ar.AuthToken == nil", http.StatusInternalServerError)
default:
http.Error(w, "auth success", http.StatusOK)
}
}
func makeAuthTestPath(reading bool, p Permission) string {
if reading {
return fmt.Sprintf("/test/auth/read/%s", p)
}
return fmt.Sprintf("/test/auth/write/%s", p)
}
func init() {
// Set test authenticator.
err := SetAuthenticator(testAuthenticator)
if err != nil {
panic(err)
}
}
func TestPermissions(t *testing.T) { //nolint:gocognit
testHandler := &mainHandler{
mux: mainMux,
}
// Define permissions that need testing.
permissionsToTest := []Permission{
NotSupported,
PermitAnyone,
PermitUser,
PermitAdmin,
PermitSelf,
Dynamic,
NotFound,
100, // Test a too high value.
-100, // Test a too low value.
-127, // Simulate authenticator failure.
-128, // Simulate authentication denied message.
}
// Register test handlers.
for _, p := range permissionsToTest {
RegisterHandler(makeAuthTestPath(true, p), &testAuthHandler{Read: p})
RegisterHandler(makeAuthTestPath(false, p), &testAuthHandler{Write: p})
}
// Test all the combinations.
for _, requestPerm := range permissionsToTest {
for _, handlerPerm := range permissionsToTest {
for _, method := range []string{
http.MethodGet,
http.MethodHead,
http.MethodPost,
http.MethodPut,
} {
// Set request permission for test requests.
reading := isReadMethod(method)
if reading {
testToken.Read = requestPerm
testToken.Write = NotSupported
} else {
testToken.Read = NotSupported
testToken.Write = requestPerm
}
// Evaluate expected result.
var expectSuccess bool
switch {
case handlerPerm == PermitAnyone:
// This is fast-tracked. There are not additional checks.
expectSuccess = true
case handlerPerm == Dynamic:
// This is turned into PermitAnyone in the authenticator.
// But authentication is still processed and the result still gets
// sanity checked!
if requestPerm >= PermitAnyone &&
requestPerm <= PermitSelf {
expectSuccess = true
}
// Another special case is when the handler requires permission to be
// processed but the authenticator fails to authenticate the request.
// In this case, a fallback token with PermitAnyone is used.
if requestPerm == -128 {
// -128 is used to simulate a permission denied message.
expectSuccess = true
}
case handlerPerm <= NotSupported:
// Invalid handler permission.
case handlerPerm > PermitSelf:
// Invalid handler permission.
case requestPerm <= NotSupported:
// Invalid request permission.
case requestPerm > PermitSelf:
// Invalid request permission.
case requestPerm < handlerPerm:
// Valid, but insufficient request permission.
default:
expectSuccess = true
}
if expectSuccess {
// Test for success.
if !assert.HTTPBodyContains(
t,
testHandler.ServeHTTP,
method,
makeAuthTestPath(reading, handlerPerm),
nil,
"auth success",
) {
t.Errorf(
"%s with %s (%d) to handler %s (%d)",
method,
requestPerm, requestPerm,
handlerPerm, handlerPerm,
)
}
} else {
// Test for error.
if !assert.HTTPError(t,
testHandler.ServeHTTP,
method,
makeAuthTestPath(reading, handlerPerm),
nil,
) {
t.Errorf(
"%s with %s (%d) to handler %s (%d)",
method,
requestPerm, requestPerm,
handlerPerm, handlerPerm,
)
}
}
}
}
}
}
func TestPermissionDefinitions(t *testing.T) {
if NotSupported != 0 {
t.Fatalf("NotSupported must be zero, was %v", NotSupported)
}
}

View file

@ -1,6 +1,6 @@
package client
// message types
// Message Types.
const (
msgRequestGet = "get"
msgRequestQuery = "query"

View file

@ -9,7 +9,7 @@ import (
"github.com/tevino/abool"
)
// Client errors
// Client errors.
var (
ErrMalformedMessage = errors.New("malformed message")
)

View file

@ -7,15 +7,20 @@ import (
"github.com/safing/portbase/log"
)
// Config Keys
// Config Keys.
const (
CfgDefaultListenAddressKey = "core/listenAddress"
CfgAPIKeys = "core/apiKeys"
)
var (
listenAddressFlag string
listenAddressConfig config.StringOption
defaultListenAddress string
configuredAPIKeys config.StringArrayOption
devMode config.BoolOption
)
func init() {
@ -58,11 +63,30 @@ func registerConfig() error {
}
listenAddressConfig = config.GetAsString(CfgDefaultListenAddressKey, getDefaultListenAddress())
err = config.Register(&config.Option{
Name: "API Keys",
Key: CfgAPIKeys,
Description: "Define API keys for priviledged access to the API. Every entry is a separate API key with respective permissions. Format is `<key>?read=<perm>&write=<perm>`. Permissions are `anyone`, `user` and `admin`, and may be omitted.",
OptType: config.OptTypeStringArray,
ExpertiseLevel: config.ExpertiseLevelDeveloper,
ReleaseLevel: config.ReleaseLevelStable,
DefaultValue: []string{},
Annotations: config.Annotations{
config.DisplayOrderAnnotation: 514,
config.CategoryAnnotation: "Development",
},
})
if err != nil {
return err
}
configuredAPIKeys = config.GetAsStringArray(CfgAPIKeys, []string{})
devMode = config.Concurrent.GetAsBool(config.CfgDevModeKey, false)
return nil
}
// SetDefaultAPIListenAddress sets the default listen address for the API.
func SetDefaultAPIListenAddress(address string) {
defaultListenAddress = address
}

View file

@ -42,7 +42,13 @@ var (
)
func init() {
RegisterHandleFunc("/api/database/v1", startDatabaseAPI) // net/http pattern matching only this exact path
RegisterHandler("/api/database/v1", WrapInAuthHandler(
startDatabaseAPI,
// Default to admin read/write permissions until the database gets support
// for api permissions.
PermitAdmin,
PermitAdmin,
))
}
// DatabaseAPI is a database API instance.
@ -93,7 +99,7 @@ func startDatabaseAPI(w http.ResponseWriter, r *http.Request) {
go new.handler()
go new.writer()
log.Infof("api request: init websocket %s %s", r.RemoteAddr, r.RequestURI)
log.Tracer(r.Context()).Infof("api request: init websocket %s %s", r.RemoteAddr, r.RequestURI)
}
func (api *DatabaseAPI) handler() {
@ -278,7 +284,7 @@ func (api *DatabaseAPI) handleGet(opID []byte, key string) {
r, err := api.db.Get(key)
if err == nil {
data, err = marshalRecord(r)
data, err = marshalRecord(r, true)
}
if err != nil {
api.send(opID, dbMsgTypeError, err.Error(), nil)
@ -336,7 +342,7 @@ func (api *DatabaseAPI) processQuery(opID []byte, q *query.Query) (ok bool) {
// process query feed
if r != nil {
// process record
data, err := marshalRecord(r)
data, err := marshalRecord(r, true)
if err != nil {
api.send(opID, dbMsgTypeWarning, err.Error(), nil)
}
@ -412,7 +418,7 @@ func (api *DatabaseAPI) processSub(opID []byte, sub *database.Subscription) {
// process sub feed
if r != nil {
// process record
data, err := marshalRecord(r)
data, err := marshalRecord(r, true)
if err != nil {
api.send(opID, dbMsgTypeWarning, err.Error(), nil)
continue
@ -625,7 +631,7 @@ func (api *DatabaseAPI) shutdown() {
// marsharlRecords locks and marshals the given record, additionally adding
// metadata and returning it as json.
func marshalRecord(r record.Record) ([]byte, error) {
func marshalRecord(r record.Record, withDSDIdentifier bool) ([]byte, error) {
r.Lock()
defer r.Unlock()
@ -651,10 +657,12 @@ func marshalRecord(r record.Record) ([]byte, error) {
}
// Add JSON identifier again.
formatID := varint.Pack8(record.JSON)
finalData := make([]byte, 0, len(formatID)+len(jsonData))
finalData = append(finalData, formatID...)
finalData = append(finalData, jsonData...)
return finalData, nil
if withDSDIdentifier {
formatID := varint.Pack8(record.JSON)
finalData := make([]byte, 0, len(formatID)+len(jsonData))
finalData = append(finalData, formatID...)
finalData = append(finalData, jsonData...)
return finalData, nil
}
return jsonData, nil
}

10
api/doc.go Normal file
View file

@ -0,0 +1,10 @@
/*
Package api provides an API for integration with other components of the same software package and also third party components.
It provides direct database access as well as a simpler way to register API endpoints. You can of course also register raw `http.Handler`s directly.
Optional authentication guards registered handlers. This is achieved by attaching functions to the `http.Handler`s that are registered, which allow them to specify the required permissions for the handler.
The permissions are divided into the roles and assume a single user per host. The Roles are User, Admin and Self. User roles are expected to have mostly read access and react to notifications or system events, like a system tray program. The Admin role is meant for advanced components that also change settings, but are restricted so they cannot break the software. Self is reserved for internal use with full access.
*/
package api

335
api/endpoints.go Normal file
View file

@ -0,0 +1,335 @@
package api
import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/log"
)
// Endpoint describes an API Endpoint.
// Path and at least one permission are required.
// As is exactly one function.
type Endpoint struct {
Path string
MimeType string
Read Permission
Write Permission
// ActionFunc is for simple actions with a return message for the user.
ActionFunc ActionFunc `json:"-"`
// DataFunc is for returning raw data that the caller for further processing.
DataFunc DataFunc `json:"-"`
// StructFunc is for returning any kind of struct.
StructFunc StructFunc `json:"-"`
// RecordFunc is for returning a database record. It will be properly locked
// and marshalled including metadata.
RecordFunc RecordFunc `json:"-"`
// HandlerFunc is the raw http handler.
HandlerFunc http.HandlerFunc `json:"-"`
// Documentation Metadata.
Name string
Description string
Parameters []Parameter
}
// Parameter describes a parameterized variation of an endpoint.
type Parameter struct {
Method string
Field string
Value string
Description string
}
type (
// ActionFunc is for simple actions with a return message for the user.
ActionFunc func(ar *Request) (msg string, err error)
// DataFunc is for returning raw data that the caller for further processing.
DataFunc func(ar *Request) (data []byte, err error)
// StructFunc is for returning any kind of struct.
StructFunc func(ar *Request) (i interface{}, err error)
// RecordFunc is for returning a database record. It will be properly locked
// and marshalled including metadata.
RecordFunc func(ar *Request) (r record.Record, err error)
)
// MIME Types.
const (
MimeTypeJSON string = "application/json"
MimeTypeText string = "text/plain"
apiV1Path = "/api/v1/"
)
func init() {
RegisterHandler(apiV1Path+"{endpointPath:.+}", &endpointHandler{})
}
var (
endpoints = make(map[string]*Endpoint)
endpointsLock sync.RWMutex
// ErrInvalidEndpoint is returned when an invalid endpoint is registered.
ErrInvalidEndpoint = errors.New("endpoint is invalid")
// ErrAlreadyRegistered is returned when there already is an endpoint with
// the same path registered.
ErrAlreadyRegistered = errors.New("an endpoint for this path is already registered")
)
func getAPIContext(r *http.Request) (apiEndpoint *Endpoint, apiRequest *Request) {
// Get request context and check if we already have an action cached.
apiRequest = GetAPIRequest(r)
if apiRequest == nil {
return nil, nil
}
var ok bool
apiEndpoint, ok = apiRequest.HandlerCache.(*Endpoint)
if ok {
return apiEndpoint, apiRequest
}
// If not, get the action from the registry.
endpointPath, ok := apiRequest.URLVars["endpointPath"]
if !ok {
return nil, apiRequest
}
endpointsLock.RLock()
defer endpointsLock.RUnlock()
apiEndpoint, ok = endpoints[endpointPath]
if ok {
// Cache for next operation.
apiRequest.HandlerCache = apiEndpoint
}
return apiEndpoint, apiRequest
}
// RegisterEndpoint registers a new endpoint. An error will be returned if it
// does not pass the sanity checks.
func RegisterEndpoint(e Endpoint) error {
if err := e.check(); err != nil {
return fmt.Errorf("%w: %s", ErrInvalidEndpoint, err)
}
endpointsLock.Lock()
defer endpointsLock.Unlock()
_, ok := endpoints[e.Path]
if ok {
return ErrAlreadyRegistered
}
endpoints[e.Path] = &e
return nil
}
func (e *Endpoint) check() error {
// Check path.
if strings.TrimSpace(e.Path) == "" {
return errors.New("path is missing")
}
// Check permissions.
if e.Read < Dynamic || e.Read > PermitSelf {
return errors.New("invalid read permission")
}
if e.Write < Dynamic || e.Write > PermitSelf {
return errors.New("invalid write permission")
}
// Check functions.
var defaultMimeType string
fnCnt := 0
if e.ActionFunc != nil {
fnCnt++
defaultMimeType = MimeTypeText
}
if e.DataFunc != nil {
fnCnt++
defaultMimeType = MimeTypeText
}
if e.StructFunc != nil {
fnCnt++
defaultMimeType = MimeTypeJSON
}
if e.RecordFunc != nil {
fnCnt++
defaultMimeType = MimeTypeJSON
}
if e.HandlerFunc != nil {
fnCnt++
defaultMimeType = MimeTypeText
}
if fnCnt != 1 {
return errors.New("only one function may be set")
}
// Set default mime type.
if e.MimeType == "" {
e.MimeType = defaultMimeType
}
return nil
}
// ExportEndpoints exports the registered endpoints. The returned data must be
// treated as immutable.
func ExportEndpoints() []*Endpoint {
endpointsLock.RLock()
defer endpointsLock.RUnlock()
// Copy the map into a slice.
eps := make([]*Endpoint, 0, len(endpoints))
for _, ep := range endpoints {
eps = append(eps, ep)
}
sort.Sort(sortByPath(eps))
return eps
}
type sortByPath []*Endpoint
func (eps sortByPath) Len() int { return len(eps) }
func (eps sortByPath) Less(i, j int) bool { return eps[i].Path < eps[j].Path }
func (eps sortByPath) Swap(i, j int) { eps[i], eps[j] = eps[j], eps[i] }
type endpointHandler struct{}
var _ AuthenticatedHandler = &endpointHandler{} // Compile time interface check.
// ReadPermission returns the read permission for the handler.
func (eh *endpointHandler) ReadPermission(r *http.Request) Permission {
apiEndpoint, _ := getAPIContext(r)
if apiEndpoint != nil {
return apiEndpoint.Read
}
return NotFound
}
// WritePermission returns the write permission for the handler.
func (eh *endpointHandler) WritePermission(r *http.Request) Permission {
apiEndpoint, _ := getAPIContext(r)
if apiEndpoint != nil {
return apiEndpoint.Write
}
return NotFound
}
// ServeHTTP handles the http request.
func (eh *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
apiEndpoint, apiRequest := getAPIContext(r)
if apiEndpoint == nil || apiRequest == nil {
http.NotFound(w, r)
return
}
switch r.Method {
case http.MethodHead:
w.WriteHeader(http.StatusOK)
return
case http.MethodPost, http.MethodPut:
// Read body data.
inputData, ok := readBody(w, r)
if !ok {
return
}
apiRequest.InputData = inputData
case http.MethodGet:
// Nothing special to do here.
case http.MethodOptions:
w.WriteHeader(http.StatusNoContent)
return
default:
http.Error(w, "Unsupported method for the actions API.", http.StatusMethodNotAllowed)
return
}
// Execute action function and get response data
var responseData []byte
var err error
switch {
case apiEndpoint.ActionFunc != nil:
var msg string
msg, err = apiEndpoint.ActionFunc(apiRequest)
if err == nil {
responseData = []byte(msg)
}
case apiEndpoint.DataFunc != nil:
responseData, err = apiEndpoint.DataFunc(apiRequest)
case apiEndpoint.StructFunc != nil:
var v interface{}
v, err = apiEndpoint.StructFunc(apiRequest)
if err == nil && v != nil {
responseData, err = json.Marshal(v)
}
case apiEndpoint.RecordFunc != nil:
var rec record.Record
rec, err = apiEndpoint.RecordFunc(apiRequest)
if err == nil && r != nil {
responseData, err = marshalRecord(rec, false)
}
case apiEndpoint.HandlerFunc != nil:
apiEndpoint.HandlerFunc(w, r)
return
default:
http.Error(w, "Internal server error: Missing handler.", http.StatusInternalServerError)
return
}
// Check for handler error.
if err != nil {
http.Error(w, "Internal server error: "+err.Error(), http.StatusInternalServerError)
return
}
// Write response.
w.Header().Set("Content-Type", apiEndpoint.MimeType+"; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(responseData)))
w.WriteHeader(http.StatusOK)
_, err = w.Write(responseData)
if err != nil {
log.Tracer(r.Context()).Warningf("api: failed to write response: %s", err)
}
}
func readBody(w http.ResponseWriter, r *http.Request) (inputData []byte, ok bool) {
// Check for too long content in order to prevent death.
if r.ContentLength > 20000000 { // 20MB
http.Error(w, "Too much input data.", http.StatusRequestEntityTooLarge)
return nil, false
}
// Read and close body.
inputData, err := ioutil.ReadAll(r.Body)
if err != nil {
http.Error(w, "Failed to read body: "+err.Error(), http.StatusInternalServerError)
return nil, false
}
return inputData, true
}

24
api/endpoints_config.go Normal file
View file

@ -0,0 +1,24 @@
package api
import (
"github.com/safing/portbase/config"
)
func registerConfigEndpoints() error {
if err := RegisterEndpoint(Endpoint{
Path: "config/options",
Read: PermitAnyone,
MimeType: MimeTypeJSON,
StructFunc: listConfig,
Name: "Export Configuration Options",
Description: "Returns a list of all registered configuration options and their metadata. This does not include the current active or default settings.",
}); err != nil {
return err
}
return nil
}
func listConfig(ar *Request) (i interface{}, err error) {
return config.ExportOptions(), nil
}

108
api/endpoints_debug.go Normal file
View file

@ -0,0 +1,108 @@
package api
import (
"bytes"
"fmt"
"net/http"
"os"
"runtime/pprof"
"github.com/safing/portbase/utils/debug"
)
func registerDebugEndpoints() error {
if err := RegisterEndpoint(Endpoint{
Path: "ping",
Read: PermitAnyone,
ActionFunc: ping,
Name: "Ping",
Description: "Pong.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "debug/stack",
Read: PermitAnyone,
DataFunc: getStack,
Name: "Get Goroutine Stack",
Description: "Returns the current goroutine stack.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "debug/stack/print",
Read: PermitAnyone,
ActionFunc: printStack,
Name: "Print Goroutine Stack",
Description: "Prints the current goroutine stack to stdout.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "debug/info",
Read: PermitAnyone,
DataFunc: debugInfo,
Name: "Get Debug Information",
Description: "Returns debugging information, including the version and platform info, errors, logs and the current goroutine stack.",
Parameters: []Parameter{{
Method: http.MethodGet,
Field: "style",
Value: "github",
Description: "Specify the formatting style. The default is simple markdown formatting.",
}},
}); err != nil {
return err
}
return nil
}
// ping responds with pong.
func ping(ar *Request) (msg string, err error) {
return "Pong.", nil
}
// getStack returns the current goroutine stack.
func getStack(_ *Request) (data []byte, err error) {
buf := &bytes.Buffer{}
err = pprof.Lookup("goroutine").WriteTo(buf, 1)
if err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// printStack prints the current goroutine stack to stderr.
func printStack(_ *Request) (msg string, err error) {
_, err = fmt.Fprint(os.Stderr, "===== PRINTING STACK =====\n")
if err == nil {
err = pprof.Lookup("goroutine").WriteTo(os.Stderr, 1)
}
if err == nil {
_, err = fmt.Fprint(os.Stderr, "===== END OF STACK =====\n")
}
if err != nil {
return "", err
}
return "stack printed to stdout", nil
}
// debugInfo returns the debugging information for support requests.
func debugInfo(ar *Request) (data []byte, err error) {
// Create debug information helper.
di := new(debug.Info)
di.Style = ar.Request.URL.Query().Get("style")
// Add debug information.
di.AddVersionInfo()
di.AddPlatformInfo(ar.Context())
di.AddLastReportedModuleError()
di.AddLastUnexpectedLogs()
di.AddGoroutineStack()
// Return data.
return di.Bytes(), nil
}

134
api/endpoints_meta.go Normal file
View file

@ -0,0 +1,134 @@
package api
import (
"encoding/json"
"errors"
"net/http"
)
func registerMetaEndpoints() error {
if err := RegisterEndpoint(Endpoint{
Path: "endpoints",
Read: PermitAnyone,
MimeType: MimeTypeJSON,
DataFunc: listEndpoints,
Name: "Export API Endpoints",
Description: "Returns a list of all registered endpoints and their metadata.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "auth/permissions",
Read: Dynamic,
StructFunc: permissions,
Name: "View Current Permissions",
Description: "Returns the current permissions assigned to the request.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "auth/bearer",
Read: Dynamic,
HandlerFunc: authBearer,
Name: "Request HTTP Bearer Auth",
Description: "Returns an HTTP Bearer Auth request, if not authenticated.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "auth/basic",
Read: Dynamic,
HandlerFunc: authBasic,
Name: "Request HTTP Basic Auth",
Description: "Returns an HTTP Basic Auth request, if not authenticated.",
}); err != nil {
return err
}
if err := RegisterEndpoint(Endpoint{
Path: "auth/reset",
Read: PermitAnyone,
HandlerFunc: authReset,
Name: "Reset Authenticated Session",
Description: "Resets authentication status internally and in the browser.",
}); err != nil {
return err
}
return nil
}
func listEndpoints(ar *Request) (data []byte, err error) {
data, err = json.Marshal(ExportEndpoints())
return
}
func permissions(ar *Request) (i interface{}, err error) {
if ar.AuthToken == nil {
return nil, errors.New("authentication token missing")
}
return struct {
Read Permission
Write Permission
ReadRole string
WriteRole string
}{
Read: ar.AuthToken.Read,
Write: ar.AuthToken.Write,
ReadRole: ar.AuthToken.Read.Role(),
WriteRole: ar.AuthToken.Write.Role(),
}, nil
}
func authBearer(w http.ResponseWriter, r *http.Request) {
// Check if authenticated by checking read permission.
ar := GetAPIRequest(r)
if ar.AuthToken.Read != PermitAnyone {
TextResponse(w, r, "Authenticated.")
return
}
// Respond with desired authentication header.
w.Header().Set("WWW-Authenticate", "Bearer realm=Portmaster API")
http.Error(w, "Authorization required.", http.StatusUnauthorized)
}
func authBasic(w http.ResponseWriter, r *http.Request) {
// Check if authenticated by checking read permission.
ar := GetAPIRequest(r)
if ar.AuthToken.Read != PermitAnyone {
TextResponse(w, r, "Authenticated.")
return
}
// Respond with desired authentication header.
w.Header().Set("WWW-Authenticate", "Basic realm=Portmaster API")
http.Error(w, "Authorization required.", http.StatusUnauthorized)
}
func authReset(w http.ResponseWriter, r *http.Request) {
// Get session cookie from request and delete session if exists.
c, err := r.Cookie(sessionCookieName)
if err == nil {
deleteSession(c.Value)
}
// Delete session and cookie.
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
MaxAge: -1, // MaxAge<0 means delete cookie now, equivalently 'Max-Age: 0'
})
// Request client to also reset all data.
w.Header().Set("Clear-Site-Data", "*")
// Set HTTP Auth Realm without requesting authorization.
w.Header().Set("WWW-Authenticate", "None realm=Portmaster API")
// Reply with 401 Unauthorized in order to clear HTTP Basic Auth data.
http.Error(w, "Session deleted.", http.StatusUnauthorized)
}

156
api/endpoints_test.go Normal file
View file

@ -0,0 +1,156 @@
package api
import (
"errors"
"sync"
"testing"
"github.com/safing/portbase/database/record"
"github.com/stretchr/testify/assert"
)
const (
successMsg = "endpoint api success"
failedMsg = "endpoint api failed"
)
type actionTestRecord struct {
record.Base
sync.Mutex
Msg string
}
func TestEndpoints(t *testing.T) {
testHandler := &mainHandler{
mux: mainMux,
}
// ActionFn
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/action",
Read: PermitAnyone,
ActionFunc: func(_ *Request) (msg string, err error) {
return successMsg, nil
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/action", nil, successMsg)
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/action-err",
Read: PermitAnyone,
ActionFunc: func(_ *Request) (msg string, err error) {
return "", errors.New(failedMsg)
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/action-err", nil, failedMsg)
// DataFn
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/data",
Read: PermitAnyone,
DataFunc: func(_ *Request) (data []byte, err error) {
return []byte(successMsg), nil
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/data", nil, successMsg)
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/data-err",
Read: PermitAnyone,
DataFunc: func(_ *Request) (data []byte, err error) {
return nil, errors.New(failedMsg)
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/data-err", nil, failedMsg)
// StructFn
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/struct",
Read: PermitAnyone,
StructFunc: func(_ *Request) (i interface{}, err error) {
return &actionTestRecord{
Msg: successMsg,
}, nil
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/struct", nil, successMsg)
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/struct-err",
Read: PermitAnyone,
StructFunc: func(_ *Request) (i interface{}, err error) {
return nil, errors.New(failedMsg)
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/struct-err", nil, failedMsg)
// RecordFn
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/record",
Read: PermitAnyone,
RecordFunc: func(_ *Request) (r record.Record, err error) {
r = &actionTestRecord{
Msg: successMsg,
}
r.CreateMeta()
return r, nil
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/record", nil, successMsg)
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/record-err",
Read: PermitAnyone,
RecordFunc: func(_ *Request) (r record.Record, err error) {
return nil, errors.New(failedMsg)
},
}))
assert.HTTPBodyContains(t, testHandler.ServeHTTP, "GET", apiV1Path+"test/record-err", nil, failedMsg)
}
func TestActionRegistration(t *testing.T) {
assert.Error(t, RegisterEndpoint(Endpoint{}))
assert.Error(t, RegisterEndpoint(Endpoint{
Path: "test/err",
Read: NotFound,
}))
assert.Error(t, RegisterEndpoint(Endpoint{
Path: "test/err",
Read: PermitSelf + 1,
}))
assert.Error(t, RegisterEndpoint(Endpoint{
Path: "test/err",
Write: NotFound,
}))
assert.Error(t, RegisterEndpoint(Endpoint{
Path: "test/err",
Write: PermitSelf + 1,
}))
assert.Error(t, RegisterEndpoint(Endpoint{
Path: "test/err",
}))
assert.Error(t, RegisterEndpoint(Endpoint{
Path: "test/err",
ActionFunc: func(_ *Request) (msg string, err error) {
return successMsg, nil
},
DataFunc: func(_ *Request) (data []byte, err error) {
return []byte(successMsg), nil
},
}))
assert.NoError(t, RegisterEndpoint(Endpoint{
Path: "test/err",
ActionFunc: func(_ *Request) (msg string, err error) {
return successMsg, nil
},
}))
}

View file

@ -2,7 +2,10 @@ package api
import (
"context"
"encoding/json"
"errors"
"flag"
"os"
"time"
"github.com/safing/portbase/modules"
@ -10,9 +13,11 @@ import (
var (
module *modules.Module
exportEndpoints bool
)
// API Errors
// API Errors.
var (
ErrAuthenticationAlreadySet = errors.New("the authentication function has already been set")
ErrAuthenticationImmutable = errors.New("the authentication function can only be set before the api has started")
@ -20,24 +25,47 @@ var (
func init() {
module = modules.Register("api", prep, start, stop, "database", "config")
flag.BoolVar(&exportEndpoints, "export-api-endpoints", false, "export api endpoint registry and exit")
}
func prep() error {
if exportEndpoints {
modules.SetCmdLineOperation(exportEndpointsCmd)
}
if getDefaultListenAddress() == "" {
return errors.New("no default listen address for api available")
}
return registerConfig()
if err := registerConfig(); err != nil {
return err
}
if err := registerDebugEndpoints(); err != nil {
return err
}
if err := registerConfigEndpoints(); err != nil {
return err
}
return registerMetaEndpoints()
}
func start() error {
logFlagOverrides()
go Serve()
_ = updateAPIKeys(module.Ctx, nil)
err := module.RegisterEventHook("config", "config change", "update API keys", updateAPIKeys)
if err != nil {
return err
}
// start api auth token cleaner
authFnLock.Lock()
defer authFnLock.Unlock()
if authFn != nil {
module.NewTask("clean api auth tokens", cleanAuthTokens).Repeat(time.Minute)
if authFnSet.IsSet() {
module.NewTask("clean api sessions", cleanSessions).Repeat(5 * time.Minute)
}
return nil
@ -49,3 +77,13 @@ func stop() error {
}
return nil
}
func exportEndpointsCmd() error {
data, err := json.MarshalIndent(ExportEndpoints(), "", " ")
if err != nil {
return err
}
_, err = os.Stdout.Write(data)
return err
}

58
api/main_test.go Normal file
View file

@ -0,0 +1,58 @@
package api
import (
"fmt"
"io/ioutil"
"os"
"testing"
"github.com/safing/portbase/dataroot"
"github.com/safing/portbase/modules"
// API depends on the database for the database api.
_ "github.com/safing/portbase/database/dbmodule"
)
func init() {
defaultListenAddress = "127.0.0.1:8817"
}
func TestMain(m *testing.M) {
// enable module for testing
module.Enable()
// tmp dir for data root (db & config)
tmpDir, err := ioutil.TempDir("", "portbase-testing-")
if err != nil {
fmt.Fprintf(os.Stderr, "failed to create tmp dir: %s\n", err)
os.Exit(1)
}
// initialize data dir
err = dataroot.Initialize(tmpDir, 0755)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to initialize data root: %s\n", err)
os.Exit(1)
}
// start modules
var exitCode int
err = modules.Start()
if err != nil {
// starting failed
fmt.Fprintf(os.Stderr, "failed to setup test: %s\n", err)
exitCode = 1
} else {
// run tests
exitCode = m.Run()
}
// shutdown
_ = modules.Shutdown()
if modules.GetExitStatusCode() != 0 {
exitCode = modules.GetExitStatusCode()
fmt.Fprintf(os.Stderr, "failed to cleanly shutdown test: %s\n", err)
}
// clean up and exit
os.RemoveAll(tmpDir)
os.Exit(exitCode)
}

View file

@ -1,52 +0,0 @@
package api
import (
"context"
"net/http"
"github.com/safing/portbase/log"
)
// Middleware is a function that can be added as a middleware to the API endpoint.
type Middleware func(next http.Handler) http.Handler
type mwHandler struct {
handlers []Middleware
final http.Handler
}
func (mwh *mwHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
handlerLock.RLock()
defer handlerLock.RUnlock()
// final handler
handler := mwh.final
// build middleware chain
// loop in reverse to build the handler chain in the correct order
for i := len(mwh.handlers) - 1; i >= 0; i-- {
handler = mwh.handlers[i](handler)
}
// start
handler.ServeHTTP(w, r)
}
// ModuleWorker is an http middleware that wraps the request in a module worker.
func ModuleWorker(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = module.RunWorker("http request", func(_ context.Context) error {
next.ServeHTTP(w, r)
return nil
})
})
}
// LogTracer is an http middleware that attaches a log tracer to the request context.
func LogTracer(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, tracer := log.AddTracer(r.Context())
next.ServeHTTP(w, r.WithContext(ctx))
tracer.Submit()
})
}

57
api/request.go Normal file
View file

@ -0,0 +1,57 @@
package api
import (
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/safing/portbase/log"
)
// Request is a support struct to pool more request related information.
type Request struct {
// Request is the http request.
*http.Request
// InputData contains the request body for write operations.
InputData []byte
// Route of this request.
Route *mux.Route
// URLVars contains the URL variables extracted by the gorilla mux.
URLVars map[string]string
// AuthToken is the request-side authentication token assigned.
AuthToken *AuthToken
// HandlerCache can be used by handlers to cache data between handlers within a request.
HandlerCache interface{}
}
// apiRequestContextKey is a key used for the context key/value storage.
type apiRequestContextKey struct{}
var (
requestContextKey = apiRequestContextKey{}
)
// GetAPIRequest returns the API Request of the given http request.
func GetAPIRequest(r *http.Request) *Request {
ar, ok := r.Context().Value(requestContextKey).(*Request)
if ok {
return ar
}
return nil
}
// TextResponse writes a text response.
func TextResponse(w http.ResponseWriter, r *http.Request, text string) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, err := fmt.Fprintln(w, text)
if err != nil {
log.Tracer(r.Context()).Warningf("api: failed to write text response: %s", err)
}
}

View file

@ -2,6 +2,7 @@ package api
import (
"context"
"errors"
"net/http"
"sync"
"time"
@ -15,17 +16,6 @@ var (
// gorilla mux
mainMux = mux.NewRouter()
// middlewares
middlewareHandler = &mwHandler{
final: mainMux,
handlers: []Middleware{
ModuleWorker,
LogTracer,
RequestLogger,
authMiddleware,
},
}
// main server and lock
server = &http.Server{}
handlerLock sync.RWMutex
@ -45,18 +35,14 @@ func RegisterHandleFunc(path string, handleFunc func(http.ResponseWriter, *http.
return mainMux.HandleFunc(path, handleFunc)
}
// RegisterMiddleware registers a middle function with the API endoint.
func RegisterMiddleware(middleware Middleware) {
handlerLock.Lock()
defer handlerLock.Unlock()
middlewareHandler.handlers = append(middlewareHandler.handlers, middleware)
}
// Serve starts serving the API endpoint.
func Serve() {
// configure server
server.Addr = listenAddressConfig()
server.Handler = middlewareHandler
server.Handler = &mainHandler{
// TODO: mainMux should not be modified anymore.
mux: mainMux,
}
// start serving
log.Infof("api: starting to listen on %s", server.Addr)
@ -76,7 +62,86 @@ func Serve() {
}
}
// GetMuxVars wraps github.com/gorilla/mux.Vars in order to mitigate context key issues in multi-repo projects.
func GetMuxVars(r *http.Request) map[string]string {
return mux.Vars(r)
type mainHandler struct {
mux *mux.Router
}
func (mh *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
_ = module.RunWorker("http request", func(_ context.Context) error {
return mh.handle(w, r)
})
}
func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error {
// Setup context trace logging.
ctx, tracer := log.AddTracer(r.Context())
// Add request context.
apiRequest := &Request{
Request: r,
}
ctx = context.WithValue(ctx, requestContextKey, apiRequest)
// Add context back to request.
r = r.WithContext(ctx)
lrw := NewLoggingResponseWriter(w, r)
tracer.Tracef("api request: %s ___ %s", r.RemoteAddr, r.RequestURI)
defer func() {
// Log request status.
if lrw.Status != 0 {
// If lrw.Status is 0, the request may have been hijacked.
tracer.Debugf("api request: %s %d %s", lrw.Request.RemoteAddr, lrw.Status, lrw.Request.RequestURI)
}
tracer.Submit()
}()
// Get handler for request.
// Gorilla does not support handling this on our own very well.
// See github.com/gorilla/mux.ServeHTTP for reference.
var match mux.RouteMatch
var handler http.Handler
if mh.mux.Match(r, &match) {
handler = match.Handler
apiRequest.Route = match.Route
apiRequest.URLVars = match.Vars
}
// Be sure that URLVars always is a map.
if apiRequest.URLVars == nil {
apiRequest.URLVars = make(map[string]string)
}
// Check authentication.
apiRequest.AuthToken = authenticateRequest(lrw, r, handler)
if apiRequest.AuthToken == nil {
// Authenticator already replied.
return nil
}
// Add security headers.
if !devMode() {
w.Header().Set(
"Content-Security-Policy",
"default-src 'self'; "+
"style-src 'self' 'unsafe-inline'; "+
"img-src 'self' data:",
)
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "deny")
w.Header().Set("X-XSS-Protection", "1; mode=block")
} else {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
// Handle request.
switch {
case handler != nil:
handler.ServeHTTP(lrw, r)
case errors.Is(match.MatchErr, mux.ErrMethodMismatch):
http.Error(lrw, "Method not allowed.", http.StatusMethodNotAllowed)
default: // handler == nil or other error
http.Error(lrw, "Not found.", http.StatusNotFound)
}
return nil
}

39
config/devmode.go Normal file
View file

@ -0,0 +1,39 @@
package config
import (
"flag"
"github.com/safing/portbase/log"
)
// Configuration Keys.
var (
CfgDevModeKey = "core/devMode"
defaultDevMode bool
)
func init() {
flag.BoolVar(&defaultDevMode, "devmode", false, "enable development mode")
}
func logDevModeOverride() {
if defaultDevMode {
log.Warning("config: development mode is enabled by default by the -devmode flag")
}
}
func registerDevModeOption() error {
return Register(&Option{
Name: "Development Mode",
Key: CfgDevModeKey,
Description: "In Development Mode, security restrictions are lifted/softened to enable unrestricted access for debugging and testing purposes.",
OptType: OptTypeBool,
ExpertiseLevel: ExpertiseLevelDeveloper,
ReleaseLevel: ReleaseLevelStable,
DefaultValue: defaultDevMode,
Annotations: Annotations{
DisplayOrderAnnotation: 512,
CategoryAnnotation: "Development",
},
})
}

View file

@ -14,7 +14,7 @@ import (
// to change deep configuration settings.
type ExpertiseLevel uint8
// Expertise Level constants
// Expertise Level constants.
const (
ExpertiseLevelUser ExpertiseLevel = 0
ExpertiseLevelExpert ExpertiseLevel = 1

View file

@ -1,7 +1,9 @@
package config
import (
"encoding/json"
"errors"
"flag"
"os"
"path/filepath"
@ -17,6 +19,8 @@ const (
var (
module *modules.Module
dataRoot *utils.DirStructure
exportConfig bool
)
// SetDataRoot sets the data root from which the updates module derives its paths.
@ -29,6 +33,8 @@ func SetDataRoot(root *utils.DirStructure) {
func init() {
module = modules.Register("config", prep, start, nil, "database")
module.RegisterEvent(configChangeEvent)
flag.BoolVar(&exportConfig, "export-config-options", false, "export configuration registry and exit")
}
func prep() error {
@ -37,6 +43,16 @@ func prep() error {
return errors.New("data root is not set")
}
if exportConfig {
modules.SetCmdLineOperation(exportConfigCmd)
}
logDevModeOverride()
err := registerDevModeOption()
if err != nil {
return err
}
return nil
}
@ -54,3 +70,13 @@ func start() error {
}
return nil
}
func exportConfigCmd() error {
data, err := json.MarshalIndent(ExportOptions(), "", " ")
if err != nil {
return err
}
_, err = os.Stdout.Write(data)
return err
}

View file

@ -60,7 +60,7 @@ type PossibleValue struct {
// future well-known annotation additions do not conflict
// with vendor/product/package specific annoations.
//
// Format: <vendor/package>:<scope>:<identifier>
// Format: <vendor/package>:<scope>:<identifier> //.
type Annotations map[string]interface{}
// Well known annotations defined by this package.
@ -144,7 +144,7 @@ type ValueRequirement struct {
Value interface{}
}
// Values for the DisplayHintAnnotation
// Values for the DisplayHintAnnotation.
const (
// DisplayHintOneOf is used to mark an option
// as a "select"-style option. That is, only one of
@ -263,7 +263,7 @@ func (option *Option) SetAnnotation(key string, value interface{}) {
option.Annotations[key] = value
}
// GetAnnotation returns the value of the annotation key
// GetAnnotation returns the value of the annotation key.
func (option *Option) GetAnnotation(key string) (interface{}, bool) {
option.Lock()
defer option.Unlock()

View file

@ -3,6 +3,7 @@ package config
import (
"fmt"
"regexp"
"sort"
"strings"
"sync"
)
@ -29,6 +30,23 @@ func ForEachOption(fn func(opt *Option) error) error {
return nil
}
// ExportOptions exports the registered options. The returned data must be
// treated as immutable.
// The data does not include the current active or default settings.
func ExportOptions() []*Option {
optionsLock.RLock()
defer optionsLock.RUnlock()
// Copy the map into a slice.
opts := make([]*Option, 0, len(options))
for _, opt := range options {
opts = append(opts, opt)
}
sort.Sort(sortByKey(opts))
return opts
}
// GetOption returns the option with name or an error
// if the option does not exist. The caller should lock
// the returned option itself for further processing.

View file

@ -61,6 +61,27 @@ func (i *Interface) DelayedCacheWriter(ctx context.Context) error {
}
}
// ClearCache clears the read cache.
func (i *Interface) ClearCache() {
// Check if cache is in use.
if i.cache == nil {
return
}
// Clear all cache entries.
i.cache.Purge()
}
// FlushCache writes (and thus clears) the write cache.
func (i *Interface) FlushCache() {
// Check if write cache is in use.
if i.options.DelayCachedWrites != "" {
return
}
i.flushWriteCache(0)
}
func (i *Interface) flushWriteCache(percentThreshold int) {
i.writeCacheLock.Lock()
defer i.writeCacheLock.Unlock()

44
go.mod Normal file
View file

@ -0,0 +1,44 @@
module github.com/safing/portbase
go 1.15
require (
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect
github.com/VictoriaMetrics/metrics v1.12.3
github.com/aead/serpent v0.0.0-20160714141033-fba169763ea6
github.com/armon/go-radix v1.0.0
github.com/bluele/gcache v0.0.0-20190518031135-bc40bd653833
github.com/davecgh/go-spew v1.1.1
github.com/dgraph-io/badger v1.6.1
github.com/go-ole/go-ole v1.2.4 // indirect
github.com/gofrs/uuid v3.3.0+incompatible
github.com/golang/protobuf v1.4.2 // indirect
github.com/gorilla/mux v1.7.4
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.0
github.com/hashicorp/go-version v1.2.0
github.com/pkg/errors v0.9.1 // indirect
github.com/seehuhn/fortuna v1.0.1
github.com/shirou/gopsutil v2.20.4+incompatible
github.com/spf13/cobra v1.0.0
github.com/spf13/pflag v1.0.5 // indirect
github.com/stretchr/testify v1.6.1
github.com/tevino/abool v1.0.0
github.com/tidwall/gjson v1.6.0
github.com/tidwall/sjson v1.1.1
go.etcd.io/bbolt v1.3.4
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 // indirect
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f
golang.org/x/tools v0.0.0-20210115202250-e0d201561e39 // indirect
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c // indirect
)
require (
// The follow-up commit removes Windows support.
// TODO: Check how we want to handle this in the future, possibly ingest
// needed functionality into here.
github.com/google/renameio v0.1.1-0.20200217212219-353f81969824
)

273
go.sum Normal file
View file

@ -0,0 +1,273 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d h1:G0m3OIz70MZUWq3EgK3CesDbo8upS2Vm9/P3FtgI+Jk=
github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
github.com/VictoriaMetrics/metrics v1.12.3 h1:Fe6JHC6MSEKa+BtLhPN8WIvS+HKPzMc2evEpNeCGy7I=
github.com/VictoriaMetrics/metrics v1.12.3/go.mod h1:Z1tSfPfngDn12bTfZSCqArT3OPY3u88J12hSoOhuiRE=
github.com/aead/serpent v0.0.0-20160714141033-fba169763ea6 h1:5L8Mj9Co9sJVgW3TpYk2gxGJnDjsYuboNTcRmbtGKGs=
github.com/aead/serpent v0.0.0-20160714141033-fba169763ea6/go.mod h1:3HgLJ9d18kXMLQlJvIY3+FszZYMxCz8WfE2MQ7hDY0w=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/bluele/gcache v0.0.0-20190518031135-bc40bd653833 h1:yCfXxYaelOyqnia8F/Yng47qhmfC9nKTRIbYRrRueq4=
github.com/bluele/gcache v0.0.0-20190518031135-bc40bd653833/go.mod h1:8c4/i2VlovMO2gBnHGQPN5EJw+H0lx1u/5p+cgsXtCk=
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger v1.6.1 h1:w9pSFNSdq/JPM1N12Fz/F/bzo993Is1W+Q7HjPzi7yg=
github.com/dgraph-io/badger v1.6.1/go.mod h1:FRmFw3uxvcpa8zG3Rxs0th+hCLIuaQg8HlNV5bjgnuU=
github.com/dgraph-io/ristretto v0.0.2 h1:a5WaUrDa0qm0YrAAS1tUykT5El3kt62KNZZeMxQn3po=
github.com/dgraph-io/ristretto v0.0.2/go.mod h1:KPxhHT9ZxKefz+PCeOGsrHpl1qZ7i70dGTu2u+Ahh6E=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-ole/go-ole v1.2.4 h1:nNBDSCOigTSiarFpYE9J/KtEA1IOW4CNeqT9TQDqCxI=
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gofrs/uuid v3.3.0+incompatible h1:8K4tyRfvU1CYPgJsveYFQMhpFd/wXNM7iK6rR7UHz84=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/renameio v0.1.0 h1:GOZbcHa3HfsPKPlmyPyN2KEohoMXOhdMbHrvbpl2QaA=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/renameio v0.1.1-0.20200217212219-353f81969824 h1:9q700G0beHecUuiZOuKgNqNsGQixTeDLnzVZ5nsW3lc=
github.com/google/renameio v0.1.1-0.20200217212219-353f81969824/go.mod h1:t/HQoYBZSsWSNK35C6CO/TpPLDVWvxOHboWUAweKUpk=
github.com/gorilla/mux v1.7.4 h1:VuZ8uybHlWmqV03+zRzdwKL4tUnIp1MAQtp1mIFE1bc=
github.com/gorilla/mux v1.7.4/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.0 h1:B9UzwGQJehnUY1yNrnwREHc3fGbC2xefo8g4TbElacI=
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0 h1:s5hAObm+yFO5uHYt5dYjxi2rXrsnmRpJx4OYvIWUaQs=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.0.0-20181113130724-41aa239b4cce/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/seehuhn/fortuna v1.0.1 h1:lu9+CHsmR0bZnx5Ay646XvCSRJ8PJTi5UYJwDBX68H0=
github.com/seehuhn/fortuna v1.0.1/go.mod h1:LX8ubejCnUoT/hX+1aKUtbKls2H6DRkqzkc7TdR3iis=
github.com/seehuhn/sha256d v1.0.0 h1:TXTsAuEWr02QjRm153Fnvvb6fXXDo7Bmy1FizxarGYw=
github.com/seehuhn/sha256d v1.0.0/go.mod h1:PEuxg9faClSveVuFXacQmi+NtDI/PX8bpKjtNzf2+s4=
github.com/shirou/gopsutil v2.20.4+incompatible h1:cMT4rxS55zx9NVUnCkrmXCsEB/RNfG9SwHY9evtX8Ng=
github.com/shirou/gopsutil v2.20.4+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/cobra v1.0.0 h1:6m/oheQuQ13N9ks4hubMG6BnvwOeaJrqSPLahSnczz8=
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/tevino/abool v1.0.0 h1:5hlcsW0yartQp609pbLLrE/s3ZNm2k/F7YSGuqJxpbM=
github.com/tevino/abool v1.0.0/go.mod h1:f1SCnEOt6sc3fOJfPQDRDzHOtSXuTtnz0ImG9kPRDV0=
github.com/tidwall/gjson v1.6.0 h1:9VEQWz6LLMUsUl6PueE49ir4Ka6CzLymOAZDxpFsTDc=
github.com/tidwall/gjson v1.6.0/go.mod h1:P256ACg0Mn+j1RXIDXoss50DeIABTYK1PULOJHhxOls=
github.com/tidwall/match v1.0.1 h1:PnKP62LPNxHKTwvHHZZzdOAOCtsJTjo6dZLCwpKm5xc=
github.com/tidwall/match v1.0.1/go.mod h1:LujAq0jyVjBy028G1WhWfIzbpQfMO8bBZ6Tyb0+pL9E=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/pretty v1.0.1 h1:WE4RBSZ1x6McVVC8S/Md+Qse8YUv6HRObAx6ke00NY8=
github.com/tidwall/pretty v1.0.1/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tidwall/sjson v1.1.1 h1:7h1vk049Jnd5EH9NyzNiEuwYW4b5qgreBbqRC19AS3U=
github.com/tidwall/sjson v1.1.1/go.mod h1:yvVuSnpEQv5cYIrO+AT6kw4QVfd5SDZoGIS7/5+fZFs=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/valyala/fastrand v1.0.0 h1:LUKT9aKer2dVQNUi3waewTbKV+7H17kvWFNKs2ObdkI=
github.com/valyala/fastrand v1.0.0/go.mod h1:HWqCzkrkg6QXT8V2EXWvXCoow7vLwOFN002oeRzjapQ=
github.com/valyala/histogram v1.1.2 h1:vOk5VrGjMBIoPR5k6wA8vBaC8toeJ8XO0yfRjFEc1h8=
github.com/valyala/histogram v1.1.2/go.mod h1:CZAr6gK9dbD7hYx2s8WSPh0p5x5wETjC+2b3PJVtEdg=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.etcd.io/bbolt v1.3.4 h1:hi1bXHMVrlQh6WwxAy+qZCV/SYIlqo+Ushwdpa4tAKg=
go.etcd.io/bbolt v1.3.4/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3 h1:XQyxROzUlZH+WIQwySDgnISgOivlhjIEwaQaJEJrrN0=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 h1:2M3HP5CCK1Si9FQhwnzYhXdG6DXeebvUHFpre8QvbyI=
golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2 h1:eDrdRpKgkcCqKZQwyZRyeFZgfqt37SL7Kv3tok06cKE=
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974 h1:IX6qOQeG5uLjB/hjjwjedwfjND0hgjPMMyO1RoIXQNI=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9 h1:SQFwaSi55rU7vdNs9Yr0Z324VNlrF+0wMqRXT4St8ck=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121 h1:rITEj+UZHYC927n8GT97eC3zrpzXdb/voyeOuVKS46o=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd h1:/e+gpKk9r3dJobndpTytxS2gOy6m5uvpg+ISQoEcusQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20210115202250-e0d201561e39 h1:BTs2GMGSMWpgtCpv1CE7vkJTv7XcHdcLLnAMu7UbgTY=
golang.org/x/tools v0.0.0-20210115202250-e0d201561e39/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0 h1:4MY060fB1DLGMB/7MBTLnwQUY6+F09GEiz6SsrNqyzM=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c h1:grhR+C34yXImVGp7EzNk+DTIk+323eIUWOmEevy6bDo=
gopkg.in/yaml.v3 v3.0.0-20200605160147-a5ece683394c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -4,6 +4,7 @@ import (
"fmt"
"os"
"runtime/debug"
"sync"
"time"
)
@ -191,6 +192,8 @@ StackTrace:
// if currentLine and line are _not_ equal, output currentLine
adapter.Write(currentLine, duplicates)
// add to unexpected logs
addUnexpectedLogs(currentLine)
// reset duplicate counter
duplicates = 0
// set new currentLine
@ -203,6 +206,8 @@ StackTrace:
// write final line
if currentLine != nil {
adapter.Write(currentLine, duplicates)
// add to unexpected logs
addUnexpectedLogs(currentLine)
}
// reset state
currentLine = nil //nolint:ineffassign
@ -231,3 +236,60 @@ func finalizeWriting() {
}
}
}
// Last Unexpected Logs
var (
lastUnexpectedLogs [10]string
lastUnexpectedLogsIndex int
lastUnexpectedLogsLock sync.Mutex
)
func addUnexpectedLogs(line *logLine) {
// Add main line.
if line.level >= WarningLevel {
addUnexpectedLogLine(line)
return
}
// Check for unexpected lines in the tracer.
if line.tracer != nil {
for _, traceLine := range line.tracer.logs {
if traceLine.level >= WarningLevel {
// Add full trace.
addUnexpectedLogLine(line)
return
}
}
}
}
func addUnexpectedLogLine(line *logLine) {
lastUnexpectedLogsLock.Lock()
defer lastUnexpectedLogsLock.Unlock()
// Format line and add to logs.
lastUnexpectedLogs[lastUnexpectedLogsIndex] = formatLine(line, 0, false)
// Increase index and wrap back to start.
lastUnexpectedLogsIndex = (lastUnexpectedLogsIndex + 1) % len(lastUnexpectedLogs)
}
// GetLastUnexpectedLogs returns the last 10 log lines of level Warning an up.
func GetLastUnexpectedLogs() []string {
lastUnexpectedLogsLock.Lock()
defer lastUnexpectedLogsLock.Unlock()
// Make a copy and return.
len := len(lastUnexpectedLogs)
start := lastUnexpectedLogsIndex
logsCopy := make([]string, 0, len)
// Loop from mid-to-mid.
for i := start; i < start+len; i++ {
if lastUnexpectedLogs[i%len] != "" {
logsCopy = append(logsCopy, lastUnexpectedLogs[i%len])
}
}
return logsCopy
}

135
metrics/api.go Normal file
View file

@ -0,0 +1,135 @@
package metrics
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"time"
"github.com/safing/portbase/api"
"github.com/safing/portbase/config"
"github.com/safing/portbase/log"
)
func registerAPI() error {
api.RegisterHandler("/metrics", &metricsAPI{})
return api.RegisterEndpoint(api.Endpoint{
Path: "metrics/list",
Read: api.PermitAnyone,
MimeType: api.MimeTypeJSON,
DataFunc: func(*api.Request) ([]byte, error) {
registryLock.RLock()
defer registryLock.RUnlock()
return json.Marshal(registry)
},
Name: "Export Registered Metrics",
Description: "List all registered metrics with their metadata.",
})
}
type metricsAPI struct{}
func (m *metricsAPI) ReadPermission(*http.Request) api.Permission { return api.Dynamic }
func (m *metricsAPI) WritePermission(*http.Request) api.Permission { return api.NotSupported }
func (m *metricsAPI) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Get API Request for permission and query.
ar := api.GetAPIRequest(r)
if ar == nil {
http.Error(w, "Missing API Request.", http.StatusInternalServerError)
return
}
// Get expertise level from query.
expertiseLevel := config.ExpertiseLevelDeveloper
switch ar.Request.URL.Query().Get("level") {
case config.ExpertiseLevelNameUser:
expertiseLevel = config.ExpertiseLevelUser
case config.ExpertiseLevelNameExpert:
expertiseLevel = config.ExpertiseLevelExpert
case config.ExpertiseLevelNameDeveloper:
expertiseLevel = config.ExpertiseLevelDeveloper
}
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.WriteHeader(http.StatusOK)
WriteMetrics(w, ar.AuthToken.Read, expertiseLevel)
}
// WriteMetrics writes all metrics that match the given permission and
// expertiseLevel to the given writer.
func WriteMetrics(w io.Writer, permission api.Permission, expertiseLevel config.ExpertiseLevel) {
registryLock.RLock()
defer registryLock.RUnlock()
// Write all matching metrics.
for _, metric := range registry {
if permission >= metric.Opts().Permission &&
expertiseLevel >= metric.Opts().ExpertiseLevel {
metric.WritePrometheus(w)
}
}
}
func writeMetricsTo(ctx context.Context, url string) error {
// First, collect metrics into buffer.
buf := &bytes.Buffer{}
WriteMetrics(buf, api.PermitSelf, config.ExpertiseLevelDeveloper)
// Check if there is something to send.
if buf.Len() == 0 {
log.Debugf("metrics: not pushing metrics, nothing to send")
return nil
}
// Create request
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, buf)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Send.
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check return status.
if resp.StatusCode >= 200 && resp.StatusCode <= 299 {
return nil
}
// Get and return error.
body, _ := ioutil.ReadAll(resp.Body)
return fmt.Errorf(
"got %s while writing metrics to %s: %s",
resp.Status,
url,
body,
)
}
func metricsWriter(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
err := writeMetricsTo(ctx, pushURL)
if err != nil {
return err
}
}
}
}

153
metrics/metric.go Normal file
View file

@ -0,0 +1,153 @@
package metrics
import (
"fmt"
"io"
"regexp"
"sort"
"strings"
"github.com/safing/portbase/api"
"github.com/safing/portbase/config"
vm "github.com/VictoriaMetrics/metrics"
)
// PrometheusFormatRequirement is required format defined by prometheus for
// metric and label names.
const PrometheusFormatRequirement = "[a-zA-Z_][a-zA-Z0-9_]*"
var prometheusFormat = regexp.MustCompile(PrometheusFormatRequirement)
// Metric represents one or more metrics.
type Metric interface {
ID() string
LabeledID() string
Opts() *Options
WritePrometheus(w io.Writer)
}
type metricBase struct {
Identifier string
Labels map[string]string
LabeledIdentifier string
Options *Options
set *vm.Set
}
// Options can be used to set advanced metric settings.
type Options struct {
// Name defines an optional human readable name for the metric.
Name string
// AlertLimit defines an upper limit that triggers an alert.
AlertLimit float64
// AlertTimeframe defines an optional timeframe in seconds for which the
// AlertLimit should be interpreted in.
AlertTimeframe float64
// Permission defines the permission that is required to read the metric.
Permission api.Permission
// ExpertiseLevel defines the expertise level that the metric is meant for.
ExpertiseLevel config.ExpertiseLevel
// Persist enabled persisting the metric on shutdown and loading the previous
// value at start. This is only supported for counters.
Persist bool
}
func newMetricBase(id string, labels map[string]string, opts Options) (*metricBase, error) {
// Check formats.
if !prometheusFormat.MatchString(id) {
return nil, fmt.Errorf("metric name %q must match %s", id, PrometheusFormatRequirement)
}
for labelName := range labels {
if !prometheusFormat.MatchString(labelName) {
return nil, fmt.Errorf("metric label name %q must match %s", labelName, PrometheusFormatRequirement)
}
}
// Check permission.
if opts.Permission < api.PermitAnyone {
// Default to PermitUser.
opts.Permission = api.PermitUser
}
// Create metric base.
base := &metricBase{
Identifier: id,
Labels: labels,
Options: &opts,
set: vm.NewSet(),
}
base.LabeledIdentifier = base.buildLabeledID()
return base, nil
}
// ID returns the given ID of the metric.
func (m *metricBase) ID() string {
return m.Identifier
}
// LabeledID returns the Prometheus-compatible labeled ID of the metric.
func (m *metricBase) LabeledID() string {
return m.LabeledIdentifier
}
// Opts returns the metric options. They may not be modified.
func (m *metricBase) Opts() *Options {
return m.Options
}
// WritePrometheus writes the metric in the prometheus format to the given writer.
func (m *metricBase) WritePrometheus(w io.Writer) {
m.set.WritePrometheus(w)
}
func (m *metricBase) buildLabeledID() string {
// Because we use the namespace and the global flags here, we need to flag
// them as immutable.
registryLock.Lock()
defer registryLock.Unlock()
firstMetricRegistered = true
// Build ID from Identifier.
metricID := strings.TrimSpace(strings.ReplaceAll(m.Identifier, "/", "_"))
// Add namespace to ID.
if metricNamespace != "" {
metricID = metricNamespace + "_" + metricID
}
// Return now if no labels are defined.
if len(globalLabels) == 0 && len(m.Labels) == 0 {
return metricID
}
// Add global labels to the custom ones, if they don't exist yet.
for labelName, labelValue := range globalLabels {
if _, ok := m.Labels[labelName]; !ok {
m.Labels[labelName] = labelValue
}
}
// Render labels into a slice and sort them in order to make the labeled ID
// reproducible.
labels := make([]string, 0, len(m.Labels))
for labelName, labelValue := range m.Labels {
labels = append(labels, fmt.Sprintf("%s=%q", labelName, labelValue))
}
sort.Strings(labels)
// Return fully labaled ID.
return fmt.Sprintf("%s{%s}", metricID, strings.Join(labels, ","))
}
// Split metrics into sets, according to the API Auth Levels, which will also correspond to the UI Mode levels. SPN // nodes will also allow public access to metrics with the permission "PermitAnyone".
// Save "life-long" metrics on shutdown and load them at start.
// Generate the correct metric name and labels.
// Expose metrics via http, but also via the runtime DB in order to push metrics to the UI.
// The UI will have to parse the prometheus metrics format and will not be able to immediately present historical data, // but data will have to be built.
// Provide the option to push metrics to a prometheus push gateway, this is especially helpful when gathering data from // loads of SPN nodes.

44
metrics/metric_counter.go Normal file
View file

@ -0,0 +1,44 @@
package metrics
import (
vm "github.com/VictoriaMetrics/metrics"
)
// Counter is a counter metric.
type Counter struct {
*metricBase
*vm.Counter
}
// NewCounter registers a new counter metric.
func NewCounter(id string, labels map[string]string, opts *Options) (*Counter, error) {
// Ensure that there are options.
if opts == nil {
opts = &Options{}
}
// Make base.
base, err := newMetricBase(id, labels, *opts)
if err != nil {
return nil, err
}
// Create metric struct.
m := &Counter{
metricBase: base,
}
// Create metric in set
m.Counter = m.set.NewCounter(m.LabeledID())
// Register metric.
err = register(m)
if err != nil {
return nil, err
}
// Load state.
m.loadState()
return m, nil
}

41
metrics/metric_gauge.go Normal file
View file

@ -0,0 +1,41 @@
package metrics
import (
vm "github.com/VictoriaMetrics/metrics"
)
// Gauge is a gauge metric.
type Gauge struct {
*metricBase
*vm.Gauge
}
// NewGauge registers a new gauge metric.
func NewGauge(id string, labels map[string]string, fn func() float64, opts *Options) (*Gauge, error) {
// Ensure that there are options.
if opts == nil {
opts = &Options{}
}
// Make base.
base, err := newMetricBase(id, labels, *opts)
if err != nil {
return nil, err
}
// Create metric struct.
m := &Gauge{
metricBase: base,
}
// Create metric in set
m.Gauge = m.set.NewGauge(m.LabeledID(), fn)
// Register metric.
err = register(m)
if err != nil {
return nil, err
}
return m, nil
}

View file

@ -0,0 +1,41 @@
package metrics
import (
vm "github.com/VictoriaMetrics/metrics"
)
// Histogram is a histogram metric.
type Histogram struct {
*metricBase
*vm.Histogram
}
// NewHistogram registers a new histogram metric.
func NewHistogram(id string, labels map[string]string, opts *Options) (*Histogram, error) {
// Ensure that there are options.
if opts == nil {
opts = &Options{}
}
// Make base.
base, err := newMetricBase(id, labels, *opts)
if err != nil {
return nil, err
}
// Create metric struct.
m := &Histogram{
metricBase: base,
}
// Create metric in set
m.Histogram = m.set.NewHistogram(m.LabeledID())
// Register metric.
err = register(m)
if err != nil {
return nil, err
}
return m, nil
}

40
metrics/metric_info.go Normal file
View file

@ -0,0 +1,40 @@
package metrics
import (
"runtime"
"strings"
"github.com/safing/portbase/info"
)
func registerInfoMetric() error {
meta := info.GetInfo()
_, err := NewGauge(
"info",
map[string]string{
"version": checkUnknown(meta.Version),
"commit": checkUnknown(meta.Commit),
"build_options": checkUnknown(meta.BuildOptions),
"build_user": checkUnknown(meta.BuildUser),
"build_host": checkUnknown(meta.BuildHost),
"build_date": checkUnknown(meta.BuildDate),
"build_source": checkUnknown(meta.BuildSource),
"go_os": runtime.GOOS,
"go_arch": runtime.GOARCH,
"go_version": runtime.Version(),
"go_compiler": runtime.Compiler,
},
func() float64 {
return 1
},
nil,
)
return err
}
func checkUnknown(s string) string {
if strings.Contains(s, "unknown") {
return "unknown"
}
return s
}

48
metrics/metric_runtime.go Normal file
View file

@ -0,0 +1,48 @@
package metrics
import (
"io"
vm "github.com/VictoriaMetrics/metrics"
"github.com/safing/portbase/api"
"github.com/safing/portbase/config"
)
func init() {
registryLock.Lock()
defer registryLock.Unlock()
registry = append(registry, &runtimeMetrics{})
}
var runtimeOpts = &Options{
Name: "Golang Runtime",
Permission: api.PermitAdmin,
ExpertiseLevel: config.ExpertiseLevelDeveloper,
}
type runtimeMetrics struct{}
func (r *runtimeMetrics) ID() string {
return "_runtime"
}
func (r *runtimeMetrics) LabeledID() string {
return "_runtime"
}
func (r *runtimeMetrics) Opts() *Options {
return runtimeOpts
}
func (r *runtimeMetrics) Permission() api.Permission {
return runtimeOpts.Permission
}
func (r *runtimeMetrics) ExpertiseLevel() config.ExpertiseLevel {
return runtimeOpts.ExpertiseLevel
}
func (r *runtimeMetrics) WritePrometheus(w io.Writer) {
vm.WriteProcessMetrics(w)
}

140
metrics/module.go Normal file
View file

@ -0,0 +1,140 @@
package metrics
import (
"errors"
"flag"
"fmt"
"sort"
"sync"
"github.com/safing/portbase/modules"
)
var (
module *modules.Module
registry []Metric
registryLock sync.RWMutex
firstMetricRegistered bool
metricNamespace string
globalLabels = make(map[string]string)
pushURL string
metricInstance string
// ErrAlreadyStarted is returned when an operation is only valid before the
// first metric is registered, and is called after.
ErrAlreadyStarted = errors.New("can only be changed before first metric is registered")
// ErrAlreadyRegistered is returned when a metric with the same ID is
// registered again.
ErrAlreadyRegistered = errors.New("metric already registered")
// ErrAlreadySet is returned when a value is already set and cannot be changed.
ErrAlreadySet = errors.New("already set")
)
func init() {
flag.StringVar(&pushURL, "push-metrics", "", "URL to push prometheus metrics to")
flag.StringVar(&metricInstance, "metrics-instance", "", "Set the global instance label")
module = modules.Register("metrics", prep, start, stop, "database", "api")
}
func prep() error {
// Add metric instance name as global variable if set.
if metricInstance != "" {
if err := AddGlobalLabel("instance", metricInstance); err != nil {
return err
}
}
return registerInfoMetric()
}
func start() error {
if err := registerAPI(); err != nil {
return err
}
if pushURL != "" {
module.StartServiceWorker("metric pusher", 0, metricsWriter)
}
return nil
}
func stop() error {
storePersistentMetrics()
return nil
}
func register(m Metric) error {
registryLock.Lock()
defer registryLock.Unlock()
// Check if metric ID is already registered.
for _, registeredMetric := range registry {
if m.LabeledID() == registeredMetric.LabeledID() {
return ErrAlreadyRegistered
}
}
// Add new metric to registry and sort it.
registry = append(registry, m)
sort.Sort(byLabeledID(registry))
// Set flag that first metric is now registered.
firstMetricRegistered = true
return nil
}
// SetNamespace sets the namespace for all metrics. It is prefixed to all
// metric IDs.
// It must be set before any metric is registered.
// Does not affect golang runtime metrics.
func SetNamespace(namespace string) error {
// Lock registry and check if a first metric is already registered.
registryLock.Lock()
defer registryLock.Unlock()
if firstMetricRegistered {
return ErrAlreadyStarted
}
// Check if the namespace is already set.
if metricNamespace != "" {
return ErrAlreadySet
}
metricNamespace = namespace
return nil
}
// AddGlobalLabel adds a global label to all metrics.
// Global labels must be added before any metric is registered.
// Does not affect golang runtime metrics.
func AddGlobalLabel(name, value string) error {
// Lock registry and check if a first metric is already registered.
registryLock.Lock()
defer registryLock.Unlock()
if firstMetricRegistered {
return ErrAlreadyStarted
}
// Check format.
if !prometheusFormat.MatchString(name) {
return fmt.Errorf("metric label name %q must match %s", name, PrometheusFormatRequirement)
}
globalLabels[name] = value
return nil
}
type byLabeledID []Metric
func (r byLabeledID) Len() int { return len(r) }
func (r byLabeledID) Less(i, j int) bool { return r[i].LabeledID() < r[j].LabeledID() }
func (r byLabeledID) Swap(i, j int) { r[i], r[j] = r[j], r[i] }

151
metrics/persistence.go Normal file
View file

@ -0,0 +1,151 @@
package metrics
import (
"errors"
"fmt"
"sync"
"time"
"github.com/safing/portbase/database"
"github.com/safing/portbase/database/record"
"github.com/safing/portbase/log"
"github.com/tevino/abool"
)
var (
storage *metricsStorage
storageKey string
storageInit = abool.New()
storageLoaded = abool.New()
db = database.NewInterface(&database.Options{
Local: true,
Internal: true,
})
// ErrAlreadyInitialized is returned when trying to initialize an option
// more than once.
ErrAlreadyInitialized = errors.New("already initialized")
)
type metricsStorage struct {
sync.Mutex
record.Base
Start time.Time
Counters map[string]uint64
}
// EnableMetricPersistence enables metric persistence for metrics that opted
// for it. They given key is the database key where the metric data will be
// persisted.
// This call also directly loads the stored data from the database.
// The returned error is only about loading the metrics, not about enabling
// persistence.
// May only be called once.
func EnableMetricPersistence(key string) error {
// Check if already initialized.
if !storageInit.SetToIf(false, true) {
return ErrAlreadyInitialized
}
// Set storage key.
storageKey = key
// Load metrics from storage.
var err error
storage, err = getMetricsStorage(key)
switch {
case err == nil:
// Continue.
case errors.Is(err, database.ErrNotFound):
return nil
default:
return err
}
storageLoaded.Set()
// Load saved state for all counter metrics.
registryLock.RLock()
defer registryLock.RUnlock()
for _, m := range registry {
counter, ok := m.(*Counter)
if ok {
counter.loadState()
}
}
return nil
}
func (c *Counter) loadState() {
// Check if we can and should load the state.
if !storageLoaded.IsSet() || !c.Opts().Persist {
return
}
c.Set(storage.Counters[c.LabeledID()])
}
func storePersistentMetrics() {
// Check if persistence is enabled.
if !storageInit.IsSet() || storageKey == "" {
return
}
// Create new storage.
newStorage := &metricsStorage{
Start: time.Now(),
Counters: make(map[string]uint64),
}
newStorage.SetKey(storageKey)
// Copy values from previous version.
if storageLoaded.IsSet() {
newStorage.Start = storage.Start
}
registryLock.RLock()
defer registryLock.RUnlock()
// Export all counter metrics.
for _, m := range registry {
if m.Opts().Persist {
counter, ok := m.(*Counter)
if ok {
newStorage.Counters[m.LabeledID()] = counter.Get()
}
}
}
// Save to database.
err := db.Put(newStorage)
if err != nil {
log.Warningf("metrics: failed to save metrics storage to db: %s", err)
}
}
func getMetricsStorage(key string) (*metricsStorage, error) {
r, err := db.Get(key)
if err != nil {
return nil, err
}
// unwrap
if r.IsWrapped() {
// only allocate a new struct, if we need it
new := &metricsStorage{}
err = record.Unwrap(r, new)
if err != nil {
return nil, err
}
return new, nil
}
// or adjust type
new, ok := r.(*metricsStorage)
if !ok {
return nil, fmt.Errorf("record not of type *metricsStorage, but %T", r)
}
return new, nil
}

1
metrics/test/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
data

4
metrics/test/README.md Normal file
View file

@ -0,0 +1,4 @@
# Testing metrics
You can spin up a test setup for pushing and viewing metrics with `docker-compose up`.
Then use the flag `--push-metrics http://127.0.0.1:8428/api/v1/import/prometheus` to push metrics.

View file

@ -0,0 +1,36 @@
version: '3.8'
networks:
pm-metrics-test-net:
services:
victoriametrics:
container_name: pm-metrics-test-victoriametrics
image: victoriametrics/victoria-metrics
command:
- '--storageDataPath=/storage'
ports:
- 8428:8428
volumes:
- ./data/victoriametrics:/storage
networks:
- pm-metrics-test-net
restart: always
grafana:
container_name: pm-metrics-test-grafana
image: grafana/grafana
command:
- '--config=/etc/grafana/provisioning/config.ini'
depends_on:
- "victoriametrics"
ports:
- 3000:3000
volumes:
- ./data/grafana:/var/lib/grafana
- ./grafana:/etc/grafana/provisioning
- ./dashboards:/dashboards
networks:
- pm-metrics-test-net
restart: always

View file

@ -0,0 +1,10 @@
[auth]
disable_login_form = true
disable_signout_menu = true
[auth.basic]
enabled = false
[auth.anonymous]
enabled = true
org_role = Admin

View file

@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: 'Portmaster'
folder: 'Portmaster'
disableDeletion: true
updateIntervalSeconds: 10
allowUiUpdates: true
options:
path: /dashboards
foldersFromFilesStructure: true

View file

@ -0,0 +1,8 @@
apiVersion: 1
datasources:
- name: VictoriaMetrics
type: prometheus
access: proxy
url: http://pm-metrics-test-victoriametrics:8428
isDefault: true

10
modules/cmd.go Normal file
View file

@ -0,0 +1,10 @@
package modules
var (
cmdLineOperation func() error
)
// SetCmdLineOperation sets a command line operation to be executed instead of starting the system. This is useful when functions need all modules to be prepared for a special operation.
func SetCmdLineOperation(fn func() error) {
cmdLineOperation = fn
}

View file

@ -11,7 +11,8 @@ import (
var (
errorReportingChannel chan *ModuleError
reportToStdErr = true
reportingLock sync.RWMutex
lastReportedError *ModuleError
reportingLock sync.Mutex
)
// ModuleError wraps a panic, error or message into an error that can be reported.
@ -67,10 +68,37 @@ func (me *ModuleError) Error() string {
return me.Message
}
// Format returns the error formatted in key/value form.
func (me *ModuleError) Format() string {
return fmt.Sprintf(
`Message: %s
Timestamp: %s
ModuleName: %s
TaskName: %s
TaskType: %s
Severity: %s
PanicValue: %s
StackTrace:
%s
`,
me.Message,
time.Now(),
me.ModuleName,
me.TaskName,
me.TaskType,
me.Severity,
me.PanicValue,
me.StackTrace,
)
}
// Report reports the error through the configured reporting channel.
func (me *ModuleError) Report() {
reportingLock.RLock()
defer reportingLock.RUnlock()
reportingLock.Lock()
defer reportingLock.Unlock()
lastReportedError = me
if errorReportingChannel != nil {
select {
@ -83,27 +111,8 @@ func (me *ModuleError) Report() {
// default to writing to stderr
fmt.Fprintf(
os.Stderr,
`===== Error Report =====
Message: %s
Timestamp: %s
ModuleName: %s
TaskName: %s
TaskType: %s
Severity: %s
PanicValue: %s
StackTrace:
%s
===== End of Report =====
`,
me.Message,
time.Now(),
me.ModuleName,
me.TaskName,
me.TaskType,
me.Severity,
me.PanicValue,
me.StackTrace,
"===== Error Report =====\n%s\n===== End of Report =====\n",
me.Format(),
)
}
}
@ -133,3 +142,11 @@ func SetStdErrReporting(on bool) {
reportToStdErr = on
}
// GetLastReportedError returns the last reported module error.
func GetLastReportedError() *ModuleError {
reportingLock.Lock()
defer reportingLock.Unlock()
return lastReportedError
}

View file

@ -74,6 +74,16 @@ func Start() error {
return err
}
// execute command if available
if cmdLineOperation != nil {
err := cmdLineOperation()
if err != nil {
SetExitStatusCode(1)
fmt.Fprintf(os.Stderr, "cmdline operation failed: %s\n", err)
}
return ErrCleanExit
}
// start logging
log.EnableScheduling()
err = log.Start()

61
test
View file

@ -6,6 +6,7 @@ scripted=0
goUp="\\e[1A"
fullTestFlags="-short"
install=0
testonly=0
function help {
echo "usage: $0 [command] [options]"
@ -17,6 +18,7 @@ function help {
echo ""
echo "options:"
echo " --scripted dont jump console lines (still use colors)"
echo " --test-only skip linters and only run tests"
echo " [package] run tests only on this package"
}
@ -92,6 +94,10 @@ while true; do
goUp=""
shift 1
;;
"--test-only")
testonly=1
shift 1
;;
"install")
install=1
shift 1
@ -117,8 +123,8 @@ if [[ $install -eq 1 ]]; then
echo "$ go get -u golang.org/x/lint/golint"
go get -u golang.org/x/lint/golint
# TODO: update golangci-lint version regularly
echo "$ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.24.0"
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.24.0
echo "$ curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.29.0"
curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin v1.29.0
exit 0
fi
@ -127,26 +133,27 @@ if [[ $(which go) == "" ]]; then
echo "go command not found"
exit 1
fi
if [[ $(which gofmt) == "" ]]; then
echo "gofmt command not found"
exit 1
if [[ $testonly -eq 0 ]]; then
if [[ $(which gofmt) == "" ]]; then
echo "gofmt command not found"
exit 1
fi
if [[ $(which golint) == "" ]]; then
echo "golint command not found"
echo "install with: go get -u golang.org/x/lint/golint"
echo "or run: ./test install"
exit 1
fi
if [[ $(which golangci-lint) == "" ]]; then
echo "golangci-lint command not found"
echo "install with: curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin vX.Y.Z"
echo "don't forget to specify the version you want"
echo "or run: ./test install"
echo ""
echo "alternatively, install the current dev version with: go get -u github.com/golangci/golangci-lint/cmd/golangci-lint"
exit 1
fi
fi
if [[ $(which golint) == "" ]]; then
echo "golint command not found"
echo "install with: go get -u golang.org/x/lint/golint"
echo "or run: ./test install"
exit 1
fi
if [[ $(which golangci-lint) == "" ]]; then
echo "golangci-lint command not found"
echo "install with: curl -sfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $(go env GOPATH)/bin vX.Y.Z"
echo "don't forget to specify the version you want"
echo "or run: ./test install"
echo ""
echo "alternatively, install the current dev version with: go get -u github.com/golangci/golangci-lint/cmd/golangci-lint"
exit 1
fi
# target selection
if [[ "$1" == "" ]]; then
# get all packages
@ -170,10 +177,12 @@ echo "running tests for ${platformInfo//$'\n'/ }:"
for package in $packages; do
echo ""
echo $package
checkformat $package
run golint -set_exit_status -min_confidence 1.0 $package
run go vet $package
run golangci-lint run $GOPATH/src/$package
if [[ $testonly -eq 0 ]]; then
checkformat $package
run golint -set_exit_status -min_confidence 1.0 $package
run go vet $package
run golangci-lint run $GOPATH/src/$package
fi
run go test -cover $fullTestFlags $package
done
@ -184,4 +193,4 @@ if [[ $errors -gt 0 ]]; then
else
echo "succeeded with $warnings warnings"
exit 0
fi
fi

View file

@ -5,6 +5,10 @@ import (
"io"
"os"
// Version is fixed to commit 353f8196982447d8b12c64f69530e657331e3dbc.
// The follow-up commit removes Windows support.
// TOOD: Check how we want to handle this in the future, possibly ingest
// needed functionality into here.
"github.com/google/renameio"
)

182
utils/debug/debug.go Normal file
View file

@ -0,0 +1,182 @@
package debug
import (
"bytes"
"context"
"fmt"
"runtime/pprof"
"time"
"github.com/safing/portbase/info"
"github.com/safing/portbase/log"
"github.com/safing/portbase/modules"
"github.com/shirou/gopsutil/host"
)
// Info gathers debugging information and stores everything in a buffer in
// order to write it to somewhere later. It directly inherits a bytes.Buffer,
// so you can also use all these functions too.
type Info struct {
bytes.Buffer
Style string
}
// InfoFlag defines possible options for adding sections to a Info.
type InfoFlag int
const (
// NoFlags does nothing.
NoFlags InfoFlag = 0
// UseCodeSection wraps the section content in a markdown code section.
UseCodeSection InfoFlag = 1
// AddContentLineBreaks adds a line breaks after each line of content,
// except for the last.
AddContentLineBreaks InfoFlag = 2
)
func useCodeSection(flags InfoFlag) bool {
return flags&UseCodeSection > 0
}
func addContentLineBreaks(flags InfoFlag) bool {
return flags&AddContentLineBreaks > 0
}
// AddSection adds a debug section to the Info. The result is directly
// written into the buffer.
func (di *Info) AddSection(name string, flags InfoFlag, content ...string) {
// Check if we need a spacer.
if di.Len() > 0 {
di.WriteString("\n\n")
}
// Write section to buffer.
// Write section header.
if di.Style == "github" {
di.WriteString(fmt.Sprintf("<details>\n<summary>%s</summary>\n\n", name))
} else {
di.WriteString(fmt.Sprintf("**%s**:\n\n", name))
}
// Write section content.
if useCodeSection(flags) {
// Write code header: Needs one empty line between previous data.
di.WriteString("```\n")
}
for i, part := range content {
di.WriteString(part)
if addContentLineBreaks(flags) && i < len(content)-1 {
di.WriteString("\n")
}
}
if useCodeSection(flags) {
// Write code footer: Needs one empty line between next data.
di.WriteString("\n```\n")
}
// Write section header.
if di.Style == "github" {
di.WriteString("\n</details>")
}
}
// AddVersionInfo adds version information from the info pkg.
func (di *Info) AddVersionInfo() {
di.AddSection(
"Version "+info.Version(),
UseCodeSection,
info.FullVersion(),
)
}
// AddPlatformInfo adds OS and platform information.
func (di *Info) AddPlatformInfo(ctx context.Context) {
// Get information from the system.
info, err := host.InfoWithContext(ctx)
if err != nil {
di.AddSection(
"Platform Information",
NoFlags,
fmt.Sprintf("Failed to get: %s", err),
)
return
}
// Check if we want to add virtulization information.
var virtInfo string
if info.VirtualizationRole == "guest" {
if info.VirtualizationSystem != "" {
virtInfo = fmt.Sprintf("VM: %s", info.VirtualizationSystem)
} else {
virtInfo = "VM: unidentified"
}
}
// Add section.
di.AddSection(
fmt.Sprintf("Platform: %s %s", info.Platform, info.PlatformVersion),
UseCodeSection|AddContentLineBreaks,
fmt.Sprintf("System: %s %s (%s) %s", info.Platform, info.OS, info.PlatformFamily, info.PlatformVersion),
fmt.Sprintf("Kernel: %s %s", info.KernelVersion, info.KernelArch),
virtInfo,
)
}
// AddGoroutineStack adds the current goroutine stack.
func (di *Info) AddGoroutineStack() {
buf := new(bytes.Buffer)
err := pprof.Lookup("goroutine").WriteTo(buf, 1)
if err != nil {
di.AddSection(
"Goroutine Stack",
NoFlags,
fmt.Sprintf("Failed to get: %s", err),
)
return
}
// Add section.
di.AddSection(
"Goroutine Stack",
UseCodeSection,
buf.String(),
)
}
// AddLastReportedModuleError adds the last reported module error, if one exists.
func (di *Info) AddLastReportedModuleError() {
me := modules.GetLastReportedError()
if me == nil {
di.AddSection("No Module Error", NoFlags)
return
}
di.AddSection(
"Module Error",
UseCodeSection,
me.Format(),
)
}
// AddLastUnexpectedLogs adds the last 10 unexpected log lines, if any.
func (di *Info) AddLastUnexpectedLogs() {
lines := log.GetLastUnexpectedLogs()
// Check if there is anything at all.
if len(lines) == 0 {
di.AddSection("No Unexpected Logs", NoFlags)
return
}
di.AddSection(
"Unexpected Logs",
UseCodeSection|AddContentLineBreaks,
append(
lines,
fmt.Sprintf("%s CURRENT TIME", time.Now().Format("060102 15:04:05.000")),
)...,
)
}

View file

@ -11,6 +11,7 @@ var (
nameOnly = regexp.MustCompile("^[A-Za-z0-9]+$")
delimitersAtStart = regexp.MustCompile("^[^A-Za-z0-9]+")
delimitersOnly = regexp.MustCompile("^[^A-Za-z0-9]+$")
removeQuotes = strings.NewReplacer(`"`, ``, `'`, ``)
)
// GenerateBinaryNameFromPath generates a more human readable binary name from
@ -67,6 +68,11 @@ func GenerateBinaryNameFromPath(path string) string {
func cleanFileDescription(fileDescr string) string {
fields := strings.Fields(fileDescr)
// Clean out and `"` and `'`.
for i := range fields {
fields[i] = removeQuotes.Replace(fields[i])
}
// If there is a 1 or 2 character delimiter field, only use fields before it.
endIndex := len(fields)
for i, field := range fields {

View file

@ -26,6 +26,8 @@ func TestCleanFileDescription(t *testing.T) {
assert.Equal(t, "Product Name", cleanFileDescription("Product Name :: Does this and that."))
assert.Equal(t, "/ Product Name", cleanFileDescription("/ Product Name"))
assert.Equal(t, "Product", cleanFileDescription("Product / Name"))
assert.Equal(t, "Software 2", cleanFileDescription("Software 2"))
assert.Equal(t, "Launcher for Software 2", cleanFileDescription("Launcher for 'Software 2'"))
assert.Equal(t, "", cleanFileDescription(". / Name"))
assert.Equal(t, "", cleanFileDescription(". "))
assert.Equal(t, "", cleanFileDescription("."))

View file

@ -13,7 +13,7 @@ func runPowershellCmd(script string) (output string, err error) {
"powershell.exe",
"-NoProfile",
"-NonInteractive",
script,
"[System.Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n"+script,
)
// Create and assign output buffers.