Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow bouncers to share API keys #3323

Merged
merged 16 commits into from
Nov 19, 2024
Merged
2 changes: 1 addition & 1 deletion cmd/crowdsec-cli/clibouncer/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func (cli *cliBouncers) add(ctx context.Context, bouncerName string, key string)
}
}

_, err = cli.db.CreateBouncer(ctx, bouncerName, "", middlewares.HashSHA512(key), types.ApiKeyAuthType)
_, err = cli.db.CreateBouncer(ctx, bouncerName, "", middlewares.HashSHA512(key), types.ApiKeyAuthType, false)
if err != nil {
return fmt.Errorf("unable to create bouncer: %w", err)
}
Expand Down
2 changes: 2 additions & 0 deletions cmd/crowdsec-cli/clibouncer/bouncers.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ type bouncerInfo struct {
AuthType string `json:"auth_type"`
OS string `json:"os,omitempty"`
Featureflags []string `json:"featureflags,omitempty"`
AutoCreated bool `json:"auto_created"`
}

func newBouncerInfo(b *ent.Bouncer) bouncerInfo {
Expand All @@ -92,6 +93,7 @@ func newBouncerInfo(b *ent.Bouncer) bouncerInfo {
AuthType: b.AuthType,
OS: clientinfo.GetOSNameAndVersion(b),
Featureflags: clientinfo.GetFeatureFlagList(b),
AutoCreated: b.AutoCreated,
}
}

Expand Down
62 changes: 55 additions & 7 deletions cmd/crowdsec-cli/clibouncer/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,73 @@
"context"
"errors"
"fmt"
"strings"

log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"

"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/database/ent"
"github.com/crowdsecurity/crowdsec/pkg/types"
)

func (cli *cliBouncers) findParentBouncer(bouncerName string, bouncers []*ent.Bouncer) (string, error) {
bouncerPrefix := strings.Split(bouncerName, "@")[0]
for _, bouncer := range bouncers {
if strings.HasPrefix(bouncer.Name, bouncerPrefix) && !bouncer.AutoCreated {
return bouncer.Name, nil
}
}

return "", errors.New("no parent bouncer found")

Check warning on line 24 in cmd/crowdsec-cli/clibouncer/delete.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/delete.go#L24

Added line #L24 was not covered by tests
}

func (cli *cliBouncers) delete(ctx context.Context, bouncers []string, ignoreMissing bool) error {
for _, bouncerID := range bouncers {
if err := cli.db.DeleteBouncer(ctx, bouncerID); err != nil {
var notFoundErr *database.BouncerNotFoundError
allBouncers, err := cli.db.ListBouncers(ctx)
if err != nil {
return fmt.Errorf("unable to list bouncers: %w", err)
}

Check warning on line 31 in cmd/crowdsec-cli/clibouncer/delete.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/delete.go#L30-L31

Added lines #L30 - L31 were not covered by tests
for _, bouncerName := range bouncers {
bouncer, err := cli.db.SelectBouncerByName(ctx, bouncerName)
if err != nil {
var notFoundErr *ent.NotFoundError
if ignoreMissing && errors.As(err, &notFoundErr) {
return nil
continue
}
return fmt.Errorf("unable to delete bouncer %s: %w", bouncerName, err)
}

// For TLS bouncers, always delete them, they have no parents
if bouncer.AuthType == types.TlsAuthType {
if err := cli.db.DeleteBouncer(ctx, bouncerName); err != nil {
return fmt.Errorf("unable to delete bouncer %s: %w", bouncerName, err)
}

Check warning on line 46 in cmd/crowdsec-cli/clibouncer/delete.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/delete.go#L45-L46

Added lines #L45 - L46 were not covered by tests
continue
}

if bouncer.AutoCreated {
parentBouncer, err := cli.findParentBouncer(bouncerName, allBouncers)
if err != nil {
log.Errorf("bouncer '%s' is auto-created, but couldn't find a parent bouncer", err)
continue

Check warning on line 54 in cmd/crowdsec-cli/clibouncer/delete.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/delete.go#L53-L54

Added lines #L53 - L54 were not covered by tests
}
log.Warnf("bouncer '%s' is auto-created and cannot be deleted, delete parent bouncer %s instead", bouncerName, parentBouncer)
continue
}
//Try to find all child bouncers and delete them
for _, childBouncer := range allBouncers {
if strings.HasPrefix(childBouncer.Name, bouncerName+"@") && childBouncer.AutoCreated {
if err := cli.db.DeleteBouncer(ctx, childBouncer.Name); err != nil {
return fmt.Errorf("unable to delete bouncer %s: %w", childBouncer.Name, err)
}

Check warning on line 64 in cmd/crowdsec-cli/clibouncer/delete.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/delete.go#L63-L64

Added lines #L63 - L64 were not covered by tests
log.Infof("bouncer '%s' deleted successfully", childBouncer.Name)
}
}

return fmt.Errorf("unable to delete bouncer: %w", err)
if err := cli.db.DeleteBouncer(ctx, bouncerName); err != nil {
return fmt.Errorf("unable to delete bouncer %s: %w", bouncerName, err)

Check warning on line 70 in cmd/crowdsec-cli/clibouncer/delete.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/delete.go#L70

Added line #L70 was not covered by tests
}

log.Infof("bouncer '%s' deleted successfully", bouncerID)
log.Infof("bouncer '%s' deleted successfully", bouncerName)
}

return nil
Expand Down
1 change: 1 addition & 0 deletions cmd/crowdsec-cli/clibouncer/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
{"Last Pull", lastPull},
{"Auth type", bouncer.AuthType},
{"OS", clientinfo.GetOSNameAndVersion(bouncer)},
{"Auto Created", bouncer.AutoCreated},

Check warning on line 43 in cmd/crowdsec-cli/clibouncer/inspect.go

View check run for this annotation

Codecov / codecov/patch

cmd/crowdsec-cli/clibouncer/inspect.go#L43

Added line #L43 was not covered by tests
})

for _, ff := range clientinfo.GetFeatureFlagList(bouncer) {
Expand Down
3 changes: 3 additions & 0 deletions pkg/apiserver/alerts_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ func (l *LAPI) RecordResponse(t *testing.T, ctx context.Context, verb string, ur
t.Fatal("auth type not supported")
}

// Port is required for gin to properly parse the client IP
req.RemoteAddr = "127.0.0.1:1234"

l.router.ServeHTTP(w, req)

return w
Expand Down
56 changes: 51 additions & 5 deletions pkg/apiserver/api_key_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,28 +20,74 @@ func TestAPIKey(t *testing.T) {
w := httptest.NewRecorder()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, 403, w.Code)
assert.Equal(t, `{"message":"access forbidden"}`, w.Body.String())
assert.Equal(t, http.StatusForbidden, w.Code)
assert.JSONEq(t, `{"message":"access forbidden"}`, w.Body.String())

// Login with invalid token
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", "a1b2c3d4e5f6")
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, 403, w.Code)
assert.Equal(t, `{"message":"access forbidden"}`, w.Body.String())
assert.Equal(t, http.StatusForbidden, w.Code)
assert.JSONEq(t, `{"message":"access forbidden"}`, w.Body.String())

// Login with valid token
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, 200, w.Code)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Login with valid token from another IP
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "4.3.2.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Make the requests multiple times to make sure we only create one
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "4.3.2.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Use the original bouncer again
w = httptest.NewRecorder()
req, _ = http.NewRequestWithContext(ctx, http.MethodGet, "/v1/decisions", strings.NewReader(""))
req.Header.Add("User-Agent", UserAgent)
req.Header.Add("X-Api-Key", APIKey)
req.RemoteAddr = "127.0.0.1:1234"
router.ServeHTTP(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "null", w.Body.String())

// Check if our second bouncer was properly created
bouncers := GetBouncers(t, config.API.Server.DbConfig)

assert.Len(t, bouncers, 2)
assert.Equal(t, "[email protected]", bouncers[1].Name)
assert.Equal(t, bouncers[0].APIKey, bouncers[1].APIKey)
assert.Equal(t, bouncers[0].AuthType, bouncers[1].AuthType)
assert.False(t, bouncers[0].AutoCreated)
assert.True(t, bouncers[1].AutoCreated)
}
16 changes: 15 additions & 1 deletion pkg/apiserver/apiserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
middlewares "github.com/crowdsecurity/crowdsec/pkg/apiserver/middlewares/v1"
"github.com/crowdsecurity/crowdsec/pkg/csconfig"
"github.com/crowdsecurity/crowdsec/pkg/database"
"github.com/crowdsecurity/crowdsec/pkg/database/ent"
"github.com/crowdsecurity/crowdsec/pkg/models"
"github.com/crowdsecurity/crowdsec/pkg/types"
)
Expand Down Expand Up @@ -62,6 +63,7 @@ func LoadTestConfig(t *testing.T) csconfig.Config {
}
apiServerConfig := csconfig.LocalApiServerCfg{
ListenURI: "http://127.0.0.1:8080",
LogLevel: ptr.Of(log.DebugLevel),
DbConfig: &dbconfig,
ProfilesPath: "./tests/profiles.yaml",
ConsoleConfig: &csconfig.ConsoleConfig{
Expand Down Expand Up @@ -206,6 +208,18 @@ func GetMachineIP(t *testing.T, machineID string, config *csconfig.DatabaseCfg)
return ""
}

func GetBouncers(t *testing.T, config *csconfig.DatabaseCfg) []*ent.Bouncer {
ctx := context.Background()

dbClient, err := database.NewClient(ctx, config)
require.NoError(t, err)

bouncers, err := dbClient.ListBouncers(ctx)
require.NoError(t, err)

return bouncers
}

func GetAlertReaderFromFile(t *testing.T, path string) *strings.Reader {
alertContentBytes, err := os.ReadFile(path)
require.NoError(t, err)
Expand Down Expand Up @@ -290,7 +304,7 @@ func CreateTestBouncer(t *testing.T, ctx context.Context, config *csconfig.Datab
apiKey, err := middlewares.GenerateAPIKey(keyLength)
require.NoError(t, err)

_, err = dbClient.CreateBouncer(ctx, "test", "127.0.0.1", middlewares.HashSHA512(apiKey), types.ApiKeyAuthType)
_, err = dbClient.CreateBouncer(ctx, "test", "127.0.0.1", middlewares.HashSHA512(apiKey), types.ApiKeyAuthType, false)
require.NoError(t, err)

return apiKey
Expand Down
80 changes: 62 additions & 18 deletions pkg/apiserver/middlewares/v1/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@

logger.Infof("Creating bouncer %s", bouncerName)

bouncer, err = a.DbClient.CreateBouncer(ctx, bouncerName, c.ClientIP(), HashSHA512(apiKey), types.TlsAuthType)
bouncer, err = a.DbClient.CreateBouncer(ctx, bouncerName, c.ClientIP(), HashSHA512(apiKey), types.TlsAuthType, true)
if err != nil {
logger.Errorf("while creating bouncer db entry: %s", err)
return nil
Expand All @@ -114,18 +114,69 @@
return nil
}

clientIP := c.ClientIP()

ctx := c.Request.Context()

hashStr := HashSHA512(val[0])

bouncer, err := a.DbClient.SelectBouncer(ctx, hashStr)
// Appsec case, we only care if the key is valid
// No content is returned, no last_pull update or anything
if c.Request.Method == http.MethodHead {
bouncer, err := a.DbClient.SelectBouncers(ctx, hashStr, types.ApiKeyAuthType)
if err != nil {
logger.Errorf("while fetching bouncer info: %s", err)
return nil
}
return bouncer[0]

Check warning on line 131 in pkg/apiserver/middlewares/v1/api_key.go

View check run for this annotation

Codecov / codecov/patch

pkg/apiserver/middlewares/v1/api_key.go#L126-L131

Added lines #L126 - L131 were not covered by tests
}

// most common case, check if this specific bouncer exists
bouncer, err := a.DbClient.SelectBouncerWithIP(ctx, hashStr, clientIP)
if err != nil && !ent.IsNotFound(err) {
logger.Errorf("while fetching bouncer info: %s", err)
return nil
}

Check warning on line 139 in pkg/apiserver/middlewares/v1/api_key.go

View check run for this annotation

Codecov / codecov/patch

pkg/apiserver/middlewares/v1/api_key.go#L137-L139

Added lines #L137 - L139 were not covered by tests

// We found the bouncer with key and IP, we can use it
if bouncer != nil {
if bouncer.AuthType != types.ApiKeyAuthType {
logger.Errorf("bouncer isn't allowed to auth by API key")
return nil
}

Check warning on line 146 in pkg/apiserver/middlewares/v1/api_key.go

View check run for this annotation

Codecov / codecov/patch

pkg/apiserver/middlewares/v1/api_key.go#L144-L146

Added lines #L144 - L146 were not covered by tests
return bouncer
}

// We didn't find the bouncer with key and IP, let's try to find it with the key only
bouncers, err := a.DbClient.SelectBouncers(ctx, hashStr, types.ApiKeyAuthType)
if err != nil {
logger.Errorf("while fetching bouncer info: %s", err)
return nil
}

if bouncer.AuthType != types.ApiKeyAuthType {
logger.Errorf("bouncer %s attempted to login using an API key but it is configured to auth with %s", bouncer.Name, bouncer.AuthType)
if len(bouncers) == 0 {
logger.Debugf("no bouncer found with this key")
return nil
}

logger.Debugf("found %d bouncers with this key", len(bouncers))

// We only have one bouncer with this key and no IP
// This is the first request made by this bouncer, keep this one
if len(bouncers) == 1 && bouncers[0].IPAddress == "" {
return bouncers[0]
}

// Bouncers are ordered by ID, first one *should* be the manually created one
// Can probably get a bit weird if the user deletes the manually created one
bouncerName := fmt.Sprintf("%s@%s", bouncers[0].Name, clientIP)

logger.Infof("Creating bouncer %s", bouncerName)

bouncer, err = a.DbClient.CreateBouncer(ctx, bouncerName, clientIP, hashStr, types.ApiKeyAuthType, true)

if err != nil {
logger.Errorf("while creating bouncer db entry: %s", err)

Check warning on line 179 in pkg/apiserver/middlewares/v1/api_key.go

View check run for this annotation

Codecov / codecov/patch

pkg/apiserver/middlewares/v1/api_key.go#L179

Added line #L179 was not covered by tests
return nil
}

Expand Down Expand Up @@ -156,27 +207,20 @@
return
}

logger = logger.WithField("name", bouncer.Name)

if bouncer.IPAddress == "" {
if err := a.DbClient.UpdateBouncerIP(ctx, clientIP, bouncer.ID); err != nil {
logger.Errorf("Failed to update ip address for '%s': %s\n", bouncer.Name, err)
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
c.Abort()

return
}
// Appsec request, return immediately if we found something
if c.Request.Method == http.MethodHead {
c.Set(BouncerContextKey, bouncer)
return

Check warning on line 213 in pkg/apiserver/middlewares/v1/api_key.go

View check run for this annotation

Codecov / codecov/patch

pkg/apiserver/middlewares/v1/api_key.go#L212-L213

Added lines #L212 - L213 were not covered by tests
}

// Don't update IP on HEAD request, as it's used by the appsec to check the validity of the API key provided
if bouncer.IPAddress != clientIP && bouncer.IPAddress != "" && c.Request.Method != http.MethodHead {
log.Warningf("new IP address detected for bouncer '%s': %s (old: %s)", bouncer.Name, clientIP, bouncer.IPAddress)
logger = logger.WithField("name", bouncer.Name)

// 1st time we see this bouncer, we update its IP
if bouncer.IPAddress == "" {
if err := a.DbClient.UpdateBouncerIP(ctx, clientIP, bouncer.ID); err != nil {
logger.Errorf("Failed to update ip address for '%s': %s\n", bouncer.Name, err)
c.JSON(http.StatusForbidden, gin.H{"message": "access forbidden"})
c.Abort()

return
}
}
Expand Down
Loading