You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

651 lines
16 KiB

package main
import (
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"github.com/BurntSushi/toml"
"github.com/dgrijalva/jwt-go/v4"
)
type Config struct {
Dashboard DashboardSettings `toml:"dashboard"`
Auth AuthProviders `toml:"auth"`
Tag AppTags `toml:"tags"`
Category AppCategories `toml:"apps"`
}
func ParseConfig(r io.Reader) (*Config, error) {
var c Config
if m, err := toml.DecodeReader(r, &c); err != nil {
return nil, err
} else if len(m.Undecoded()) != 0 {
k := m.Undecoded()[0]
p, v := k[:len(k)-1], k[len(k)-1]
return nil, fmt.Errorf("unrecognized key %#v in %#v", v, p.String())
}
return &c, nil
}
func (c Config) Validate() error {
if err := c.Dashboard.Validate(); err != nil {
return fmt.Errorf("dashboard: %w", err)
}
if err := c.Auth.Validate(); err != nil {
return fmt.Errorf("auth: %w", err)
}
if err := c.Tag.Validate(c.Auth); err != nil {
return fmt.Errorf("tags: %w", err)
}
for v := range c.Category {
if strings.Contains(v, "::") {
return fmt.Errorf("apps: illegal ID %#v: must not contain '::'", v)
}
}
if err := c.Category.Validate(c.Auth, c.Tag); err != nil {
return fmt.Errorf("apps: %w", err)
}
return nil
}
// Filtered returns a config which is suitable to be shown to the given user. If
// authProvider is empty, the user is anonymous.
func (c Config) Filtered(authProvider, authUser string) Config {
var filtered Config
filtered.Dashboard = c.Dashboard
filtered.Auth = AuthProviders{}
filtered.Tag = c.Tag.Filtered(authProvider, authUser)
filtered.Category = c.Category.Filtered(authProvider, authUser, filtered.Tag)
return filtered
}
type DashboardSettings struct {
// Title is a custom page title to use.
Title string `toml:"title"`
// CustomCSS is added to the page.
CustomCSS string `toml:"custom_css"`
}
func (d DashboardSettings) Validate() error {
return nil
}
// AuthProviders contains the supported providers. At most one may be enabled at
// a time.
type AuthProviders struct {
JWT *AuthProviderJWT `toml:"jwt"`
}
func (a AuthProviders) Validate() error {
var n int
if a.JWT != nil {
n++
if err := a.JWT.Validate(); err != nil {
return fmt.Errorf("jwt: %w", err)
}
}
if n > 1 {
return fmt.Errorf("at most one provider may be set at once")
}
return nil
}
func (a AuthProviders) Contains(p string) bool {
switch p {
case "jwt":
return a.JWT != nil
default:
return false
}
}
// GetProvider returns the provider and its name, or nil and an empty string.
func (a AuthProviders) GetProvider() (interface{}, string) {
switch {
case a.JWT != nil:
return *a.JWT, "jwt"
default:
return nil, ""
}
}
func (a AuthProviders) ValidateUserList(u PropList) error {
for _, k := range u.Keys() {
if !a.Contains(k) {
return fmt.Errorf("unknown auth method %#v", k)
}
}
return nil
}
// AuthProviderJWT authenticates against a JWT (the sub field is checked). This
// provider is intended for use with reverse proxies.
//
// If login/logout URLs are provided (relative or absolute), they are shown as
// buttons.
//
// If CallbackParam is set, a parameter is added to the login and logout URLs
// with a URL to redirect back to.
//
// Example 1 - caddy + loginsrv middleware:
//
// Example 1: Caddy config (loginsrv middleware):
// example.com {
// proxy / dashboard.local:8080 {
// except /login /logout
// transparent
// }
// login {
// login_path /login
// logout_url /
// jwt_secret "MYSECRET"
// simple username=password,username1=password1
// }
// }
//
// Example 1: Provider config:
// secret = "MYSECRET"
// alg = "HS512"
// cookie_name = "jwt_token"
// login_url = "/login"
// logout_url = "/login?logout=true"
// callback_param = "backTo"
//
// Example 2 - loginsrv on separate domain (dashboard on d.example.com, login on l.example.com):
//
// Example 2: Provider config:
// secret = "MYSECRET"
// alg = "HS512"
// cookie_name = "loginsrv_jwt_token"
// login_url = "https://l.example.com/login"
// logout_url = "https://l.example.com/login?logout=true"
// callback_param = "backTo"
//
// Example 2: loginsrv config:
// -cookie-domain=".example.com"
// -cookie-name="loginsrv_jwt_token"
// -cookie-secure=false (if using HTTP)
// -jwt-secret="MYSECRET"
// -redirect=true
// -redirect-query-parameter="backTo"
// -redirect-check-referer=false
// -logout-url="/"
//
// You can also use public/private keys for JWT authentication. To generate a
// ECDSA keypair, you can use the following commands:
//
// openssl ecparam -genkey -name secp521r1 -noout -out privkey.pem
// openssl ec -in privkey.pem -pubout -out pubkey.pem
//
// Then, include the key contents in the secret option (just use triple quotes
// for a multiline string), and set the alg to ES512.
type AuthProviderJWT struct {
Secret string `toml:"secret"` // can also be ENV:varname to take from the env var named varname
Alg string `toml:"alg"` // can also be ENV:varname to take from the env var named varname
CookieName string `toml:"cookie_name"`
LoginURL string `toml:"login_url"` // optional
LogoutURL string `toml:"logout_url"` // optional
CallbackParam string `toml:"callback_param"` // optional
}
func (a AuthProviderJWT) Validate() error {
if a.Secret == "" {
return fmt.Errorf("secret is required")
}
if a.CookieName == "" {
return fmt.Errorf("cookie name is required")
}
if a.Alg == "" {
return fmt.Errorf("unknown signing method %#v", a.Alg)
}
if _, err := a.KeyFunc(); err != nil {
return fmt.Errorf("key: %w", err)
}
return nil
}
func (a AuthProviderJWT) KeyFunc() (jwt.Keyfunc, error) {
var secret string
if strings.HasPrefix(a.Secret, "ENV:") {
v := strings.TrimPrefix(a.Secret, "ENV:")
secret = os.Getenv(v)
if secret == "" {
return nil, fmt.Errorf("no secret set from env var %#v", v)
}
} else {
secret = a.Secret
}
var alg string
if strings.HasPrefix(a.Alg, "ENV:") {
v := strings.TrimPrefix(a.Alg, "ENV:")
alg = os.Getenv(v)
if alg == "" {
return nil, fmt.Errorf("no alg set from env var %#v", v)
}
} else {
alg = a.Alg
}
switch sm := jwt.GetSigningMethod(alg).(type) {
case *jwt.SigningMethodHMAC:
return jwt.KnownKeyfunc(sm, []byte(secret)), nil
case *jwt.SigningMethodRSA, *jwt.SigningMethodRSAPSS:
k, err := jwt.ParseRSAPublicKeyFromPEM([]byte(secret))
if err != nil {
return nil, fmt.Errorf("error parsing RSA public key %#v from provided PEM: %w", secret, err)
}
return jwt.KnownKeyfunc(sm, k), nil
case *jwt.SigningMethodECDSA:
k, err := jwt.ParseECPublicKeyFromPEM([]byte(secret))
if err != nil {
return nil, fmt.Errorf("error parsing ECDSA public key %#v from provided PEM: %w", secret, err)
}
return jwt.KnownKeyfunc(sm, k), nil
case nil:
return nil, fmt.Errorf("unknown signing method %s", a.Alg)
default:
return nil, fmt.Errorf("unsupported signing method %s", a.Alg)
}
}
func (a AuthProviderJWT) ParseJWT(r *http.Request) (authUser string, err error) {
ck, err := r.Cookie(a.CookieName)
if err != nil {
if err == http.ErrNoCookie {
// no login info
return "", nil
}
return "", fmt.Errorf("error reading auth cookie: %w", err)
}
kf, err := a.KeyFunc()
if err != nil {
return "", fmt.Errorf("error parsing jwt config: %w", err)
}
tok, err := jwt.ParseWithClaims(ck.Value, &jwt.StandardClaims{}, kf) // note: this validates the sig, iss, exp, etc too
if err != nil {
return "", err
}
return tok.Claims.(*jwt.StandardClaims).Subject, nil
}
type AppTags map[string]AppTag
func (a AppTags) Validate(p AuthProviders) error {
for id, tag := range a {
if strings.Contains(id, "::") || strings.Contains(id, ".") {
return fmt.Errorf("tag %#v: illegal ID: must not contain '::' or '.'", id)
}
if err := tag.Validate(p); err != nil {
return fmt.Errorf("tag %#v: %w", id, err)
}
}
return nil
}
func (a AppTags) Contains(t string) bool {
_, ok := a[t]
return ok
}
func (a AppTags) ValidateTagList(t PropList) error {
for _, k := range t.Keys() {
if !a.Contains(k) {
return fmt.Errorf("unknown tag %#v", k)
}
}
return nil
}
func (a AppTags) Filtered(authProvider, authUser string) AppTags {
filtered := AppTags{}
for id, tag := range a {
filteredTag := tag.Filtered(authProvider, authUser)
if filteredTag != nil {
filtered[id] = *filteredTag
}
}
return filtered
}
func (a AppTags) Sorted() (entries []struct {
Key string
Value AppTag
}) {
for id, tag := range a {
entries = append(entries, struct {
Key string
Value AppTag
}{id, tag})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Value.Order != entries[j].Value.Order {
return entries[i].Value.Order < entries[j].Value.Order
}
return entries[i].Value.Name < entries[j].Value.Name
})
return entries
}
type AppTag struct {
// The name of the tag.
Name string `toml:"name"`
// A human-readable description.
Desc string `toml:"desc"` // optional
// User specifies the users allowed to see the tag. If set, the tag is not
// visible unless one of the specified users are authenticated.
//
// Format: auth-id::user-id
User PropList `toml:"users"` // optional
// Order is the sort order. Entries are sorted in ascending by their Order,
// then by their Name.
Order int `toml:"order"` // optional
}
func (a AppTag) Validate(p AuthProviders) error {
if a.Name == "" {
return fmt.Errorf("name is required")
}
if err := p.ValidateUserList(a.User); err != nil {
return fmt.Errorf("users: %w", err)
}
return nil
}
func (a AppTag) Filtered(authProvider, authUser string) *AppTag {
if a.User.Len() != 0 && authProvider != "" && !a.User.Contains(authProvider, authUser) {
return nil
}
var filtered AppTag
filtered.Name = a.Name
filtered.Desc = a.Desc
filtered.User = nil
if a.User.Len() != 0 && authUser != "" {
filtered.User = PropList{authProvider + "::" + authUser}
}
filtered.Order = a.Order
return &filtered
}
type AppCategories map[string]AppCategory
func (a AppCategories) Contains(t string) bool {
_, ok := a[t]
return ok
}
func (a AppCategories) Validate(p AuthProviders, t AppTags) error {
for id, cat := range a {
if strings.Contains(id, "::") || strings.Contains(id, ".") {
return fmt.Errorf("category %#v: illegal ID: must not contain '::' or '.'", id)
}
if err := cat.Validate(p, t); err != nil {
return fmt.Errorf("category %#v: %w", id, err)
}
}
return nil
}
func (a AppCategories) Filtered(authProvider, authUser string, filteredTags AppTags) AppCategories {
filtered := AppCategories{}
for id, cat := range a {
filteredCat := cat.Filtered(authProvider, authUser, filteredTags)
if filteredCat != nil {
filtered[id] = *filteredCat
}
}
return filtered
}
func (a AppCategories) Sorted() (entries []struct {
Key string
Value AppCategory
}) {
for id, cat := range a {
entries = append(entries, struct {
Key string
Value AppCategory
}{id, cat})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Value.Order != entries[j].Value.Order {
return entries[i].Value.Order < entries[j].Value.Order
}
return entries[i].Value.Name < entries[j].Value.Name
})
return entries
}
type AppCategory struct {
// The name of the category.
Name string `toml:"name"`
// Apps.
App Apps `toml:"app"`
// User specifies the users allowed to access apps in a category. If set,
// none of the apps are visible unless one of the specified users are
// authenticated.
//
// Format: auth-id::user-id
User PropList `toml:"users"` // optional
// Order is the sort order. Entries are sorted in ascending by their Order,
// then by their Name.
Order int `toml:"order"` // optional
// Whether to hide the category name in the UI.
HideName bool `toml:"hide_name"` // optional
}
func (a AppCategory) Validate(p AuthProviders, t AppTags) error {
if a.Name == "" {
return fmt.Errorf("name is required")
}
if err := a.App.Validate(p, t); err != nil {
return fmt.Errorf("apps: %w", err)
}
if err := p.ValidateUserList(a.User); err != nil {
return fmt.Errorf("users: %w", err)
}
return nil
}
func (a AppCategory) Filtered(authProvider, authUser string, filteredTags AppTags) *AppCategory {
if a.User.Len() != 0 && authProvider != "" && !a.User.Contains(authProvider, authUser) {
return nil
}
var filtered AppCategory
filtered.Name = a.Name
filtered.App = a.App.Filtered(authProvider, authUser, filteredTags)
filtered.User = nil
if a.User.Len() != 0 && authUser != "" {
filtered.User = PropList{authProvider + "::" + authUser}
}
filtered.Order = a.Order
filtered.HideName = a.HideName
return &filtered
}
type Apps map[string]App
func (a Apps) Validate(p AuthProviders, t AppTags) error {
for id, app := range a {
if strings.Contains(id, "::") || strings.Contains(id, ".") {
return fmt.Errorf("app %#v: illegal ID: must not contain '::' or '.'", id)
}
if err := app.Validate(p, t); err != nil {
return fmt.Errorf("app %#v: %w", id, err)
}
}
return nil
}
func (a Apps) Contains(t string) bool {
_, ok := a[t]
return ok
}
func (a Apps) Filtered(authProvider, authUser string, filteredTags AppTags) Apps {
filtered := Apps{}
for id, app := range a {
filteredApp := app.Filtered(authProvider, authUser, filteredTags)
if filteredApp != nil {
filtered[id] = *filteredApp
}
}
return filtered
}
func (a Apps) Sorted() (entries []struct {
Key string
Value App
}) {
for id, app := range a {
entries = append(entries, struct {
Key string
Value App
}{id, app})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Value.Order != entries[j].Value.Order {
return entries[i].Value.Order < entries[j].Value.Order
}
return entries[i].Value.Name < entries[j].Value.Name
})
return entries
}
type App struct {
// The name of the app.
Name string `toml:"name"`
// The base domain for the app.
Domain string `toml:"domain"`
// The link to the app.
Link string `toml:"link"`
// The path to the icon to use for the app.
Icon string `toml:"icon"` // optional
// A long description of the app.
Desc string `toml:"desc"` // optional
// Tag specifies additional data associated with an app.
//
// Format: tag-id::value
Tag PropList `toml:"tags"` // optional
// User specifies the users allowed to access an app.
//
// Format: auth-id::user-id
User PropList `toml:"users"` // optional
// Order is the sort order. Entries are sorted in ascending by their Order,
// then by their Name.
Order int `toml:"order"` // optional
}
func (a App) Validate(p AuthProviders, t AppTags) error {
if a.Name == "" {
return fmt.Errorf("name is required")
}
if a.Domain == "" {
return fmt.Errorf("domain is required")
}
if a.Link == "" {
return fmt.Errorf("link is required")
}
if err := t.ValidateTagList(a.Tag); err != nil {
return fmt.Errorf("tags: %w", err)
}
if err := p.ValidateUserList(a.User); err != nil {
return fmt.Errorf("users: %w", err)
}
return nil
}
func (a App) Filtered(authProvider, authUser string, filteredTags AppTags) *App {
if a.User.Len() != 0 && authProvider != "" && !a.User.Contains(authProvider, authUser) {
return nil
}
var filtered App
filtered.Name = a.Name
filtered.Domain = a.Domain
filtered.Link = a.Link
filtered.Icon = a.Icon
filtered.Desc = a.Desc
filtered.Tag = a.Tag.Filtered(filteredTags.Contains)
filtered.User = nil
if a.User.Len() != 0 && authUser != "" {
filtered.User = PropList{authProvider + "::" + authUser}
}
filtered.Order = a.Order
return &filtered
}
type PropList []string
func (p PropList) Keys() []string {
var keys []string
seen := map[string]bool{}
for _, v := range p {
spl := strings.SplitN(v, "::", 2)
if !seen[spl[0]] {
keys = append(keys, spl[0])
}
seen[spl[0]] = true
}
sort.Strings(keys)
return keys
}
func (p PropList) Props() (map[string][]string, error) {
props := map[string][]string{} // TODO: find a more optimized way to do this (maybe unmarshal directly?)
for _, v := range p {
spl := strings.SplitN(v, "::", 2)
if len(spl) == 1 {
spl = append(spl, "")
}
props[spl[0]] = append(props[spl[0]], spl[1])
}
return props, nil
}
func (p PropList) Len() int {
return len(p)
}
func (p PropList) Contains(key, value string) bool {
if strings.Contains(key, "::") {
panic("invalid :: in key")
}
t := key + "::" + value
for _, v := range p {
if v == t {
return true
}
}
return false
}
func (p PropList) Filtered(allowKey func(string) bool) PropList {
var filtered PropList
for _, v := range p {
spl := strings.SplitN(v, "::", 2)
if allowKey(spl[0]) {
filtered = append(filtered, v)
}
}
return filtered
}