mirror of
https://github.com/safing/portmaster
synced 2025-04-07 12:39:09 +00:00
* Move portbase into monorepo * Add new simple module mgr * [WIP] Switch to new simple module mgr * Add StateMgr and more worker variants * [WIP] Switch more modules * [WIP] Switch more modules * [WIP] swtich more modules * [WIP] switch all SPN modules * [WIP] switch all service modules * [WIP] Convert all workers to the new module system * [WIP] add new task system to module manager * [WIP] Add second take for scheduling workers * [WIP] Add FIXME for bugs in new scheduler * [WIP] Add minor improvements to scheduler * [WIP] Add new worker scheduler * [WIP] Fix more bug related to new module system * [WIP] Fix start handing of the new module system * [WIP] Improve startup process * [WIP] Fix minor issues * [WIP] Fix missing subsystem in settings * [WIP] Initialize managers in constructor * [WIP] Move module event initialization to constrictors * [WIP] Fix setting for enabling and disabling the SPN module * [WIP] Move API registeration into module construction * [WIP] Update states mgr for all modules * [WIP] Add CmdLine operation support * Add state helper methods to module group and instance * Add notification and module status handling to status package * Fix starting issues * Remove pilot widget and update security lock to new status data * Remove debug logs * Improve http server shutdown * Add workaround for cleanly shutting down firewall+netquery * Improve logging * Add syncing states with notifications for new module system * Improve starting, stopping, shutdown; resolve FIXMEs/TODOs * [WIP] Fix most unit tests * Review new module system and fix minor issues * Push shutdown and restart events again via API * Set sleep mode via interface * Update example/template module * [WIP] Fix spn/cabin unit test * Remove deprecated UI elements * Make log output more similar for the logging transition phase * Switch spn hub and observer cmds to new module system * Fix log sources * Make worker mgr less error prone * Fix tests and minor issues * Fix observation hub * Improve shutdown and restart handling * Split up big connection.go source file * Move varint and dsd packages to structures repo * Improve expansion test * Fix linter warnings * Fix interception module on windows * Fix linter errors --------- Co-authored-by: Vladimir Stoilov <vladimir@safing.io>
598 lines
15 KiB
Go
598 lines
15 KiB
Go
package api
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/tevino/abool"
|
|
|
|
"github.com/safing/portmaster/base/config"
|
|
"github.com/safing/portmaster/base/log"
|
|
"github.com/safing/portmaster/base/rng"
|
|
"github.com/safing/portmaster/service/mgr"
|
|
)
|
|
|
|
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.
|
|
type Permission int8
|
|
|
|
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
|
|
)
|
|
|
|
// 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
|
|
ValidUntil *time.Time
|
|
}
|
|
|
|
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(r *http.Request) Permission
|
|
WritePermission(r *http.Request) Permission
|
|
}
|
|
|
|
// SetAuthenticator sets an authenticator function for the API endpoint. If none is set, all requests will be permitted.
|
|
func SetAuthenticator(fn AuthenticatorFunc) error {
|
|
if module.online.Load() {
|
|
return ErrAuthenticationImmutable
|
|
}
|
|
|
|
if !authFnSet.SetToIf(false, true) {
|
|
return ErrAuthenticationAlreadySet
|
|
}
|
|
|
|
authFn = fn
|
|
return nil
|
|
}
|
|
|
|
func authenticateRequest(w http.ResponseWriter, r *http.Request, targetHandler http.Handler, readMethod bool) *AuthToken {
|
|
tracer := log.Tracer(r.Context())
|
|
|
|
// Get required permission for target handler.
|
|
requiredPermission := PermitSelf
|
|
if authdHandler, ok := targetHandler.(AuthenticatedHandler); ok {
|
|
if readMethod {
|
|
requiredPermission = authdHandler.ReadPermission(r)
|
|
} else {
|
|
requiredPermission = authdHandler.WritePermission(r)
|
|
}
|
|
}
|
|
|
|
// Check if we need to do any authentication at all.
|
|
switch requiredPermission { //nolint:exhaustive
|
|
case NotFound:
|
|
// Not found.
|
|
tracer.Debug("api: no API endpoint registered for this path")
|
|
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 readMethod {
|
|
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" domain="/"`,
|
|
)
|
|
http.Error(w, "Authorization required.", http.StatusUnauthorized)
|
|
return nil
|
|
}
|
|
|
|
// Otherwise just inform of insufficient permissions.
|
|
http.Error(w, "Insufficient permissions.", http.StatusForbidden)
|
|
return nil
|
|
}
|
|
|
|
tracer.Tracef("api: granted %s access to protected handler", r.RemoteAddr)
|
|
|
|
// 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 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
|
|
}
|
|
|
|
// Database Bridge Access.
|
|
if r.RemoteAddr == endpointBridgeRemoteAddress {
|
|
return &AuthToken{
|
|
Read: dbCompatibilityPermission,
|
|
Write: dbCompatibilityPermission,
|
|
}, false
|
|
}
|
|
|
|
// 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 from %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
|
|
}
|
|
|
|
// Abort if the token is expired.
|
|
if token.ValidUntil != nil && time.Now().After(*token.ValidUntil) {
|
|
log.Tracer(r.Context()).Warningf("api: denying api access from %s using expired token", r.RemoteAddr)
|
|
return nil
|
|
}
|
|
|
|
return token
|
|
}
|
|
|
|
func updateAPIKeys() {
|
|
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)
|
|
}
|
|
|
|
// whether or not we found expired API keys that should be removed
|
|
// from the setting
|
|
hasExpiredKeys := false
|
|
|
|
// a list of valid API keys. Used when hasExpiredKeys is set to true.
|
|
// in that case we'll update the setting to only contain validAPIKeys
|
|
validAPIKeys := []string{}
|
|
|
|
// 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
|
|
|
|
expireStr := q.Get("expires")
|
|
if expireStr != "" {
|
|
validUntil, err := time.Parse(time.RFC3339, expireStr)
|
|
if err != nil {
|
|
log.Errorf("api: invalid API key %s: %s", key, err)
|
|
continue
|
|
}
|
|
|
|
// continue to the next token if this one is already invalid
|
|
if time.Now().After(validUntil) {
|
|
// mark the key as expired so we'll remove it from the setting afterwards
|
|
hasExpiredKeys = true
|
|
|
|
continue
|
|
}
|
|
|
|
token.ValidUntil = &validUntil
|
|
}
|
|
|
|
// Save token.
|
|
apiKeys[u.Path] = token
|
|
validAPIKeys = append(validAPIKeys, key)
|
|
}
|
|
|
|
if hasExpiredKeys {
|
|
module.mgr.Go("api key cleanup", func(ctx *mgr.WorkerCtx) error {
|
|
if err := config.SetConfigOption(CfgAPIKeys, validAPIKeys); err != nil {
|
|
log.Errorf("api: failed to remove expired API keys: %s", err)
|
|
} else {
|
|
log.Infof("api: removed expired API keys from %s", CfgAPIKeys)
|
|
}
|
|
|
|
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(_ *mgr.WorkerCtx) 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 getEffectiveMethod(r *http.Request) (eMethod string, readMethod bool, ok bool) {
|
|
method := r.Method
|
|
|
|
// Get CORS request method if OPTIONS request.
|
|
if r.Method == http.MethodOptions {
|
|
method = r.Header.Get("Access-Control-Request-Method")
|
|
if method == "" {
|
|
return "", false, false
|
|
}
|
|
}
|
|
|
|
switch method {
|
|
case http.MethodGet, http.MethodHead:
|
|
return http.MethodGet, true, true
|
|
case http.MethodPost, http.MethodPut, http.MethodDelete:
|
|
return method, false, true
|
|
default:
|
|
return "", false, 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"
|
|
case Dynamic, NotFound, NotSupported:
|
|
return "Invalid"
|
|
default:
|
|
return "Invalid"
|
|
}
|
|
}
|