Add API Key auth and improve endpoints

This commit is contained in:
Daniel 2021-01-19 15:37:55 +01:00
parent 8c6cb758f7
commit 11e8271d41
10 changed files with 529 additions and 164 deletions

View file

@ -4,7 +4,10 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/url"
"strings"
"sync" "sync"
"time" "time"
@ -16,6 +19,28 @@ import (
"github.com/safing/portbase/rng" "github.com/safing/portbase/rng"
) )
const (
sessionCookieName = "Portmaster-API-Token"
sessionCookieTTL = 5 * time.Minute
)
var (
apiKeys = make(map[string]*AuthToken)
apiKeysLock sync.Mutex
authFnSet = abool.New()
authFn AuthenticatorFunc
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("")
)
// Permission defines an API requests permission. // Permission defines an API requests permission.
type Permission int8 type Permission int8
@ -23,9 +48,9 @@ const (
// NotFound declares that the operation does not exist. // NotFound declares that the operation does not exist.
NotFound Permission = -2 NotFound Permission = -2
// Require declares that the operation requires permission to be processed, // Dynamic declares that the operation requires permission to be processed,
// but anyone can execute the operation. // but anyone can execute the operation, as it reacts to permissions itself.
Require Permission = -1 Dynamic Permission = -1
// NotSupported declares that the operation is not supported. // NotSupported declares that the operation is not supported.
NotSupported Permission = 0 NotSupported Permission = 0
@ -61,25 +86,29 @@ type AuthenticatorFunc func(r *http.Request, s *http.Server) (*AuthToken, error)
type AuthToken struct { type AuthToken struct {
Read Permission Read Permission
Write Permission Write Permission
}
type session struct {
sync.Mutex
token *AuthToken
validUntil time.Time validUntil time.Time
validLock sync.Mutex
} }
// Expired returns whether the token has expired. // Expired returns whether the session has expired.
func (token *AuthToken) Expired() bool { func (sess *session) Expired() bool {
token.validLock.Lock() sess.Lock()
defer token.validLock.Unlock() defer sess.Unlock()
return time.Now().After(token.validUntil) return time.Now().After(sess.validUntil)
} }
// Refresh refreshes the validity of the token with the given TTL. // Refresh refreshes the validity of the session with the given TTL.
func (token *AuthToken) Refresh(ttl time.Duration) { func (sess *session) Refresh(ttl time.Duration) {
token.validLock.Lock() sess.Lock()
defer token.validLock.Unlock() defer sess.Unlock()
token.validUntil = time.Now().Add(ttl) sess.validUntil = time.Now().Add(ttl)
} }
// AuthenticatedHandler defines the handler interface to specify custom // AuthenticatedHandler defines the handler interface to specify custom
@ -90,25 +119,6 @@ type AuthenticatedHandler interface {
WritePermission(*http.Request) Permission WritePermission(*http.Request) Permission
} }
const (
cookieName = "Portmaster-API-Token"
cookieTTL = 5 * time.Minute
)
var (
authFnSet = abool.New()
authFn AuthenticatorFunc
authTokens = make(map[string]*AuthToken)
authTokensLock 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("")
)
// SetAuthenticator sets an authenticator function for the API endpoint. If none is set, all requests will be permitted. // SetAuthenticator sets an authenticator function for the API endpoint. If none is set, all requests will be permitted.
func SetAuthenticator(fn AuthenticatorFunc) error { func SetAuthenticator(fn AuthenticatorFunc) error {
if module.Online() { if module.Online() {
@ -126,27 +136,22 @@ func SetAuthenticator(fn AuthenticatorFunc) error {
func authMiddleware(next http.Handler) http.Handler { func authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
token := authenticateRequest(w, r, next) token := authenticateRequest(w, r, next)
if token != nil { if token == nil {
if _, apiRequest := getAPIContext(r); apiRequest != nil { // Authenticator already replied.
apiRequest.AuthToken = token return
}
next.ServeHTTP(w, r)
} }
// Add token to request and serve next handler.
if _, apiRequest := getAPIContext(r); apiRequest != nil {
apiRequest.AuthToken = token
}
next.ServeHTTP(w, r)
}) })
} }
func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler http.Handler) *AuthToken { func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler http.Handler) *AuthToken {
tracer := log.Tracer(r.Context()) tracer := log.Tracer(r.Context())
// Check if authenticator is set.
if !authFnSet.IsSet() {
// Return highest available permissions for the request.
return &AuthToken{
Read: PermitSelf,
Write: PermitSelf,
}
}
// Check if request is read only. // Check if request is read only.
readRequest := isReadMethod(r.Method) readRequest := isReadMethod(r.Method)
@ -179,7 +184,7 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h
Read: PermitAnyone, Read: PermitAnyone,
Write: PermitAnyone, Write: PermitAnyone,
} }
case Require: case Dynamic:
// Continue processing permissions, but treat as PermitAnyone. // Continue processing permissions, but treat as PermitAnyone.
requiredPermission = PermitAnyone requiredPermission = PermitAnyone
} }
@ -196,40 +201,16 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h
return nil return nil
} }
// Check for an existing auth token. // Authenticate request.
token := checkAuthToken(r) token, handled := checkAuth(w, r, requiredPermission > PermitAnyone)
switch {
// Get auth token from authenticator if none was in the request. case handled:
if token == nil { return nil
var err error case token == nil:
token, err = authFn(r, server) // Use default permissions.
if err != nil { token = &AuthToken{
// Check for internal error. Read: PermitAnyone,
if !errors.Is(err, ErrAPIAccessDeniedMessage) { Write: PermitAnyone,
tracer.Errorf("api: authenticator failed: %s", err)
http.Error(w, "Internal server error during authentication.", http.StatusInternalServerError)
return nil
}
// If authentication failed and we require authentication, return an
// authentication error.
if requiredPermission != PermitAnyone {
// Return authentication error.
tracer.Warningf("api: denying api access to %s", r.RemoteAddr)
http.Error(w, err.Error(), http.StatusForbidden)
return nil
}
token = &AuthToken{
Read: PermitAnyone,
Write: PermitAnyone,
}
}
// Apply auth token to request.
err = applyAuthToken(w, token)
if err != nil {
tracer.Warningf("api: failed to create auth token: %s", err)
} }
} }
@ -254,11 +235,19 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h
// Check permission. // Check permission.
if requestPermission < requiredPermission { 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
}
// Otherwise just inform of insufficient permissions.
http.Error(w, "Insufficient permissions.", http.StatusForbidden) http.Error(w, "Insufficient permissions.", http.StatusForbidden)
return nil return nil
} }
tracer.Tracef("api: granted %s access to authenticated handler", r.RemoteAddr) tracer.Tracef("api: granted %s access to protected handler", r.RemoteAddr)
// Make a copy of the AuthToken in order mitigate the handler poisoning the // Make a copy of the AuthToken in order mitigate the handler poisoning the
// token, as changes would apply to future requests. // token, as changes would apply to future requests.
@ -268,85 +257,249 @@ func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler h
} }
} }
func checkAuthToken(r *http.Request) *AuthToken { func checkAuth(w http.ResponseWriter, r *http.Request, authRequired bool) (token *AuthToken, handled bool) {
// Get auth token from request. // Check for valid API key.
c, err := r.Cookie(cookieName) 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 { if err != nil {
return nil return nil
} }
// Check if auth token is registered. // Check if session cookie is registered.
authTokensLock.Lock() sessionsLock.Lock()
token, ok := authTokens[c.Value] sess, ok := sessions[c.Value]
authTokensLock.Unlock() sessionsLock.Unlock()
if !ok { if !ok {
log.Tracer(r.Context()).Tracef("api: provided auth token %s is unknown", c.Value) log.Tracer(r.Context()).Tracef("api: provided session cookie %s is unknown", c.Value)
return nil return nil
} }
// Check if token is still valid. // Check if session is still valid.
if token.Expired() { if sess.Expired() {
log.Tracer(r.Context()).Tracef("api: provided auth token %s has expired", c.Value) log.Tracer(r.Context()).Tracef("api: provided session cookie %s has expired", c.Value)
return nil return nil
} }
// Refresh token and return. // Refresh session and return.
token.Refresh(cookieTTL) sess.Refresh(sessionCookieTTL)
log.Tracer(r.Context()).Tracef("api: auth token %s is valid, refreshing", c.Value) log.Tracer(r.Context()).Tracef("api: session cookie %s is valid, refreshing", c.Value)
return token return sess.token
} }
func applyAuthToken(w http.ResponseWriter, token *AuthToken) error { func createSession(w http.ResponseWriter, r *http.Request, token *AuthToken) error {
// Generate new token secret. // Generate new session key.
secret, err := rng.Bytes(32) // 256 bit secret, err := rng.Bytes(32) // 256 bit
if err != nil { if err != nil {
return err return err
} }
secretHex := base64.RawURLEncoding.EncodeToString(secret) sessionKey := base64.RawURLEncoding.EncodeToString(secret)
// Set token cookie in response. // Set token cookie in response.
http.SetCookie(w, &http.Cookie{ http.SetCookie(w, &http.Cookie{
Name: cookieName, Name: sessionCookieName,
Value: secretHex, Value: sessionKey,
Path: "/", Path: "/",
HttpOnly: true, HttpOnly: true,
SameSite: http.SameSiteStrictMode, SameSite: http.SameSiteStrictMode,
}) })
// Set token TTL. // Create session.
token.Refresh(cookieTTL) sess := &session{
token: token,
}
sess.Refresh(sessionCookieTTL)
// Save token. // Save session.
authTokensLock.Lock() sessionsLock.Lock()
defer authTokensLock.Unlock() defer sessionsLock.Unlock()
authTokens[secretHex] = token sessions[sessionKey] = sess
log.Tracer(r.Context()).Debug("api: issued session cookie")
return nil return nil
} }
func cleanAuthTokens(_ context.Context, _ *modules.Task) error { func cleanSessions(_ context.Context, _ *modules.Task) error {
authTokensLock.Lock() sessionsLock.Lock()
defer authTokensLock.Unlock() defer sessionsLock.Unlock()
for secret, token := range authTokens { for sessionKey, sess := range sessions {
if token.Expired() { if sess.Expired() {
delete(authTokens, secret) delete(sessions, sessionKey)
} }
} }
return nil return nil
} }
func deleteSession(sessionKey string) {
sessionsLock.Lock()
defer sessionsLock.Unlock()
delete(sessions, sessionKey)
}
func isReadMethod(method string) bool { func isReadMethod(method string) bool {
return method == http.MethodGet || method == http.MethodHead return method == http.MethodGet || method == http.MethodHead
} }
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 { func (p Permission) String() string {
switch p { switch p {
case NotSupported: case NotSupported:
return "NotSupported" return "NotSupported"
case Require: case Dynamic:
return "Require" return "Dynamic"
case PermitAnyone: case PermitAnyone:
return "PermitAnyone" return "PermitAnyone"
case PermitUser: case PermitUser:
@ -361,3 +514,19 @@ func (p Permission) String() string {
return "Unknown" 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"
}
}

View file

@ -77,7 +77,7 @@ func TestPermissions(t *testing.T) { //nolint:gocognit
PermitUser, PermitUser,
PermitAdmin, PermitAdmin,
PermitSelf, PermitSelf,
Require, Dynamic,
NotFound, NotFound,
100, // Test a too high value. 100, // Test a too high value.
-100, // Test a too low value. -100, // Test a too low value.
@ -117,7 +117,7 @@ func TestPermissions(t *testing.T) { //nolint:gocognit
case handlerPerm == PermitAnyone: case handlerPerm == PermitAnyone:
// This is fast-tracked. There are not additional checks. // This is fast-tracked. There are not additional checks.
expectSuccess = true expectSuccess = true
case handlerPerm == Require: case handlerPerm == Dynamic:
// This is turned into PermitAnyone in the authenticator. // This is turned into PermitAnyone in the authenticator.
// But authentication is still processed and the result still gets // But authentication is still processed and the result still gets
// sanity checked! // sanity checked!

View file

@ -10,12 +10,15 @@ import (
// Config Keys // Config Keys
const ( const (
CfgDefaultListenAddressKey = "core/listenAddress" CfgDefaultListenAddressKey = "core/listenAddress"
CfgAPIKeys = "core/apiKeys"
) )
var ( var (
listenAddressFlag string listenAddressFlag string
listenAddressConfig config.StringOption listenAddressConfig config.StringOption
defaultListenAddress string defaultListenAddress string
configuredAPIKeys config.StringArrayOption
) )
func init() { func init() {
@ -58,11 +61,28 @@ func registerConfig() error {
} }
listenAddressConfig = config.GetAsString(CfgDefaultListenAddressKey, getDefaultListenAddress()) listenAddressConfig = config.GetAsString(CfgDefaultListenAddressKey, getDefaultListenAddress())
err = config.Register(&config.Option{
Name: "API Keys",
Key: CfgAPIKeys,
Description: "Defined API Keys.",
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{})
return nil return nil
} }
// SetDefaultAPIListenAddress sets the default listen address for the API. // SetDefaultAPIListenAddress sets the default listen address for the API.
func SetDefaultAPIListenAddress(address string) { func SetDefaultAPIListenAddress(address string) {
defaultListenAddress = address defaultListenAddress = address
} }

View file

@ -42,7 +42,13 @@ var (
) )
func init() { 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. // DatabaseAPI is a database API instance.
@ -93,7 +99,7 @@ func startDatabaseAPI(w http.ResponseWriter, r *http.Request) {
go new.handler() go new.handler()
go new.writer() 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() { func (api *DatabaseAPI) handler() {

View file

@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"sort"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@ -23,13 +24,6 @@ type Endpoint struct {
Read Permission Read Permission
Write Permission Write Permission
// TODO: We _could_ expose more metadata to be able to build lists of actions
// automatically.
// Name string
// Description string
// Order int
// ExpertiseLevel config.ExpertiseLevel
// ActionFunc is for simple actions with a return message for the user. // ActionFunc is for simple actions with a return message for the user.
ActionFunc ActionFunc `json:"-"` ActionFunc ActionFunc `json:"-"`
@ -45,6 +39,19 @@ type Endpoint struct {
// HandlerFunc is the raw http handler. // HandlerFunc is the raw http handler.
HandlerFunc http.HandlerFunc `json:"-"` 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 ( type (
@ -141,10 +148,10 @@ func (e *Endpoint) check() error {
} }
// Check permissions. // Check permissions.
if e.Read < Require || e.Read > PermitSelf { if e.Read < Dynamic || e.Read > PermitSelf {
return errors.New("invalid read permission") return errors.New("invalid read permission")
} }
if e.Write < Require || e.Write > PermitSelf { if e.Write < Dynamic || e.Write > PermitSelf {
return errors.New("invalid write permission") return errors.New("invalid write permission")
} }
@ -183,6 +190,28 @@ func (e *Endpoint) check() error {
return nil 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{} type endpointHandler struct{}
var _ AuthenticatedHandler = &endpointHandler{} // Compile time interface check. var _ AuthenticatedHandler = &endpointHandler{} // Compile time interface check.
@ -278,6 +307,7 @@ func (eh *endpointHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Write response. // Write response.
w.Header().Set("Content-Type", apiEndpoint.MimeType+"; charset=utf-8") w.Header().Set("Content-Type", apiEndpoint.MimeType+"; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(responseData))) w.Header().Set("Content-Length", strconv.Itoa(len(responseData)))
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
_, err = w.Write(responseData) _, err = w.Write(responseData)
if err != nil { if err != nil {

View file

@ -3,6 +3,7 @@ package api
import ( import (
"bytes" "bytes"
"fmt" "fmt"
"net/http"
"os" "os"
"runtime/pprof" "runtime/pprof"
@ -11,25 +12,37 @@ import (
func registerDebugEndpoints() error { func registerDebugEndpoints() error {
if err := RegisterEndpoint(Endpoint{ if err := RegisterEndpoint(Endpoint{
Path: "debug/stack", Path: "debug/stack",
Read: PermitAnyone, Read: PermitAnyone,
DataFunc: getStack, DataFunc: getStack,
Name: "Get Goroutine Stack",
Description: "Returns the current goroutine stack.",
}); err != nil { }); err != nil {
return err return err
} }
if err := RegisterEndpoint(Endpoint{ if err := RegisterEndpoint(Endpoint{
Path: "debug/stack/print", Path: "debug/stack/print",
Read: PermitAnyone, Read: PermitAnyone,
ActionFunc: printStack, ActionFunc: printStack,
Name: "Print Goroutine Stack",
Description: "Prints the current goroutine stack to stdout.",
}); err != nil { }); err != nil {
return err return err
} }
if err := RegisterEndpoint(Endpoint{ if err := RegisterEndpoint(Endpoint{
Path: "debug/info", Path: "debug/info",
Read: PermitAnyone, Read: PermitAnyone,
DataFunc: debugInfo, 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 { }); err != nil {
return err return err
} }

View file

@ -3,30 +3,67 @@ package api
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"net/http"
) )
func registerMetaEndpoints() error { func registerMetaEndpoints() error {
if err := RegisterEndpoint(Endpoint{ if err := RegisterEndpoint(Endpoint{
Path: "endpoints", Path: "ping",
Read: PermitAnyone, Read: PermitAnyone,
MimeType: MimeTypeJSON, ActionFunc: ping,
DataFunc: listEndpoints, Name: "Ping",
Description: "Pong.",
}); err != nil { }); err != nil {
return err return err
} }
if err := RegisterEndpoint(Endpoint{ if err := RegisterEndpoint(Endpoint{
Path: "permission", Path: "endpoints",
Read: Require, Read: PermitAnyone,
StructFunc: permissions, MimeType: MimeTypeJSON,
DataFunc: listEndpoints,
Name: "Export API Endpoints",
Description: "Returns a list of all registered endpoints and their metadata.",
}); err != nil { }); err != nil {
return err return err
} }
if err := RegisterEndpoint(Endpoint{ if err := RegisterEndpoint(Endpoint{
Path: "ping", Path: "auth/permissions",
Read: PermitAnyone, Read: Dynamic,
ActionFunc: ping, 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 { }); err != nil {
return err return err
} }
@ -35,10 +72,7 @@ func registerMetaEndpoints() error {
} }
func listEndpoints(ar *Request) (data []byte, err error) { func listEndpoints(ar *Request) (data []byte, err error) {
endpointsLock.Lock() data, err = json.Marshal(ExportEndpoints())
defer endpointsLock.Unlock()
data, err = json.Marshal(endpoints)
return return
} }
@ -48,18 +82,67 @@ func permissions(ar *Request) (i interface{}, err error) {
} }
return struct { return struct {
Read Permission Read Permission
Write Permission Write Permission
ReadPermName string ReadRole string
WritePermName string WriteRole string
}{ }{
Read: ar.AuthToken.Read, Read: ar.AuthToken.Read,
Write: ar.AuthToken.Write, Write: ar.AuthToken.Write,
ReadPermName: ar.AuthToken.Read.String(), ReadRole: ar.AuthToken.Read.Role(),
WritePermName: ar.AuthToken.Write.String(), WriteRole: ar.AuthToken.Write.Role(),
}, nil }, nil
} }
func ping(ar *Request) (msg string, err error) { func ping(ar *Request) (msg string, err error) {
return "Pong.", nil return "Pong.", 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)
}

View file

@ -2,7 +2,10 @@ package api
import ( import (
"context" "context"
"encoding/json"
"errors" "errors"
"flag"
"os"
"time" "time"
"github.com/safing/portbase/modules" "github.com/safing/portbase/modules"
@ -10,6 +13,8 @@ import (
var ( var (
module *modules.Module module *modules.Module
exportEndpoints bool
) )
// API Errors // API Errors
@ -20,9 +25,15 @@ var (
func init() { func init() {
module = modules.Register("api", prep, start, stop, "database", "config") 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 { func prep() error {
if exportEndpoints {
modules.SetCmdLineOperation(exportEndpointsCmd)
}
if getDefaultListenAddress() == "" { if getDefaultListenAddress() == "" {
return errors.New("no default listen address for api available") return errors.New("no default listen address for api available")
} }
@ -35,6 +46,10 @@ func prep() error {
return err return err
} }
if err := registerConfigEndpoints(); err != nil {
return err
}
return registerMetaEndpoints() return registerMetaEndpoints()
} }
@ -42,9 +57,15 @@ func start() error {
logFlagOverrides() logFlagOverrides()
go Serve() 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 // start api auth token cleaner
if authFnSet.IsSet() { if authFnSet.IsSet() {
module.NewTask("clean api auth tokens", cleanAuthTokens).Repeat(5 * time.Minute) module.NewTask("clean api sessions", cleanSessions).Repeat(5 * time.Minute)
} }
return nil return nil
@ -56,3 +77,13 @@ func stop() error {
} }
return nil return nil
} }
func exportEndpointsCmd() error {
data, err := json.MarshalIndent(ExportEndpoints(), "", " ")
if err != nil {
return err
}
_, err = os.Stdout.Write(data)
return err
}

View file

@ -1,9 +1,11 @@
package api package api
import ( import (
"fmt"
"net/http" "net/http"
"github.com/gorilla/mux" "github.com/gorilla/mux"
"github.com/safing/portbase/log"
) )
// Request is a support struct to pool more request related information. // Request is a support struct to pool more request related information.
@ -42,3 +44,14 @@ func GetAPIRequest(r *http.Request) *Request {
} }
return nil 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

@ -92,7 +92,6 @@ func (mh *mainHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error { func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error {
// Setup context trace logging. // Setup context trace logging.
ctx, tracer := log.AddTracer(r.Context()) ctx, tracer := log.AddTracer(r.Context())
lrw := NewLoggingResponseWriter(w, r)
// Add request context. // Add request context.
apiRequest := &Request{ apiRequest := &Request{
Request: r, Request: r,
@ -100,6 +99,7 @@ func (mh *mainHandler) handle(w http.ResponseWriter, r *http.Request) error {
ctx = context.WithValue(ctx, requestContextKey, apiRequest) ctx = context.WithValue(ctx, requestContextKey, apiRequest)
// Add context back to request. // Add context back to request.
r = r.WithContext(ctx) r = r.WithContext(ctx)
lrw := NewLoggingResponseWriter(w, r)
tracer.Tracef("api request: %s ___ %s", r.RemoteAddr, r.RequestURI) tracer.Tracef("api request: %s ___ %s", r.RemoteAddr, r.RequestURI)
defer func() { defer func() {