Skip to content

Commit

Permalink
Merge pull request #74 from darklynx/restricted_mode
Browse files Browse the repository at this point in the history
Restricted mode
  • Loading branch information
darklynx committed Jun 7, 2022
2 parents ca6c44b + 0baa706 commit d19f5bc
Show file tree
Hide file tree
Showing 11 changed files with 231 additions and 58 deletions.
28 changes: 17 additions & 11 deletions README.md
Expand Up @@ -84,7 +84,7 @@ Request Baskets service supports several command line configuration parameters.
$ request-baskets --help
Usage of bin/request-baskets:
-db string
Baskets storage type: mem - in-memory, bolt - Bolt DB, sql - SQL database (default "mem")
Baskets storage type: "mem" - in-memory, "bolt" - Bolt DB, "sql" - SQL database (default "mem")
-file string
Database location, only applicable for file or SQL databases (default "./baskets.db")
-conn string
Expand All @@ -105,20 +105,26 @@ Usage of bin/request-baskets:
Name of a basket to auto-create during service startup (can be specified multiple times)
-prefix string
Service URL path prefix
-mode string
Service mode: "public" - any visitor can create a new basket, "restricted" - baskets creation requires master token (default "public")
```

### Parameters

* `-p` *port* - HTTP service listener port, default value is `55555`
* `-page` *size* - default page size when retrieving collections
* `-size` *size* - default new basket capacity, applied if basket capacity is not provided during creation
* `-maxsize` *size* - maximum allowed basket capacity, basket capacity greater than this number will be rejected by service
* `-token` *token* - master token to gain control over all baskets, if not defined a random token will be generated when service is launched and printed to *stdout*
* `-db` *type* - defines baskets storage type: `mem` - in-memory storage (default), `bolt` - [bbolt](https://github.com/etcd-io/bbolt) database, `sql` - SQL database
* `-file` *location* - location of Bolt database file, only relevant if appropriate storage type is chosen
* `-conn` *connection* - database connection string for SQL databases, if undefined `-file` argument is considered
* `-basket` *value* - name of a basket to auto-create during service startup, this parameter can be specified multiple times
* `-prefix` *URL path prefix* - allows to host API and web-UI of baskets service under a sub-path instead of domain ROOT
List of comman line parameters with corresponding ENVVAR for [docker container](./docker/entrypoint.sh):

* `-p` *port* (`PORT`) - HTTP service listener port, default value is `55555`
* `-l` *IP address* (`LISTEN`) - HTTP listener IP address, default `127.0.0.1` (docker default: `0.0.0.0`)
* `-page` *size* (`PAGE`) - default page size when retrieving collections
* `-size` *size* (`SIZE`) - default new basket capacity, applied if basket capacity is not provided during creation
* `-maxsize` *size* (`MAXSIZE`) - maximum allowed basket capacity, basket capacity greater than this number will be rejected by service
* `-token` *token* (`TOKEN`) - master token to gain control over all baskets, if not defined a random token will be generated when service is launched and printed to *stdout*
* `-db` *type* (`DB`) - defines baskets storage type: `mem` - in-memory storage (default), `bolt` - [bbolt](https://github.com/etcd-io/bbolt) database (docker default), `sql` - SQL database
* `-file` *location* (`FILE`) - location of Bolt database file, only relevant if appropriate storage type is chosen
* `-conn` *connection* (`CONN`) - database connection string for SQL databases, if undefined `-file` argument is considered
* `-basket` *value* (`BASKET`) - name of a basket to auto-create during service startup, this parameter can be specified multiple times
* `-prefix` *URL path prefix* (`PATHPREFIX`) - allows to host API and web-UI of baskets service under a sub-path instead of domain ROOT
* `-mode` *mode* (`MODE`) - defines service operation mode: `public` - when any visitor can create a new basket, or `restricted` - baskets creation requires master token

## Usage

Expand Down
10 changes: 8 additions & 2 deletions config.go
Expand Up @@ -35,6 +35,7 @@ type ServerConfig struct {
DbConnection string
Baskets []string
PathPrefix string
Mode string
}

type arrayFlags []string
Expand All @@ -57,10 +58,14 @@ func CreateConfig() *ServerConfig {
var pageSize = flag.Int("page", defaultPageSize, "Default page size")
var masterToken = flag.String("token", "", "Master token, random token is generated if not provided")
var dbType = flag.String("db", defaultDatabaseType, fmt.Sprintf(
"Baskets storage type: %s - in-memory, %s - Bolt DB, %s - SQL database", DbTypeMemory, DbTypeBolt, DbTypeSQL))
"Baskets storage type: \"%s\" - in-memory, \"%s\" - Bolt DB, \"%s\" - SQL database",
DbTypeMemory, DbTypeBolt, DbTypeSQL))
var dbFile = flag.String("file", "./baskets.db", "Database location, only applicable for file or SQL databases")
var dbConnection = flag.String("conn", "", "Database connection string for SQL databases, if undefined \"file\" argument is considered")
var prefix = flag.String("prefix", "", "Service URL path prefix")
var mode = flag.String("mode", ModePublic, fmt.Sprintf(
"Service mode: \"%s\" - any visitor can create a new basket, \"%s\" - baskets creation requires master token",
ModePublic, ModeRestricted))

var baskets arrayFlags
flag.Var(&baskets, "basket", "Name of a basket to auto-create during service startup (can be specified multiple times)")
Expand All @@ -83,7 +88,8 @@ func CreateConfig() *ServerConfig {
DbFile: *dbFile,
DbConnection: *dbConnection,
Baskets: baskets,
PathPrefix: normalizePrefix(*prefix)}
PathPrefix: normalizePrefix(*prefix),
Mode: *mode}
}

func normalizePrefix(prefix string) string {
Expand Down
4 changes: 4 additions & 0 deletions docker/entrypoint.sh
Expand Up @@ -46,6 +46,10 @@ if [ -n "$PATHPREFIX" ]; then
args="$args -prefix $PATHPREFIX"
fi

if [ -n "$MODE" ]; then
args="$args -mode $MODE"
fi

cmd="/bin/rbaskets $args"
echo "Executing: $cmd"
exec $cmd
62 changes: 41 additions & 21 deletions handlers.go
Expand Up @@ -16,6 +16,11 @@ import (
"github.com/julienschmidt/httprouter"
)

const (
ModePublic = "public"
ModeRestricted = "restricted"
)

var validBasketName = regexp.MustCompile(basketNamePattern)
var defaultResponse = ResponseConfig{Status: http.StatusOK, Headers: http.Header{}, IsTemplate: false}
var indexPageTemplate = template.Must(template.New("index").Parse(indexPageContentTemplate))
Expand Down Expand Up @@ -59,14 +64,14 @@ func getPage(values url.Values) (int, int) {
return max, skip
}

// getAuthenticatedBasket fetches basket details by name and authenticates the access to this basket, returns nil in case of failure
func getAuthenticatedBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) (string, Basket) {
// getAuthorizedBasket fetches basket details by name and authorizes the access to this basket, returns nil in case of failure
func getAuthorizedBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params, config *ServerConfig) (string, Basket) {
name := ps.ByName("basket")
if !validBasketName.MatchString(name) {
http.Error(w, "invalid basket name; the name does not match pattern: "+validBasketName.String(), http.StatusBadRequest)
} else if basket := basketsDb.Get(name); basket != nil {
// maybe custom header, e.g. basket_key, basket_token
if token := r.Header.Get("Authorization"); basket.Authorize(token) || token == serverConfig.MasterToken {
if token := r.Header.Get("Authorization"); basket.Authorize(token) || token == config.MasterToken {
return name, basket
}
w.WriteHeader(http.StatusUnauthorized)
Expand All @@ -77,6 +82,21 @@ func getAuthenticatedBasket(w http.ResponseWriter, r *http.Request, ps httproute
return "", nil
}

// authorizeRequest helps to authorize requests for restricted end-points and returns true in case of successful authorization
// publicAPI requires no authorization unless the server mode is set to "restricted"
func authorizeRequest(w http.ResponseWriter, r *http.Request, publicAPI bool, config *ServerConfig) bool {
if publicAPI && config.Mode != ModeRestricted {
return true
}

if r.Header.Get("Authorization") == serverConfig.MasterToken {
return true
}

w.WriteHeader(http.StatusUnauthorized)
return false
}

// validateBasketConfig validates basket configuration
func validateBasketConfig(config *BasketConfig) error {
// validate Capacity
Expand Down Expand Up @@ -138,9 +158,7 @@ func getValidMethod(ps httprouter.Params) (string, error) {

// GetBaskets handles HTTP request to get registered baskets
func GetBaskets(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if r.Header.Get("Authorization") != serverConfig.MasterToken {
w.WriteHeader(http.StatusUnauthorized)
} else {
if authorizeRequest(w, r, false, serverConfig) {
values := r.URL.Query()
if query := values.Get("q"); len(query) > 0 {
// find names
Expand All @@ -157,9 +175,7 @@ func GetBaskets(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

// GetStats handles HTTP request to get database statistics
func GetStats(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if r.Header.Get("Authorization") != serverConfig.MasterToken {
w.WriteHeader(http.StatusUnauthorized)
} else {
if authorizeRequest(w, r, false, serverConfig) {
// get database stats
max := parseInt(r.URL.Query().Get("max"), 1, 100, 5)
json, err := json.Marshal(basketsDb.GetStats(max))
Expand All @@ -176,14 +192,18 @@ func GetVersion(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {

// GetBasket handles HTTP request to get basket configuration
func GetBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if _, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if _, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
json, err := json.Marshal(basket.Config())
writeJSON(w, http.StatusOK, json, err)
}
}

// CreateBasket handles HTTP request to create a new basket
func CreateBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if !authorizeRequest(w, r, true, serverConfig) {
return
}

name := ps.ByName("basket")
if name == serviceOldAPIPath || name == serviceAPIPath || name == serviceUIPath {
http.Error(w, "This basket name conflicts with reserved system path: "+name, http.StatusForbidden)
Expand Down Expand Up @@ -228,7 +248,7 @@ func CreateBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params)

// UpdateBasket handles HTTP request to update basket configuration
func UpdateBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if _, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if _, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
// read config (max 2 kB)
body, err := ioutil.ReadAll(io.LimitReader(r.Body, 2048))
r.Body.Close()
Expand Down Expand Up @@ -257,7 +277,7 @@ func UpdateBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params)

// DeleteBasket handles HTTP request to delete basket
func DeleteBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if name, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if name, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
log.Printf("[info] deleting basket: %s", name)

basketsDb.Delete(name)
Expand All @@ -267,7 +287,7 @@ func DeleteBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params)

// GetBasketResponse handles HTTP request to get basket response configuration
func GetBasketResponse(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if _, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if _, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
method, errm := getValidMethod(ps)
if errm != nil {
http.Error(w, errm.Error(), http.StatusBadRequest)
Expand All @@ -285,7 +305,7 @@ func GetBasketResponse(w http.ResponseWriter, r *http.Request, ps httprouter.Par

// UpdateBasketResponse handles HTTP request to update basket response configuration
func UpdateBasketResponse(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if _, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if _, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
method, errm := getValidMethod(ps)
if errm != nil {
http.Error(w, errm.Error(), http.StatusBadRequest)
Expand Down Expand Up @@ -318,7 +338,7 @@ func UpdateBasketResponse(w http.ResponseWriter, r *http.Request, ps httprouter.

// GetBasketRequests handles HTTP request to get requests collected by basket
func GetBasketRequests(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if _, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if _, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
values := r.URL.Query()
if query := values.Get("q"); len(query) > 0 {
// find requests
Expand All @@ -335,15 +355,15 @@ func GetBasketRequests(w http.ResponseWriter, r *http.Request, ps httprouter.Par

// ClearBasket handles HTTP request to delete all requests collected by basket
func ClearBasket(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
if _, basket := getAuthenticatedBasket(w, r, ps); basket != nil {
if _, basket := getAuthorizedBasket(w, r, ps, serverConfig); basket != nil {
basket.Clear()
w.WriteHeader(http.StatusNoContent)
}
}

// ForwardToWeb handels HTTP forwarding to /web
func ForwardToWeb(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
http.Redirect(w, r, pathPrefix+"/"+serviceUIPath, http.StatusFound)
http.Redirect(w, r, serverConfig.PathPrefix+"/"+serviceUIPath, http.StatusFound)
}

type TemplateData struct {
Expand All @@ -356,7 +376,7 @@ type TemplateData struct {
// WebIndexPage handles HTTP request to render index page
func WebIndexPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
indexPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version})
indexPageTemplate.Execute(w, TemplateData{Prefix: serverConfig.PathPrefix, Version: version})
}

// WebBasketPage handles HTTP request to render basket details page
Expand All @@ -366,9 +386,9 @@ func WebBasketPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params)
case serviceOldAPIPath:
// admin page to access all baskets
w.Header().Set("Content-Type", "text/html; charset=utf-8")
basketsPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version})
basketsPageTemplate.Execute(w, TemplateData{Prefix: serverConfig.PathPrefix, Version: version})
default:
basketPageTemplate.Execute(w, TemplateData{Prefix: pathPrefix, Version: version, Basket: name})
basketPageTemplate.Execute(w, TemplateData{Prefix: serverConfig.PathPrefix, Version: version, Basket: name})
}
} else {
http.Error(w, "Basket name does not match pattern: "+validBasketName.String(), http.StatusBadRequest)
Expand All @@ -377,7 +397,7 @@ func WebBasketPage(w http.ResponseWriter, r *http.Request, ps httprouter.Params)

// AcceptBasketRequests accepts and handles HTTP requests passed to different baskets
func AcceptBasketRequests(w http.ResponseWriter, r *http.Request) {
name, publicErr, err := getBasketNameOfAcceptedRequest(r, pathPrefix)
name, publicErr, err := getBasketNameOfAcceptedRequest(r, serverConfig.PathPrefix)
if err != nil {
log.Printf("[error] %s", err)
http.Error(w, publicErr, http.StatusBadRequest)
Expand Down
42 changes: 42 additions & 0 deletions handlers_test.go
Expand Up @@ -284,6 +284,48 @@ func TestCreateBasket_ReadTimeout(t *testing.T) {
}
}

func TestCreateBasket_Unauthorized(t *testing.T) {
basket := "create10"

serverConfig.Mode = ModeRestricted
r, err := http.NewRequest("POST", "http://localhost:55555/api/baskets/"+basket, strings.NewReader(""))
if assert.NoError(t, err) {
w := httptest.NewRecorder()
ps := append(make(httprouter.Params, 0), httprouter.Param{Key: "basket", Value: basket})
CreateBasket(w, r, ps)

// validate response: 401 - Unauthorized
assert.Equal(t, 401, w.Code, "wrong HTTP result code")

// validate database
assert.Nil(t, basketsDb.Get(basket), "basket '%v' should not be created", basket)
}

serverConfig.Mode = ModePublic
}

func TestCreateBasket_Authorized(t *testing.T) {
basket := "create11"

serverConfig.Mode = ModeRestricted
r, err := http.NewRequest("POST", "http://localhost:55555/api/baskets/"+basket, strings.NewReader(""))
if assert.NoError(t, err) {
r.Header.Add("Authorization", serverConfig.MasterToken)

w := httptest.NewRecorder()
ps := append(make(httprouter.Params, 0), httprouter.Param{Key: "basket", Value: basket})
CreateBasket(w, r, ps)

// validate response: 201 - Created
assert.Equal(t, 201, w.Code, "wrong HTTP result code")

// validate database
assert.NotNil(t, basketsDb.Get(basket), "basket '%v' should be created", basket)
}

serverConfig.Mode = ModePublic
}

func TestGetBasket(t *testing.T) {
basket := "get01"

Expand Down
10 changes: 5 additions & 5 deletions server.go
Expand Up @@ -16,7 +16,6 @@ var basketsDb BasketsDatabase
var httpClient *http.Client
var httpInsecureClient *http.Client
var version *Version
var pathPrefix string

// CreateServer creates an instance of Request Baskets server
func CreateServer(config *ServerConfig) *http.Server {
Expand All @@ -43,9 +42,8 @@ func CreateServer(config *ServerConfig) *http.Server {
insecureTransport := &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}
httpInsecureClient = &http.Client{Transport: insecureTransport}

setPathPrefix(config.PathPrefix)

// configure service HTTP router
pathPrefix := getPathPrefix(config)
router := httprouter.New()

//// Old API mapping ////
Expand Down Expand Up @@ -112,11 +110,13 @@ func createBasketsDatabase(dbtype string, file string, conn string) BasketsDatab
}
}

func setPathPrefix(prefix string) {
pathPrefix = prefix
func getPathPrefix(config *ServerConfig) string {
pathPrefix := config.PathPrefix
if len(pathPrefix) > 0 {
log.Printf("[info] service path prefix: %s", pathPrefix)
}

return pathPrefix
}

func shutdownHook() {
Expand Down
7 changes: 2 additions & 5 deletions server_test.go
Expand Up @@ -80,9 +80,6 @@ func TestCreateDefaultBaskets(t *testing.T) {
}

func TestSetPathPrefix(t *testing.T) {
setPathPrefix("/abc")
assert.Equal(t, "/abc", pathPrefix, "unexpected prefix")

setPathPrefix("")
assert.Empty(t, pathPrefix, "prefix is not expected")
assert.Equal(t, "/abc", getPathPrefix(&ServerConfig{PathPrefix: "/abc"}), "unexpected prefix")
assert.Empty(t, getPathPrefix(&ServerConfig{}), "prefix is not expected")
}
2 changes: 1 addition & 1 deletion web/baskets.html
Expand Up @@ -96,7 +96,7 @@
basketsList.append("<li class='list-group-item'><a href='" + prefix + "/web/basket.html?name=" + basket.name
+ "' title='" + basket.name + "'>" + toDisplayName(basket.name) + "</a> - " + basket.requests_count
+ " (" + toDisplayInt(basket.requests_total_count) + ")<br>Last Request: "
+ new Date(basket.last_request_date).toISOString() + "</li>");
+ ((basket.last_request_date > 0) ? new Date(basket.last_request_date).toISOString() : "n/a") + "</li>");
}
}
}
Expand Down

0 comments on commit d19f5bc

Please sign in to comment.