From 503a116f7c7814fb46719323978e684c98ccf3e7 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Tue, 14 Jun 2022 10:32:54 +0200 Subject: [PATCH] OpenAPI specification and API Adjusts (#356) Introduced an OpenAPI specification. Updated API handlers to use the specification types. Added patch operation for rules and groups and methods to the account manager. HTTP PUT operations require id, fail if not provided. Use snake_case for HTTP request and response body --- go.mod | 11 +- go.sum | 23 +- management/server/account.go | 31 +- management/server/file_store.go | 2 +- management/server/group.go | 70 ++ management/server/http/api/cfg.yaml | 5 + management/server/http/api/generate.sh | 16 + management/server/http/api/openapi.yml | 942 ++++++++++++++++++ management/server/http/api/types.gen.go | 339 +++++++ management/server/http/handler/groups.go | 292 +++++- management/server/http/handler/groups_test.go | 135 ++- management/server/http/handler/peers.go | 85 +- management/server/http/handler/peers_test.go | 7 +- management/server/http/handler/rules.go | 362 +++++-- management/server/http/handler/rules_test.go | 139 ++- management/server/http/handler/setupkeys.go | 75 +- management/server/http/handler/users.go | 34 +- management/server/http/handler/util.go | 17 + management/server/http/server.go | 15 +- management/server/jwtclaims/extractor.go | 3 + management/server/mock_server/account_mock.go | 54 +- management/server/peer.go | 6 + management/server/peer_test.go | 32 + management/server/rule.go | 122 +++ 24 files changed, 2506 insertions(+), 311 deletions(-) create mode 100644 management/server/http/api/cfg.yaml create mode 100644 management/server/http/api/generate.sh create mode 100644 management/server/http/api/openapi.yml create mode 100644 management/server/http/api/types.gen.go diff --git a/go.mod b/go.mod index 2c3aa0865a3..9f1a6b3340c 100644 --- a/go.mod +++ b/go.mod @@ -17,8 +17,8 @@ require ( github.com/spf13/cobra v1.3.0 github.com/spf13/pflag v1.0.5 github.com/vishvananda/netlink v1.1.0 - golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 - golang.org/x/sys v0.0.0-20220412211240-33da011f77ad + golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 + golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a golang.zx2c4.com/wireguard v0.0.0-20211209221555-9c9e7e272434 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20211215182854-7a385b3431de golang.zx2c4.com/wireguard/windows v0.5.1 @@ -84,6 +84,7 @@ require ( github.com/prometheus/client_model v0.2.0 // indirect github.com/prometheus/common v0.33.0 // indirect github.com/prometheus/procfs v0.7.3 // indirect + github.com/rogpeppe/go-internal v1.8.0 // indirect github.com/spf13/cast v1.5.0 // indirect github.com/srwiley/oksvg v0.0.0-20200311192757-870daf9aa564 // indirect github.com/srwiley/rasterx v0.0.0-20200120212402-85cb7272f5e9 // indirect @@ -91,15 +92,15 @@ require ( github.com/yuin/goldmark v1.4.1 // indirect golang.org/x/image v0.0.0-20200430140353-33d19683fad8 // indirect golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect - golang.org/x/net v0.0.0-20220412020605-290c469a71a5 // indirect + golang.org/x/net v0.0.0-20220513224357-95641704303c // indirect golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect golang.org/x/text v0.3.8-0.20211105212822-18b340fc7af2 // indirect golang.org/x/tools v0.1.10 // indirect - golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect + golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect golang.zx2c4.com/go118/netip v0.0.0-20211111135330-a4a02eeacf9d // indirect golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 // indirect google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa // indirect - gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/tomb.v2 v2.0.0-20161208151619-d5d1b5820637 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect diff --git a/go.sum b/go.sum index e8c19aaedbe..c38b83a2b83 100644 --- a/go.sum +++ b/go.sum @@ -461,7 +461,6 @@ github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRW github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= @@ -509,6 +508,7 @@ github.com/pion/turn/v2 v2.0.7 h1:SZhc00WDovK6czaN1RSiHqbwANtIO6wfZQsU0m0KNE8= github.com/pion/turn/v2 v2.0.7/go.mod h1:+y7xl719J8bAEVpSXBXvTxStjJv3hbz9YFflvkpcGPw= github.com/pion/udp v0.1.1 h1:8UAPvyqmsxK8oOjloDk4wUt63TzFe9WEJkg5lChlj7o= github.com/pion/udp v0.1.1/go.mod h1:6AFo+CMdKQm7UiA0eUPA8/eVCTx8jBIITLZHc9DWX5M= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -548,7 +548,8 @@ github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0 github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= +github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= github.com/rs/cors v1.8.0 h1:P2KMzcFwrPoSjkF1WLRPsp3UMLyql8L4v9hQpVeK5so= github.com/rs/cors v1.8.0/go.mod h1:EBwu+T5AvHOcXwvZIkQFjUN6s8Czyqw12GL/Y0tUyRM= github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= @@ -646,8 +647,9 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20211202192323-5770296d904e/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838 h1:71vQrMauZZhcTVK6KdYM+rklehEEwb3E+ZhaE5jrPrE= golang.org/x/crypto v0.0.0-20220131195533-30dcbda58838/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9 h1:NUzdAbFtCJSXU20AOXgeqaUwg8Ypg4MPYmL+d+rsB5c= +golang.org/x/crypto v0.0.0-20220513210258-46612604a0f9/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -754,8 +756,8 @@ golang.org/x/net v0.0.0-20211208012354-db4efeb81f4b/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20211209124913-491a49abca63/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5 h1:bRb386wvrE+oBNdF1d/Xh9mQrfQ4ecYhW5qJ5GvTGT4= -golang.org/x/net v0.0.0-20220412020605-290c469a71a5/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220513224357-95641704303c h1:nF9mHSvoKBLkQNQhJZNsc66z2UzAMUbLGjC95CF3pU0= +golang.org/x/net v0.0.0-20220513224357-95641704303c/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -887,8 +889,8 @@ golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20211214234402-4825e8c3871d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= -golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a h1:N2T1jUrTQE9Re6TFF5PhvEHXHCguynGhKjWVsIUt5cY= +golang.org/x/sys v0.0.0-20220513210249-45d2b4557a2a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.0.0-20160726164857-2910a502d2bf/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -971,8 +973,9 @@ golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U= +golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.zx2c4.com/go118/netip v0.0.0-20211111135330-a4a02eeacf9d h1:9+v0G0naRhLPOJEeJOL6NuXTtAHHwmkyZlgQJ0XcQ8I= golang.zx2c4.com/go118/netip v0.0.0-20211111135330-a4a02eeacf9d/go.mod h1:5yyfuiqVIJ7t+3MqrpTQ+QqRkMWiESiyDvPNvKYCecg= golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224 h1:Ug9qvr1myri/zFN6xL17LSCBGFDnphBBhzmILHsM5TY= @@ -1138,8 +1141,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= -gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= diff --git a/management/server/account.go b/management/server/account.go index 4221e5f59bb..459cabcd3a9 100644 --- a/management/server/account.go +++ b/management/server/account.go @@ -7,7 +7,6 @@ import ( cacheStore "github.com/eko/gocache/v2/store" "github.com/netbirdio/netbird/management/server/idp" "github.com/netbirdio/netbird/management/server/jwtclaims" - "github.com/netbirdio/netbird/util" gocache "github.com/patrickmn/go-cache" "github.com/rs/xid" log "github.com/sirupsen/logrus" @@ -35,7 +34,7 @@ type AccountManager interface { accountId string, keyName string, keyType SetupKeyType, - expiresIn *util.Duration, + expiresIn time.Duration, ) (*SetupKey, error) RevokeSetupKey(accountId string, keyId string) (*SetupKey, error) RenameSetupKey(accountId string, keyId string, newName string) (*SetupKey, error) @@ -55,6 +54,7 @@ type AccountManager interface { GetUsersFromAccount(accountId string) ([]*UserInfo, error) GetGroup(accountId, groupID string) (*Group, error) SaveGroup(accountId string, group *Group) error + UpdateGroup(accountID string, groupID string, operations []GroupUpdateOperation) (*Group, error) DeleteGroup(accountId, groupID string) error ListGroups(accountId string) ([]*Group, error) GroupAddPeer(accountId, groupID, peerKey string) error @@ -62,6 +62,7 @@ type AccountManager interface { GroupListPeers(accountId, groupID string) ([]*Peer, error) GetRule(accountId, ruleID string) (*Rule, error) SaveRule(accountID string, rule *Rule) error + UpdateRule(accountID string, ruleID string, operations []RuleUpdateOperation) (*Rule, error) DeleteRule(accountId, ruleID string) error ListRules(accountId string) ([]*Rule, error) } @@ -222,14 +223,14 @@ func (am *DefaultAccountManager) AddSetupKey( accountId string, keyName string, keyType SetupKeyType, - expiresIn *util.Duration, + expiresIn time.Duration, ) (*SetupKey, error) { am.mux.Lock() defer am.mux.Unlock() keyDuration := DefaultSetupKeyDuration - if expiresIn != nil { - keyDuration = expiresIn.Duration + if expiresIn != 0 { + keyDuration = expiresIn } account, err := am.Store.GetAccount(accountId) @@ -633,7 +634,9 @@ func addAllGroup(account *Account) { defaultRule := &Rule{ ID: xid.New().String(), - Name: "Default", + Name: DefaultRuleName, + Description: DefaultRuleDescription, + Disabled: false, Source: []string{allGroup.ID}, Destination: []string{allGroup.ID}, } @@ -687,3 +690,19 @@ func getAccountSetupKeyByKey(acc *Account, key string) *SetupKey { } return nil } + +func removeFromList(inputList []string, toRemove []string) []string { + toRemoveMap := make(map[string]struct{}) + for _, item := range toRemove { + toRemoveMap[item] = struct{}{} + } + + var resultList []string + for _, item := range inputList { + _, ok := toRemoveMap[item] + if !ok { + resultList = append(resultList, item) + } + } + return resultList +} diff --git a/management/server/file_store.go b/management/server/file_store.go index a3abebb0cc9..bb108514b47 100644 --- a/management/server/file_store.go +++ b/management/server/file_store.go @@ -184,8 +184,8 @@ func (s *FileStore) DeletePeer(accountId string, peerKey string) (*Peer, error) delete(s.PeerKeyId2SrcRulesId, peerKey) // cleanup groups - var peers []string for _, g := range account.Groups { + var peers []string for _, p := range g.Peers { if p != peerKey { peers = append(peers, p) diff --git a/management/server/group.go b/management/server/group.go index de57ac2f39e..00ae3604ce8 100644 --- a/management/server/group.go +++ b/management/server/group.go @@ -17,6 +17,26 @@ type Group struct { Peers []string } +const ( + // UpdateGroupName indicates a name update operation + UpdateGroupName GroupUpdateOperationType = iota + // InsertPeersToGroup indicates insert peers to group operation + InsertPeersToGroup + // RemovePeersFromGroup indicates a remove peers from group operation + RemovePeersFromGroup + // UpdateGroupPeers indicates a replacement of group peers list + UpdateGroupPeers +) + +// GroupUpdateOperationType operation type +type GroupUpdateOperationType int + +// GroupUpdateOperation operation object with type and values to be applied +type GroupUpdateOperation struct { + Type GroupUpdateOperationType + Values []string +} + func (g *Group) Copy() *Group { return &Group{ ID: g.ID, @@ -63,6 +83,56 @@ func (am *DefaultAccountManager) SaveGroup(accountID string, group *Group) error return am.updateAccountPeers(account) } +// UpdateGroup updates a group using a list of operations +func (am *DefaultAccountManager) UpdateGroup(accountID string, + groupID string, operations []GroupUpdateOperation) (*Group, error) { + am.mux.Lock() + defer am.mux.Unlock() + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return nil, status.Errorf(codes.NotFound, "account not found") + } + + groupToUpdate, ok := account.Groups[groupID] + if !ok { + return nil, status.Errorf(codes.NotFound, "group %s no longer exists", groupID) + } + + group := groupToUpdate.Copy() + + for _, operation := range operations { + switch operation.Type { + case UpdateGroupName: + group.Name = operation.Values[0] + case UpdateGroupPeers: + group.Peers = operation.Values + case InsertPeersToGroup: + sourceList := group.Peers + resultList := removeFromList(sourceList, operation.Values) + group.Peers = append(resultList, operation.Values...) + case RemovePeersFromGroup: + sourceList := group.Peers + resultList := removeFromList(sourceList, operation.Values) + group.Peers = resultList + } + } + + account.Groups[groupID] = group + + account.Network.IncSerial() + if err = am.Store.SaveAccount(account); err != nil { + return nil, err + } + + err = am.updateAccountPeers(account) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to update account peers") + } + + return group, nil +} + // DeleteGroup object of the peers func (am *DefaultAccountManager) DeleteGroup(accountID, groupID string) error { am.mux.Lock() diff --git a/management/server/http/api/cfg.yaml b/management/server/http/api/cfg.yaml new file mode 100644 index 00000000000..5c0afab66e1 --- /dev/null +++ b/management/server/http/api/cfg.yaml @@ -0,0 +1,5 @@ +package: api +generate: + models: true + embedded-spec: false +output: types.gen.go diff --git a/management/server/http/api/generate.sh b/management/server/http/api/generate.sh new file mode 100644 index 00000000000..d90db7a5f27 --- /dev/null +++ b/management/server/http/api/generate.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +if ! which realpath > /dev/null 2>&1 +then + echo realpath is not installed + echo run: brew install coreutils + exit 1 +fi + +old_pwd=$(pwd) +script_path=$(dirname $(realpath "$0")) +cd "$script_path" +go install github.com/deepmap/oapi-codegen/cmd/oapi-codegen@v1.11.0 +oapi-codegen --config cfg.yaml openapi.yml +cd "$old_pwd" \ No newline at end of file diff --git a/management/server/http/api/openapi.yml b/management/server/http/api/openapi.yml new file mode 100644 index 00000000000..f8af2710546 --- /dev/null +++ b/management/server/http/api/openapi.yml @@ -0,0 +1,942 @@ +openapi: 3.0.1 +info: + title: NetBird REST API + description: API to manipulate groups, rules and retrieve information about peers and users + version: 0.0.1 +tags: + - name: Users + description: Interact with and view information about users. + - name: Peers + description: Interact with and view information about peers. + - name: Setup Keys + description: Interact with and view information about setup keys. + - name: Groups + description: Interact with and view information about groups. + - name: Rules + description: Interact with and view information about rules. +components: + schemas: + User: + type: object + properties: + id: + description: User ID + type: string + email: + description: User's email address + type: string + name: + description: User's name from idp provider + type: string + role: + description: User's Netbird account role + type: string + required: + - id + - email + - name + - role + PeerMinimum: + type: object + properties: + id: + description: Peer ID + type: string + name: + description: Peer's hostname + type: string + required: + - id + - name + Peer: + allOf: + - $ref: '#/components/schemas/PeerMinimum' + - type: object + properties: + ip: + description: Peer's IP address + type: string + connected: + description: Peer to Management connection status + type: boolean + last_seen: + description: Last time peer connected to Netbird's management service + type: string + format: date-time + os: + description: Peer's operating system and version + type: string + version: + description: Peer's daemon or cli version + type: string + groups: + description: Groups that the peer belongs to + type: array + items: + $ref: '#/components/schemas/GroupMinimum' + activated_by: + description: Provides information of who activated the Peer. User or Setup Key + type: object + properties: + type: + type: string + value: + type: string + required: + - type + - value + required: + - ip + - connected + - last_seen + - os + - version + - groups + - activated_by + SetupKey: + type: object + properties: + id: + description: Setup Key ID + type: string + key: + description: Setup Key value + type: string + name: + description: Setup key name identifier + type: string + expires: + description: Setup Key expiration date + type: string + format: date-time + type: + description: Setup key type, one-off for single time usage and reusable + type: string + valid: + description: Setup key validity status + type: boolean + revoked: + description: Setup key revocation status + type: boolean + used_times: + description: Usage count of setup key + type: integer + last_used: + description: Setup key last usage date + type: string + format: date-time + state: + description: Setup key status, "valid", "overused","expired" or "revoked" + type: string + required: + - id + - key + - name + - expires + - type + - valid + - revoked + - used_times + - last_used + - state + SetupKeyRequest: + type: object + properties: + name: + description: Setup Key name + type: string + type: + description: Setup key type, one-off for single time usage and reusable + type: string + expires_in: + description: Expiration time in seconds + type: integer + revoked: + description: Setup key revocation status + type: boolean + required: + - name + - type + - expires_in + - revoked + GroupMinimum: + type: object + properties: + id: + description: Group ID + type: string + name: + description: Group Name identifier + type: string + peers_count: + description: Count of peers associated to the group + type: integer + required: + - id + - name + - peers_count + Group: + allOf: + - $ref: '#/components/schemas/GroupMinimum' + - type: object + properties: + peers: + description: List of peers object + type: array + items: + $ref: '#/components/schemas/PeerMinimum' + required: + - peers + GroupPatchOperation: + type: object + properties: + op: + description: Patch operation type + type: string + enum: [ "replace","add","remove" ] + path: + description: Group field to update in form / + type: string + enum: [ "name","peers" ] + value: + description: Values to be applied + type: array + items: + type: string + required: + - op + - path + - value + RuleMinimum: + type: object + properties: + name: + description: Rule name identifier + type: string + description: + description: Rule friendly description + type: string + disabled: + description: Rules status + type: boolean + flow: + description: Rule flow, currently, only "bidirect" for bi-directional traffic is accepted + type: string + required: + - name + - description + - disabled + - flow + Rule: + allOf: + - type: object + properties: + id: + description: Rule ID + type: string + required: + - id + - $ref: '#/components/schemas/RuleMinimum' + - type: object + properties: + sources: + description: Rule source groups + type: array + items: + $ref: '#/components/schemas/GroupMinimum' + destinations: + description: Rule destination groups + type: array + items: + $ref: '#/components/schemas/GroupMinimum' + required: + - sources + - destinations + RulePatchOperation: + type: object + properties: + op: + description: Patch operation type + type: string + enum: [ "replace","add","remove" ] + path: + description: Rule field to update in form / + type: string + enum: [ "name","description","disabled","flow","sources","destinations" ] + value: + description: Values to be applied + type: array + items: + type: string + required: + - op + - path + - value + responses: + not_found: + description: Resource not found + content: {} + validation_failed_simple: + description: Validation failed + content: {} + bad_request: + description: Bad Request + content: {} + internal_error: + description: Internal Server Error + content: { } + validation_failed: + description: Validation failed + content: {} + forbidden: + description: Forbidden + content: {} + requires_authentication: + description: Requires authentication + content: {} + securitySchemes: + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +security: + - BearerAuth: [ ] +paths: + /api/users: + get: + summary: Returns a list of all users + tags: [Users] + security: + - BearerAuth: [] + responses: + '200': + description: A JSON array of Users + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/peers: + get: + summary: Returns a list of all peers + tags: [Peers] + security: + - BearerAuth: [] + responses: + '200': + description: A JSON Array of Peers + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Peer' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/peers/{id}: + get: + summary: Get information about a peer + tags: [Peers] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Peer ID + responses: + '200': + description: A Peer object + content: + application/json: + schema: + $ref: '#/components/schemas/Peer' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update information about a peer + tags: [Peers] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Peer ID + requestBody: + description: update to peers + content: + 'application/json': + schema: + type: object + properties: + name: + type: string + required: + - name + responses: + '200': + description: A Peer object + content: + application/json: + schema: + $ref: '#/components/schemas/Peer' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a peer + tags: [Peers] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Peer ID + responses: + '200': + description: Delete status code + content: {} + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/setup-keys: + get: + summary: Returns a list of all Setup Keys + tags: [Setup Keys] + security: + - BearerAuth: [ ] + responses: + '200': + description: A JSON Array of Setup keys + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/SetupKey' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Creates a Setup Key + tags: [Setup Keys] + security: + - BearerAuth: [ ] + requestBody: + description: New Setup Key request + content: + 'application/json': + schema: + $ref: '#/components/schemas/SetupKeyRequest' + responses: + '200': + description: A Setup Keys Object + content: + application/json: + schema: + $ref: '#/components/schemas/SetupKey' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/setup-keys/{id}: + get: + summary: Get information about a Setup Key + tags: [Setup Keys] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Setup Key ID + responses: + '200': + description: A Setup Key object + content: + application/json: + schema: + $ref: '#/components/schemas/SetupKey' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update information about a Setup Key + tags: [Setup Keys] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Setup Key ID + requestBody: + description: update to Setup Key + content: + 'application/json': + schema: + $ref: '#/components/schemas/SetupKeyRequest' + responses: + '200': + description: A Setup Key object + content: + application/json: + schema: + $ref: '#/components/schemas/SetupKey' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a Setup Key + tags: [Setup Keys] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Setup Key ID + responses: + '200': + description: Delete status code + content: {} + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/groups: + get: + summary: Returns a list of all Groups + tags: [Groups] + security: + - BearerAuth: [ ] + responses: + '200': + description: A JSON Array of Groups + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Group' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Creates a Group + tags: [Groups] + security: + - BearerAuth: [ ] + requestBody: + description: New Group request + content: + 'application/json': + schema: + type: object + properties: + name: + type: string + peers: + type: array + items: + type: string + required: + - name + responses: + '200': + description: A Group Object + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/groups/{id}: + get: + summary: Get information about a Group + tags: [Groups] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Group ID + responses: + '200': + description: A Group object + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update/Replace a Group + tags: [Groups] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Group ID + requestBody: + description: Update Group request + content: + 'application/json': + schema: + type: object + properties: + Name: + type: string + Peers: + type: array + items: + type: string + responses: + '200': + description: A Group object + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + patch: + summary: Update information about a Group + tags: [ Groups ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Group ID + requestBody: + description: Update Group request using a list of json patch objects + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/GroupPatchOperation' + responses: + '200': + description: A Group object + content: + application/json: + schema: + $ref: '#/components/schemas/Group' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a Group + tags: [Groups] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Group ID + responses: + '200': + description: Delete status code + content: {} + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + /api/rules: + get: + summary: Returns a list of all Rules + tags: [Rules] + security: + - BearerAuth: [ ] + responses: + '200': + description: A JSON Array of Rules + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Rule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + post: + summary: Creates a Rule + tags: [Rules] + security: + - BearerAuth: [ ] + requestBody: + description: New Rule request + content: + 'application/json': + schema: + allOf: + - $ref: '#/components/schemas/RuleMinimum' + - type: object + properties: + sources: + type: array + items: + type: string + destinations: + type: array + items: + type: string + responses: + '200': + description: A Rule Object + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + /api/rules/{id}: + get: + summary: Get information about a Rules + tags: [Rules] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Rule ID + responses: + '200': + description: A Rule object + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + put: + summary: Update/Replace a Rule + tags: [Rules] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Rule ID + requestBody: + description: Update Rule request + content: + 'application/json': + schema: + allOf: + - $ref: '#/components/schemas/RuleMinimum' + - type: object + properties: + sources: + type: array + items: + type: string + destinations: + type: array + items: + type: string + responses: + '200': + description: A Rule object + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + patch: + summary: Update information about a Rule + tags: [ Rules ] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Rule ID + requestBody: + description: Update Rule request using a list of json patch objects + content: + 'application/json': + schema: + type: array + items: + $ref: '#/components/schemas/RulePatchOperation' + responses: + '200': + description: A Rule object + content: + application/json: + schema: + $ref: '#/components/schemas/Rule' + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" + delete: + summary: Delete a Rule + tags: [Rules] + security: + - BearerAuth: [ ] + parameters: + - in: path + name: id + required: true + schema: + type: string + description: The Rule ID + responses: + '200': + description: Delete status code + content: {} + '400': + "$ref": "#/components/responses/bad_request" + '401': + "$ref": "#/components/responses/requires_authentication" + '403': + "$ref": "#/components/responses/forbidden" + '500': + "$ref": "#/components/responses/internal_error" \ No newline at end of file diff --git a/management/server/http/api/types.gen.go b/management/server/http/api/types.gen.go new file mode 100644 index 00000000000..a58b3352279 --- /dev/null +++ b/management/server/http/api/types.gen.go @@ -0,0 +1,339 @@ +// Package api provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/deepmap/oapi-codegen version v1.11.0 DO NOT EDIT. +package api + +import ( + "time" +) + +const ( + BearerAuthScopes = "BearerAuth.Scopes" +) + +// Defines values for GroupPatchOperationOp. +const ( + GroupPatchOperationOpAdd GroupPatchOperationOp = "add" + GroupPatchOperationOpRemove GroupPatchOperationOp = "remove" + GroupPatchOperationOpReplace GroupPatchOperationOp = "replace" +) + +// Defines values for GroupPatchOperationPath. +const ( + GroupPatchOperationPathName GroupPatchOperationPath = "name" + GroupPatchOperationPathPeers GroupPatchOperationPath = "peers" +) + +// Defines values for RulePatchOperationOp. +const ( + RulePatchOperationOpAdd RulePatchOperationOp = "add" + RulePatchOperationOpRemove RulePatchOperationOp = "remove" + RulePatchOperationOpReplace RulePatchOperationOp = "replace" +) + +// Defines values for RulePatchOperationPath. +const ( + RulePatchOperationPathDescription RulePatchOperationPath = "description" + RulePatchOperationPathDestinations RulePatchOperationPath = "destinations" + RulePatchOperationPathDisabled RulePatchOperationPath = "disabled" + RulePatchOperationPathFlow RulePatchOperationPath = "flow" + RulePatchOperationPathName RulePatchOperationPath = "name" + RulePatchOperationPathSources RulePatchOperationPath = "sources" +) + +// Group defines model for Group. +type Group struct { + // Group ID + Id string `json:"id"` + + // Group Name identifier + Name string `json:"name"` + + // List of peers object + Peers []PeerMinimum `json:"peers"` + + // Count of peers associated to the group + PeersCount int `json:"peers_count"` +} + +// GroupMinimum defines model for GroupMinimum. +type GroupMinimum struct { + // Group ID + Id string `json:"id"` + + // Group Name identifier + Name string `json:"name"` + + // Count of peers associated to the group + PeersCount int `json:"peers_count"` +} + +// GroupPatchOperation defines model for GroupPatchOperation. +type GroupPatchOperation struct { + // Patch operation type + Op GroupPatchOperationOp `json:"op"` + + // Group field to update in form / + Path GroupPatchOperationPath `json:"path"` + + // Values to be applied + Value []string `json:"value"` +} + +// Patch operation type +type GroupPatchOperationOp string + +// Group field to update in form / +type GroupPatchOperationPath string + +// Peer defines model for Peer. +type Peer struct { + // Provides information of who activated the Peer. User or Setup Key + ActivatedBy struct { + Type string `json:"type"` + Value string `json:"value"` + } `json:"activated_by"` + + // Peer to Management connection status + Connected bool `json:"connected"` + + // Groups that the peer belongs to + Groups []GroupMinimum `json:"groups"` + + // Peer ID + Id string `json:"id"` + + // Peer's IP address + Ip string `json:"ip"` + + // Last time peer connected to Netbird's management service + LastSeen time.Time `json:"last_seen"` + + // Peer's hostname + Name string `json:"name"` + + // Peer's operating system and version + Os string `json:"os"` + + // Peer's daemon or cli version + Version string `json:"version"` +} + +// PeerMinimum defines model for PeerMinimum. +type PeerMinimum struct { + // Peer ID + Id string `json:"id"` + + // Peer's hostname + Name string `json:"name"` +} + +// Rule defines model for Rule. +type Rule struct { + // Rule friendly description + Description string `json:"description"` + + // Rule destination groups + Destinations []GroupMinimum `json:"destinations"` + + // Rules status + Disabled bool `json:"disabled"` + + // Rule flow, currently, only "bidirect" for bi-directional traffic is accepted + Flow string `json:"flow"` + + // Rule ID + Id string `json:"id"` + + // Rule name identifier + Name string `json:"name"` + + // Rule source groups + Sources []GroupMinimum `json:"sources"` +} + +// RuleMinimum defines model for RuleMinimum. +type RuleMinimum struct { + // Rule friendly description + Description string `json:"description"` + + // Rules status + Disabled bool `json:"disabled"` + + // Rule flow, currently, only "bidirect" for bi-directional traffic is accepted + Flow string `json:"flow"` + + // Rule name identifier + Name string `json:"name"` +} + +// RulePatchOperation defines model for RulePatchOperation. +type RulePatchOperation struct { + // Patch operation type + Op RulePatchOperationOp `json:"op"` + + // Rule field to update in form / + Path RulePatchOperationPath `json:"path"` + + // Values to be applied + Value []string `json:"value"` +} + +// Patch operation type +type RulePatchOperationOp string + +// Rule field to update in form / +type RulePatchOperationPath string + +// SetupKey defines model for SetupKey. +type SetupKey struct { + // Setup Key expiration date + Expires time.Time `json:"expires"` + + // Setup Key ID + Id string `json:"id"` + + // Setup Key value + Key string `json:"key"` + + // Setup key last usage date + LastUsed time.Time `json:"last_used"` + + // Setup key name identifier + Name string `json:"name"` + + // Setup key revocation status + Revoked bool `json:"revoked"` + + // Setup key status, "valid", "overused","expired" or "revoked" + State string `json:"state"` + + // Setup key type, one-off for single time usage and reusable + Type string `json:"type"` + + // Usage count of setup key + UsedTimes int `json:"used_times"` + + // Setup key validity status + Valid bool `json:"valid"` +} + +// SetupKeyRequest defines model for SetupKeyRequest. +type SetupKeyRequest struct { + // Expiration time in seconds + ExpiresIn int `json:"expires_in"` + + // Setup Key name + Name string `json:"name"` + + // Setup key revocation status + Revoked bool `json:"revoked"` + + // Setup key type, one-off for single time usage and reusable + Type string `json:"type"` +} + +// User defines model for User. +type User struct { + // User's email address + Email string `json:"email"` + + // User ID + Id string `json:"id"` + + // User's name from idp provider + Name string `json:"name"` + + // User's Netbird account role + Role string `json:"role"` +} + +// PostApiGroupsJSONBody defines parameters for PostApiGroups. +type PostApiGroupsJSONBody struct { + Name string `json:"name"` + Peers *[]string `json:"peers,omitempty"` +} + +// PatchApiGroupsIdJSONBody defines parameters for PatchApiGroupsId. +type PatchApiGroupsIdJSONBody = []GroupPatchOperation + +// PutApiGroupsIdJSONBody defines parameters for PutApiGroupsId. +type PutApiGroupsIdJSONBody struct { + Name *string `json:"Name,omitempty"` + Peers *[]string `json:"Peers,omitempty"` +} + +// PutApiPeersIdJSONBody defines parameters for PutApiPeersId. +type PutApiPeersIdJSONBody struct { + Name string `json:"name"` +} + +// PostApiRulesJSONBody defines parameters for PostApiRules. +type PostApiRulesJSONBody struct { + // Rule friendly description + Description string `json:"description"` + Destinations *[]string `json:"destinations,omitempty"` + + // Rules status + Disabled bool `json:"disabled"` + + // Rule flow, currently, only "bidirect" for bi-directional traffic is accepted + Flow string `json:"flow"` + + // Rule name identifier + Name string `json:"name"` + Sources *[]string `json:"sources,omitempty"` +} + +// PatchApiRulesIdJSONBody defines parameters for PatchApiRulesId. +type PatchApiRulesIdJSONBody = []RulePatchOperation + +// PutApiRulesIdJSONBody defines parameters for PutApiRulesId. +type PutApiRulesIdJSONBody struct { + // Rule friendly description + Description string `json:"description"` + Destinations *[]string `json:"destinations,omitempty"` + + // Rules status + Disabled bool `json:"disabled"` + + // Rule flow, currently, only "bidirect" for bi-directional traffic is accepted + Flow string `json:"flow"` + + // Rule name identifier + Name string `json:"name"` + Sources *[]string `json:"sources,omitempty"` +} + +// PostApiSetupKeysJSONBody defines parameters for PostApiSetupKeys. +type PostApiSetupKeysJSONBody = SetupKeyRequest + +// PutApiSetupKeysIdJSONBody defines parameters for PutApiSetupKeysId. +type PutApiSetupKeysIdJSONBody = SetupKeyRequest + +// PostApiGroupsJSONRequestBody defines body for PostApiGroups for application/json ContentType. +type PostApiGroupsJSONRequestBody PostApiGroupsJSONBody + +// PatchApiGroupsIdJSONRequestBody defines body for PatchApiGroupsId for application/json ContentType. +type PatchApiGroupsIdJSONRequestBody = PatchApiGroupsIdJSONBody + +// PutApiGroupsIdJSONRequestBody defines body for PutApiGroupsId for application/json ContentType. +type PutApiGroupsIdJSONRequestBody PutApiGroupsIdJSONBody + +// PutApiPeersIdJSONRequestBody defines body for PutApiPeersId for application/json ContentType. +type PutApiPeersIdJSONRequestBody PutApiPeersIdJSONBody + +// PostApiRulesJSONRequestBody defines body for PostApiRules for application/json ContentType. +type PostApiRulesJSONRequestBody PostApiRulesJSONBody + +// PatchApiRulesIdJSONRequestBody defines body for PatchApiRulesId for application/json ContentType. +type PatchApiRulesIdJSONRequestBody = PatchApiRulesIdJSONBody + +// PutApiRulesIdJSONRequestBody defines body for PutApiRulesId for application/json ContentType. +type PutApiRulesIdJSONRequestBody PutApiRulesIdJSONBody + +// PostApiSetupKeysJSONRequestBody defines body for PostApiSetupKeys for application/json ContentType. +type PostApiSetupKeysJSONRequestBody = PostApiSetupKeysJSONBody + +// PutApiSetupKeysIdJSONRequestBody defines body for PutApiSetupKeysId for application/json ContentType. +type PutApiSetupKeysIdJSONRequestBody = PutApiSetupKeysIdJSONBody diff --git a/management/server/http/handler/groups.go b/management/server/http/handler/groups.go index cf179a52290..66fa8e3e276 100644 --- a/management/server/http/handler/groups.go +++ b/management/server/http/handler/groups.go @@ -3,6 +3,9 @@ package handler import ( "encoding/json" "fmt" + "github.com/netbirdio/netbird/management/server/http/api" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" "net/http" "github.com/netbirdio/netbird/management/server" @@ -13,26 +16,6 @@ import ( log "github.com/sirupsen/logrus" ) -// GroupResponse is a response sent to the client -type GroupResponse struct { - ID string - Name string - Peers []GroupPeerResponse `json:",omitempty"` -} - -// GroupPeerResponse is a response sent to the client -type GroupPeerResponse struct { - Key string - Name string -} - -// GroupRequest to create or update group -type GroupRequest struct { - ID string - Name string - Peers []string -} - // Groups is a handler that returns groups of the account type Groups struct { jwtExtractor jwtclaims.ClaimsExtractor @@ -50,14 +33,14 @@ func NewGroups(accountManager server.AccountManager, authAudience string) *Group // GetAllGroupsHandler list for the account func (h *Groups) GetAllGroupsHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getGroupAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } - var groups []*GroupResponse + var groups []*api.Group for _, g := range account.Groups { groups = append(groups, toGroupResponse(account, g)) } @@ -65,31 +48,209 @@ func (h *Groups) GetAllGroupsHandler(w http.ResponseWriter, r *http.Request) { writeJSONObject(w, groups) } -func (h *Groups) CreateOrUpdateGroupHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getGroupAccount(r) +// UpdateGroupHandler handles update to a group identified by a given ID +func (h *Groups) UpdateGroupHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + groupID, ok := vars["id"] + if !ok { + http.Error(w, "group ID field is missing", http.StatusBadRequest) + return + } + if len(groupID) == 0 { + http.Error(w, "group ID can't be empty", http.StatusUnprocessableEntity) + return + } + + _, ok = account.Groups[groupID] + if !ok { + http.Error(w, fmt.Sprintf("couldn't find group with ID %s", groupID), http.StatusNotFound) + return + } + + allGroup, err := account.GetGroupAll() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + if allGroup.ID == groupID { + http.Error(w, "updating group ALL is not allowed", http.StatusMethodNotAllowed) + return + } + + var req api.PutApiGroupsIdJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if *req.Name == "" { + http.Error(w, "group name shouldn't be empty", http.StatusUnprocessableEntity) + return + } + + group := server.Group{ + ID: groupID, + Name: *req.Name, + Peers: peerIPsToKeys(account, req.Peers), + } + + if err := h.accountManager.SaveGroup(account.Id, &group); err != nil { + log.Errorf("failed updating group %s under account %s %v", groupID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + writeJSONObject(w, toGroupResponse(account, &group)) +} + +// PatchGroupHandler handles patch updates to a group identified by a given ID +func (h *Groups) PatchGroupHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + groupID := vars["id"] + if len(groupID) == 0 { + http.Error(w, "invalid group Id", http.StatusBadRequest) + return + } + + _, ok := account.Groups[groupID] + if !ok { + http.Error(w, fmt.Sprintf("couldn't find group id %s", groupID), http.StatusNotFound) + return + } + + allGroup, err := account.GetGroupAll() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if allGroup.ID == groupID { + http.Error(w, "updating group ALL is not allowed", http.StatusMethodNotAllowed) + return + } + + var req api.PatchApiGroupsIdJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if len(req) == 0 { + http.Error(w, "no patch instruction received", http.StatusBadRequest) + return + } + + var operations []server.GroupUpdateOperation + + for _, patch := range req { + switch patch.Path { + case api.GroupPatchOperationPathName: + if patch.Op != api.GroupPatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Name field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + + if len(patch.Value) == 0 || patch.Value[0] == "" { + http.Error(w, "Group name shouldn't be empty", http.StatusUnprocessableEntity) + return + } + + operations = append(operations, server.GroupUpdateOperation{ + Type: server.UpdateGroupName, + Values: patch.Value, + }) + case api.GroupPatchOperationPathPeers: + switch patch.Op { + case api.GroupPatchOperationOpReplace: + peerKeys := peerIPsToKeys(account, &patch.Value) + operations = append(operations, server.GroupUpdateOperation{ + Type: server.UpdateGroupPeers, + Values: peerKeys, + }) + case api.GroupPatchOperationOpRemove: + peerKeys := peerIPsToKeys(account, &patch.Value) + operations = append(operations, server.GroupUpdateOperation{ + Type: server.RemovePeersFromGroup, + Values: peerKeys, + }) + case api.GroupPatchOperationOpAdd: + peerKeys := peerIPsToKeys(account, &patch.Value) + operations = append(operations, server.GroupUpdateOperation{ + Type: server.InsertPeersToGroup, + Values: peerKeys, + }) + default: + http.Error(w, "invalid operation, \"%s\", for Peers field", http.StatusBadRequest) + return + } + default: + http.Error(w, "invalid patch path", http.StatusBadRequest) + return + } + } + + group, err := h.accountManager.UpdateGroup(account.Id, groupID, operations) + + if err != nil { + errStatus, ok := status.FromError(err) + if ok && errStatus.Code() == codes.Internal { + http.Error(w, errStatus.String(), http.StatusInternalServerError) + return + } + + if ok && errStatus.Code() == codes.NotFound { + http.Error(w, errStatus.String(), http.StatusNotFound) + return + } + + log.Errorf("failed updating group %s under account %s %v", groupID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + writeJSONObject(w, toGroupResponse(account, group)) +} + +// CreateGroupHandler handles group creation request +func (h *Groups) CreateGroupHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { http.Redirect(w, r, "/", http.StatusInternalServerError) return } - var req GroupRequest + var req api.PostApiGroupsJSONRequestBody if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if r.Method == http.MethodPost { - req.ID = xid.New().String() + if req.Name == "" { + http.Error(w, "Group name shouldn't be empty", http.StatusUnprocessableEntity) + return } group := server.Group{ - ID: req.ID, + ID: xid.New().String(), Name: req.Name, - Peers: req.Peers, + Peers: peerIPsToKeys(account, req.Peers), } if err := h.accountManager.SaveGroup(account.Id, &group); err != nil { - log.Errorf("failed updating group %s under account %s %v", req.ID, account.Id, err) + log.Errorf("failed creating group \"%s\" under account %s %v", req.Name, account.Id, err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } @@ -97,22 +258,34 @@ func (h *Groups) CreateOrUpdateGroupHandler(w http.ResponseWriter, r *http.Reque writeJSONObject(w, toGroupResponse(account, &group)) } +// DeleteGroupHandler handles group deletion request func (h *Groups) DeleteGroupHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getGroupAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { http.Redirect(w, r, "/", http.StatusInternalServerError) return } aID := account.Id - gID := mux.Vars(r)["id"] - if len(gID) == 0 { + groupID := mux.Vars(r)["id"] + if len(groupID) == 0 { http.Error(w, "invalid group ID", http.StatusBadRequest) return } - if err := h.accountManager.DeleteGroup(aID, gID); err != nil { - log.Errorf("failed delete group %s under account %s %v", gID, aID, err) + allGroup, err := account.GetGroupAll() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + if allGroup.ID == groupID { + http.Error(w, "deleting group ALL is not allowed", http.StatusMethodNotAllowed) + return + } + + if err := h.accountManager.DeleteGroup(aID, groupID); err != nil { + log.Errorf("failed delete group %s under account %s %v", groupID, aID, err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } @@ -120,8 +293,9 @@ func (h *Groups) DeleteGroupHandler(w http.ResponseWriter, r *http.Request) { writeJSONObject(w, "") } +// GetGroupHandler returns a group func (h *Groups) GetGroupHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getGroupAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { http.Redirect(w, r, "/", http.StatusInternalServerError) return @@ -147,39 +321,51 @@ func (h *Groups) GetGroupHandler(w http.ResponseWriter, r *http.Request) { } } -func (h *Groups) getGroupAccount(r *http.Request) (*server.Account, error) { - jwtClaims := h.jwtExtractor.ExtractClaimsFromRequestContext(r, h.authAudience) - - account, err := h.accountManager.GetAccountWithAuthorizationClaims(jwtClaims) - if err != nil { - return nil, fmt.Errorf("failed getting account of a user %s: %v", jwtClaims.UserId, err) +func peerIPsToKeys(account *server.Account, peerIPs *[]string) []string { + var mappedPeerKeys []string + if peerIPs == nil { + return mappedPeerKeys } - return account, nil + peersChecked := make(map[string]struct{}) + + for _, requestPeersIP := range *peerIPs { + _, ok := peersChecked[requestPeersIP] + if ok { + continue + } + peersChecked[requestPeersIP] = struct{}{} + for _, accountPeer := range account.Peers { + if accountPeer.IP.String() == requestPeersIP { + mappedPeerKeys = append(mappedPeerKeys, accountPeer.Key) + } + } + } + return mappedPeerKeys } -func toGroupResponse(account *server.Account, group *server.Group) *GroupResponse { - cache := make(map[string]GroupPeerResponse) - gr := GroupResponse{ - ID: group.ID, - Name: group.Name, +func toGroupResponse(account *server.Account, group *server.Group) *api.Group { + cache := make(map[string]api.PeerMinimum) + gr := api.Group{ + Id: group.ID, + Name: group.Name, + PeersCount: len(group.Peers), } for _, pid := range group.Peers { - peerResp, ok := cache[pid] + _, ok := cache[pid] if !ok { peer, ok := account.Peers[pid] if !ok { continue } - peerResp = GroupPeerResponse{ - Key: peer.Key, + peerResp := api.PeerMinimum{ + Id: peer.IP.String(), Name: peer.Name, } cache[pid] = peerResp + gr.Peers = append(gr.Peers, peerResp) } - gr.Peers = append(gr.Peers, peerResp) } - return &gr } diff --git a/management/server/http/handler/groups_test.go b/management/server/http/handler/groups_test.go index ed8d46568ab..ba68d4d2b28 100644 --- a/management/server/http/handler/groups_test.go +++ b/management/server/http/handler/groups_test.go @@ -4,7 +4,9 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/netbirdio/netbird/management/server/http/api" "io" + "net" "net/http" "net/http/httptest" "strings" @@ -18,6 +20,11 @@ import ( "github.com/netbirdio/netbird/management/server/mock_server" ) +var TestPeers = map[string]*server.Peer{ + "A": &server.Peer{Key: "A", IP: net.ParseIP("100.100.100.100")}, + "B": &server.Peer{Key: "B", IP: net.ParseIP("200.200.200.200")}, +} + func initGroupTestData(groups ...*server.Group) *Groups { return &Groups{ accountManager: &mock_server.MockAccountManager{ @@ -36,10 +43,38 @@ func initGroupTestData(groups ...*server.Group) *Groups { Name: "Group", }, nil }, + UpdateGroupFunc: func(_ string, groupID string, operations []server.GroupUpdateOperation) (*server.Group, error) { + var group server.Group + group.ID = groupID + for _, operation := range operations { + switch operation.Type { + case server.UpdateGroupName: + group.Name = operation.Values[0] + case server.UpdateGroupPeers, server.InsertPeersToGroup: + group.Peers = operation.Values + case server.RemovePeersFromGroup: + default: + return nil, fmt.Errorf("no operation") + } + } + return &group, nil + }, + GetPeerByIPFunc: func(_ string, peerIP string) (*server.Peer, error) { + for _, peer := range TestPeers { + if peer.IP.String() == peerIP { + return peer, nil + } + } + return nil, fmt.Errorf("peer not found") + }, GetAccountWithAuthorizationClaimsFunc: func(claims jwtclaims.AuthorizationClaims) (*server.Account, error) { return &server.Account{ Id: claims.AccountId, Domain: "hotmail.com", + Peers: TestPeers, + Groups: map[string]*server.Group{ + "id-existed": &server.Group{ID: "id-existed", Peers: []string{"A", "B"}}, + "id-all": &server.Group{ID: "id-all", Name: "All"}}, }, nil }, }, @@ -125,41 +160,114 @@ func TestGetGroup(t *testing.T) { } } -func TestSaveGroup(t *testing.T) { +func TestWriteGroup(t *testing.T) { tt := []struct { name string expectedStatus int expectedBody bool - expectedGroup *server.Group + expectedGroup *api.Group requestType string requestPath string requestBody io.Reader }{ { - name: "SaveGroup POST OK", + name: "Write Group POST OK", requestType: http.MethodPost, requestPath: "/api/groups", requestBody: bytes.NewBuffer( []byte(`{"Name":"Default POSTed Group"}`)), expectedStatus: http.StatusOK, expectedBody: true, - expectedGroup: &server.Group{ - ID: "id-was-set", + expectedGroup: &api.Group{ + Id: "id-was-set", Name: "Default POSTed Group", }, }, { - name: "SaveGroup PUT OK", - requestType: http.MethodPut, + name: "Write Group POST Invalid Name", + requestType: http.MethodPost, requestPath: "/api/groups", requestBody: bytes.NewBuffer( - []byte(`{"ID":"id-existed","Name":"Default POSTed Group"}`)), + []byte(`{"name":""}`)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "Write Group PUT OK", + requestType: http.MethodPut, + requestPath: "/api/groups/id-existed", + requestBody: bytes.NewBuffer( + []byte(`{"Name":"Default POSTed Group"}`)), expectedStatus: http.StatusOK, - expectedGroup: &server.Group{ - ID: "id-existed", + expectedGroup: &api.Group{ + Id: "id-existed", Name: "Default POSTed Group", }, }, + { + name: "Write Group PUT Invalid Name", + requestType: http.MethodPut, + requestPath: "/api/groups/id-existed", + requestBody: bytes.NewBuffer( + []byte(`{"Name":""}`)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "Write Group PUT All Group Name", + requestType: http.MethodPut, + requestPath: "/api/groups/id-all", + requestBody: bytes.NewBuffer( + []byte(`{"Name":"super"}`)), + expectedStatus: http.StatusMethodNotAllowed, + expectedBody: false, + }, + { + name: "Write Group PATCH Name OK", + requestType: http.MethodPatch, + requestPath: "/api/groups/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"replace","path":"name","value":["Default POSTed Group"]}]`)), + expectedStatus: http.StatusOK, + expectedGroup: &api.Group{ + Id: "id-existed", + Name: "Default POSTed Group", + }, + }, + { + name: "Write Group PATCH Invalid Name OP", + requestType: http.MethodPatch, + requestPath: "/api/groups/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"insert","path":"name","value":[""]}]`)), + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, + { + name: "Write Group PATCH Invalid Name", + requestType: http.MethodPatch, + requestPath: "/api/groups/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"replace","path":"name","value":[]}]`)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "Write Group PATCH Peers OK", + requestType: http.MethodPatch, + requestPath: "/api/groups/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"replace","path":"peers","value":["100.100.100.100","200.200.200.200"]}]`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedGroup: &api.Group{ + Id: "id-existed", + PeersCount: 2, + Peers: []api.PeerMinimum{ + {Id: "100.100.100.100"}, + {Id: "200.200.200.200"}}, + }, + }, } p := initGroupTestData() @@ -170,7 +278,9 @@ func TestSaveGroup(t *testing.T) { req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) router := mux.NewRouter() - router.HandleFunc("/api/groups", p.CreateOrUpdateGroupHandler).Methods("PUT", "POST") + router.HandleFunc("/api/groups", p.CreateGroupHandler).Methods("POST") + router.HandleFunc("/api/groups/{id}", p.UpdateGroupHandler).Methods("PUT") + router.HandleFunc("/api/groups/{id}", p.PatchGroupHandler).Methods("PATCH") router.ServeHTTP(recorder, req) res := recorder.Result() @@ -191,11 +301,10 @@ func TestSaveGroup(t *testing.T) { return } - got := &server.Group{} + got := &api.Group{} if err = json.Unmarshal(content, &got); err != nil { t.Fatalf("Sent content is not in correct json format; %v", err) } - assert.Equal(t, got, tc.expectedGroup) }) } diff --git a/management/server/http/handler/peers.go b/management/server/http/handler/peers.go index a8958e08607..2765372a4dc 100644 --- a/management/server/http/handler/peers.go +++ b/management/server/http/handler/peers.go @@ -3,13 +3,12 @@ package handler import ( "encoding/json" "fmt" - "github.com/netbirdio/netbird/management/server/jwtclaims" - "net/http" - "time" - "github.com/gorilla/mux" "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/jwtclaims" log "github.com/sirupsen/logrus" + "net/http" ) //Peers is a handler that returns peers of the account @@ -19,21 +18,6 @@ type Peers struct { jwtExtractor jwtclaims.ClaimsExtractor } -//PeerResponse is a response sent to the client -type PeerResponse struct { - Name string - IP string - Connected bool - LastSeen time.Time - OS string - Version string -} - -//PeerRequest is a request sent by the client -type PeerRequest struct { - Name string -} - func NewPeers(accountManager server.AccountManager, authAudience string) *Peers { return &Peers{ accountManager: accountManager, @@ -42,21 +26,21 @@ func NewPeers(accountManager server.AccountManager, authAudience string) *Peers } } -func (h *Peers) updatePeer(accountId string, peer *server.Peer, w http.ResponseWriter, r *http.Request) { - req := &PeerRequest{} +func (h *Peers) updatePeer(account *server.Account, peer *server.Peer, w http.ResponseWriter, r *http.Request) { + req := &api.PutApiPeersIdJSONBody{} peerIp := peer.IP err := json.NewDecoder(r.Body).Decode(&req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - peer, err = h.accountManager.RenamePeer(accountId, peer.Key, req.Name) + peer, err = h.accountManager.RenamePeer(account.Id, peer.Key, req.Name) if err != nil { - log.Errorf("failed updating peer %s under account %s %v", peerIp, accountId, err) + log.Errorf("failed updating peer %s under account %s %v", peerIp, account.Id, err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } - writeJSONObject(w, toPeerResponse(peer)) + writeJSONObject(w, toPeerResponse(peer, account)) } func (h *Peers) deletePeer(accountId string, peer *server.Peer, w http.ResponseWriter, r *http.Request) { @@ -69,19 +53,8 @@ func (h *Peers) deletePeer(accountId string, peer *server.Peer, w http.ResponseW writeJSONObject(w, "") } -func (h *Peers) getPeerAccount(r *http.Request) (*server.Account, error) { - jwtClaims := h.jwtExtractor.ExtractClaimsFromRequestContext(r, h.authAudience) - - account, err := h.accountManager.GetAccountWithAuthorizationClaims(jwtClaims) - if err != nil { - return nil, fmt.Errorf("failed getting account of a user %s: %v", jwtClaims.UserId, err) - } - - return account, nil -} - func (h *Peers) HandlePeer(w http.ResponseWriter, r *http.Request) { - account, err := h.getPeerAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) @@ -105,10 +78,10 @@ func (h *Peers) HandlePeer(w http.ResponseWriter, r *http.Request) { h.deletePeer(account.Id, peer, w, r) return case http.MethodPut: - h.updatePeer(account.Id, peer, w, r) + h.updatePeer(account, peer, w, r) return case http.MethodGet: - writeJSONObject(w, toPeerResponse(peer)) + writeJSONObject(w, toPeerResponse(peer, account)) return default: @@ -120,16 +93,16 @@ func (h *Peers) HandlePeer(w http.ResponseWriter, r *http.Request) { func (h *Peers) GetPeers(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: - account, err := h.getPeerAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } - respBody := []*PeerResponse{} + respBody := []*api.Peer{} for _, peer := range account.Peers { - respBody = append(respBody, toPeerResponse(peer)) + respBody = append(respBody, toPeerResponse(peer, account)) } writeJSONObject(w, respBody) return @@ -138,13 +111,35 @@ func (h *Peers) GetPeers(w http.ResponseWriter, r *http.Request) { } } -func toPeerResponse(peer *server.Peer) *PeerResponse { - return &PeerResponse{ +func toPeerResponse(peer *server.Peer, account *server.Account) *api.Peer { + var groupsInfo []api.GroupMinimum + groupsChecked := make(map[string]struct{}) + for _, group := range account.Groups { + _, ok := groupsChecked[group.ID] + if ok { + continue + } + groupsChecked[group.ID] = struct{}{} + for _, pk := range group.Peers { + if pk == peer.Key { + info := api.GroupMinimum{ + Id: group.ID, + Name: group.Name, + PeersCount: len(group.Peers), + } + groupsInfo = append(groupsInfo, info) + break + } + } + } + return &api.Peer{ + Id: peer.IP.String(), Name: peer.Name, - IP: peer.IP.String(), + Ip: peer.IP.String(), Connected: peer.Status.Connected, LastSeen: peer.Status.LastSeen, - OS: fmt.Sprintf("%s %s", peer.Meta.OS, peer.Meta.Core), + Os: fmt.Sprintf("%s %s", peer.Meta.OS, peer.Meta.Core), Version: peer.Meta.WtVersion, + Groups: groupsInfo, } } diff --git a/management/server/http/handler/peers_test.go b/management/server/http/handler/peers_test.go index c3ade8ff81b..132ee1d6d14 100644 --- a/management/server/http/handler/peers_test.go +++ b/management/server/http/handler/peers_test.go @@ -2,6 +2,7 @@ package handler import ( "encoding/json" + "github.com/netbirdio/netbird/management/server/http/api" "io" "net" "net/http" @@ -98,7 +99,7 @@ func TestGetPeers(t *testing.T) { t.Fatalf("I don't know what I expected; %v", err) } - respBody := []*PeerResponse{} + respBody := []*api.Peer{} err = json.Unmarshal(content, &respBody) if err != nil { t.Fatalf("Sent content is not in correct json format; %v", err) @@ -107,8 +108,8 @@ func TestGetPeers(t *testing.T) { got := respBody[0] assert.Equal(t, got.Name, peer.Name) assert.Equal(t, got.Version, peer.Meta.WtVersion) - assert.Equal(t, got.IP, peer.IP.String()) - assert.Equal(t, got.OS, "OS core") + assert.Equal(t, got.Ip, peer.IP.String()) + assert.Equal(t, got.Os, "OS core") }) } } diff --git a/management/server/http/handler/rules.go b/management/server/http/handler/rules.go index 514222f8b59..16220f30b5e 100644 --- a/management/server/http/handler/rules.go +++ b/management/server/http/handler/rules.go @@ -3,43 +3,17 @@ package handler import ( "encoding/json" "fmt" - "net/http" - + "github.com/gorilla/mux" "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/http/api" "github.com/netbirdio/netbird/management/server/jwtclaims" "github.com/rs/xid" - - "github.com/gorilla/mux" log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "net/http" ) -const FlowBidirectString = "bidirect" - -// RuleResponse is a response sent to the client -type RuleResponse struct { - ID string - Name string - Source []RuleGroupResponse - Destination []RuleGroupResponse - Flow string -} - -// RuleGroupResponse is a response sent to the client -type RuleGroupResponse struct { - ID string - Name string - PeersCount int -} - -// RuleRequest to create or update rule -type RuleRequest struct { - ID string - Name string - Source []string - Destination []string - Flow string -} - // Rules is a handler that returns rules of the account type Rules struct { jwtExtractor jwtclaims.ClaimsExtractor @@ -57,14 +31,14 @@ func NewRules(accountManager server.AccountManager, authAudience string) *Rules // GetAllRulesHandler list for the account func (h *Rules) GetAllRulesHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getRuleAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } - rules := []*RuleResponse{} + rules := []*api.Rule{} for _, r := range account.Rules { rules = append(rules, toRuleResponse(account, r)) } @@ -72,32 +46,273 @@ func (h *Rules) GetAllRulesHandler(w http.ResponseWriter, r *http.Request) { writeJSONObject(w, rules) } -func (h *Rules) CreateOrUpdateRuleHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getRuleAccount(r) +// UpdateRuleHandler handles update to a rule identified by a given ID +func (h *Rules) UpdateRuleHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + ruleID := vars["id"] + if len(ruleID) == 0 { + http.Error(w, "invalid rule Id", http.StatusBadRequest) + return + } + + _, ok := account.Rules[ruleID] + if !ok { + http.Error(w, fmt.Sprintf("couldn't find rule id %s", ruleID), http.StatusNotFound) + return + } + + var req api.PutApiRulesIdJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "Rule name shouldn't be empty", http.StatusUnprocessableEntity) + return + } + + var reqSources []string + if req.Sources != nil { + reqSources = *req.Sources + } + + var reqDestinations []string + if req.Destinations != nil { + reqDestinations = *req.Destinations + } + + rule := server.Rule{ + ID: ruleID, + Name: req.Name, + Source: reqSources, + Destination: reqDestinations, + Disabled: req.Disabled, + Description: req.Description, + } + + switch req.Flow { + case server.TrafficFlowBidirectString: + rule.Flow = server.TrafficFlowBidirect + default: + http.Error(w, "unknown flow type", http.StatusBadRequest) + return + } + + if err := h.accountManager.SaveRule(account.Id, &rule); err != nil { + log.Errorf("failed updating rule \"%s\" under account %s %v", ruleID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + resp := toRuleResponse(account, &rule) + + writeJSONObject(w, &resp) +} + +// PatchRuleHandler handles patch updates to a rule identified by a given ID +func (h *Rules) PatchRuleHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) + if err != nil { + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + vars := mux.Vars(r) + ruleID := vars["id"] + if len(ruleID) == 0 { + http.Error(w, "invalid rule Id", http.StatusBadRequest) + return + } + + _, ok := account.Rules[ruleID] + if !ok { + http.Error(w, fmt.Sprintf("couldn't find rule id %s", ruleID), http.StatusNotFound) + return + } + + var req api.PatchApiRulesIdJSONRequestBody + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if len(req) == 0 { + http.Error(w, "no patch instruction received", http.StatusBadRequest) + return + } + + var operations []server.RuleUpdateOperation + + for _, patch := range req { + switch patch.Path { + case api.RulePatchOperationPathName: + if patch.Op != api.RulePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Name field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + if len(patch.Value) == 0 || patch.Value[0] == "" { + http.Error(w, "Rule name shouldn't be empty", http.StatusUnprocessableEntity) + return + } + operations = append(operations, server.RuleUpdateOperation{ + Type: server.UpdateRuleName, + Values: patch.Value, + }) + case api.RulePatchOperationPathDescription: + if patch.Op != api.RulePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Description field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RuleUpdateOperation{ + Type: server.UpdateRuleDescription, + Values: patch.Value, + }) + case api.RulePatchOperationPathFlow: + if patch.Op != api.RulePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Flow field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RuleUpdateOperation{ + Type: server.UpdateRuleFlow, + Values: patch.Value, + }) + case api.RulePatchOperationPathDisabled: + if patch.Op != api.RulePatchOperationOpReplace { + http.Error(w, fmt.Sprintf("Disabled field only accepts replace operation, got %s", patch.Op), + http.StatusBadRequest) + return + } + operations = append(operations, server.RuleUpdateOperation{ + Type: server.UpdateRuleStatus, + Values: patch.Value, + }) + case api.RulePatchOperationPathSources: + switch patch.Op { + case api.RulePatchOperationOpReplace: + operations = append(operations, server.RuleUpdateOperation{ + Type: server.UpdateSourceGroups, + Values: patch.Value, + }) + case api.RulePatchOperationOpRemove: + operations = append(operations, server.RuleUpdateOperation{ + Type: server.RemoveGroupsFromSource, + Values: patch.Value, + }) + case api.RulePatchOperationOpAdd: + operations = append(operations, server.RuleUpdateOperation{ + Type: server.InsertGroupsToSource, + Values: patch.Value, + }) + default: + http.Error(w, "invalid operation, \"%s\", for Source field", http.StatusBadRequest) + return + } + case api.RulePatchOperationPathDestinations: + switch patch.Op { + case api.RulePatchOperationOpReplace: + operations = append(operations, server.RuleUpdateOperation{ + Type: server.UpdateDestinationGroups, + Values: patch.Value, + }) + case api.RulePatchOperationOpRemove: + operations = append(operations, server.RuleUpdateOperation{ + Type: server.RemoveGroupsFromDestination, + Values: patch.Value, + }) + case api.RulePatchOperationOpAdd: + operations = append(operations, server.RuleUpdateOperation{ + Type: server.InsertGroupsToDestination, + Values: patch.Value, + }) + default: + http.Error(w, "invalid operation, \"%s\", for Destination field", http.StatusBadRequest) + return + } + default: + http.Error(w, "invalid patch path", http.StatusBadRequest) + return + } + } + + rule, err := h.accountManager.UpdateRule(account.Id, ruleID, operations) + + if err != nil { + errStatus, ok := status.FromError(err) + if ok && errStatus.Code() == codes.Internal { + http.Error(w, errStatus.String(), http.StatusInternalServerError) + return + } + + if ok && errStatus.Code() == codes.NotFound { + http.Error(w, errStatus.String(), http.StatusNotFound) + return + } + + if ok && errStatus.Code() == codes.InvalidArgument { + http.Error(w, errStatus.String(), http.StatusBadRequest) + return + } + + log.Errorf("failed updating rule %s under account %s %v", ruleID, account.Id, err) + http.Redirect(w, r, "/", http.StatusInternalServerError) + return + } + + resp := toRuleResponse(account, rule) + + writeJSONObject(w, &resp) +} + +// CreateRuleHandler handles rule creation request +func (h *Rules) CreateRuleHandler(w http.ResponseWriter, r *http.Request) { + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { http.Redirect(w, r, "/", http.StatusInternalServerError) return } - var req RuleRequest + var req api.PostApiRulesJSONRequestBody if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if r.Method == http.MethodPost { - req.ID = xid.New().String() + if req.Name == "" { + http.Error(w, "Rule name shouldn't be empty", http.StatusUnprocessableEntity) + return + } + + var reqSources []string + if req.Sources != nil { + reqSources = *req.Sources + } + + var reqDestinations []string + if req.Destinations != nil { + reqDestinations = *req.Destinations } rule := server.Rule{ - ID: req.ID, + ID: xid.New().String(), Name: req.Name, - Source: req.Source, - Destination: req.Destination, + Source: reqSources, + Destination: reqDestinations, + Disabled: req.Disabled, + Description: req.Description, } switch req.Flow { - case FlowBidirectString: + case server.TrafficFlowBidirectString: rule.Flow = server.TrafficFlowBidirect default: http.Error(w, "unknown flow type", http.StatusBadRequest) @@ -105,16 +320,19 @@ func (h *Rules) CreateOrUpdateRuleHandler(w http.ResponseWriter, r *http.Request } if err := h.accountManager.SaveRule(account.Id, &rule); err != nil { - log.Errorf("failed updating rule %s under account %s %v", req.ID, account.Id, err) + log.Errorf("failed creating rule \"%s\" under account %s %v", req.Name, account.Id, err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } - writeJSONObject(w, &req) + resp := toRuleResponse(account, &rule) + + writeJSONObject(w, &resp) } +// DeleteRuleHandler handles rule deletion request func (h *Rules) DeleteRuleHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getRuleAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { http.Redirect(w, r, "/", http.StatusInternalServerError) return @@ -136,8 +354,9 @@ func (h *Rules) DeleteRuleHandler(w http.ResponseWriter, r *http.Request) { writeJSONObject(w, "") } +// GetRuleHandler handles a group Get request identified by ID func (h *Rules) GetRuleHandler(w http.ResponseWriter, r *http.Request) { - account, err := h.getRuleAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { http.Redirect(w, r, "/", http.StatusInternalServerError) return @@ -163,47 +382,54 @@ func (h *Rules) GetRuleHandler(w http.ResponseWriter, r *http.Request) { } } -func (h *Rules) getRuleAccount(r *http.Request) (*server.Account, error) { - jwtClaims := h.jwtExtractor.ExtractClaimsFromRequestContext(r, h.authAudience) - - account, err := h.accountManager.GetAccountWithAuthorizationClaims(jwtClaims) - if err != nil { - return nil, fmt.Errorf("failed getting account of a user %s: %v", jwtClaims.UserId, err) - } - - return account, nil -} - -func toRuleResponse(account *server.Account, rule *server.Rule) *RuleResponse { - gr := RuleResponse{ - ID: rule.ID, - Name: rule.Name, +func toRuleResponse(account *server.Account, rule *server.Rule) *api.Rule { + cache := make(map[string]api.GroupMinimum) + gr := api.Rule{ + Id: rule.ID, + Name: rule.Name, + Description: rule.Description, + Disabled: rule.Disabled, } switch rule.Flow { case server.TrafficFlowBidirect: - gr.Flow = FlowBidirectString + gr.Flow = server.TrafficFlowBidirectString default: gr.Flow = "unknown" } for _, gid := range rule.Source { + _, ok := cache[gid] + if ok { + continue + } + if group, ok := account.Groups[gid]; ok { - gr.Source = append(gr.Source, RuleGroupResponse{ - ID: group.ID, + minimum := api.GroupMinimum{ + Id: group.ID, Name: group.Name, PeersCount: len(group.Peers), - }) + } + + gr.Sources = append(gr.Sources, minimum) + cache[gid] = minimum } } for _, gid := range rule.Destination { + cachedMinimum, ok := cache[gid] + if ok { + gr.Destinations = append(gr.Destinations, cachedMinimum) + continue + } if group, ok := account.Groups[gid]; ok { - gr.Destination = append(gr.Destination, RuleGroupResponse{ - ID: group.ID, + minimum := api.GroupMinimum{ + Id: group.ID, Name: group.Name, PeersCount: len(group.Peers), - }) + } + gr.Destinations = append(gr.Destinations, minimum) + cache[gid] = minimum } } diff --git a/management/server/http/handler/rules_test.go b/management/server/http/handler/rules_test.go index 958beb4c6d0..3d307c3fed1 100644 --- a/management/server/http/handler/rules_test.go +++ b/management/server/http/handler/rules_test.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/json" "fmt" + "github.com/netbirdio/netbird/management/server/http/api" "io" "net/http" "net/http/httptest" @@ -39,10 +40,41 @@ func initRulesTestData(rules ...*server.Rule) *Rules { Flow: server.TrafficFlowBidirect, }, nil }, + UpdateRuleFunc: func(_ string, ruleID string, operations []server.RuleUpdateOperation) (*server.Rule, error) { + var rule server.Rule + rule.ID = ruleID + for _, operation := range operations { + switch operation.Type { + case server.UpdateRuleName: + rule.Name = operation.Values[0] + case server.UpdateRuleDescription: + rule.Description = operation.Values[0] + case server.UpdateRuleFlow: + if server.TrafficFlowBidirectString == operation.Values[0] { + rule.Flow = server.TrafficFlowBidirect + } else { + rule.Flow = 100 + } + case server.UpdateSourceGroups, server.InsertGroupsToSource: + rule.Source = operation.Values + case server.UpdateDestinationGroups, server.InsertGroupsToDestination: + rule.Destination = operation.Values + case server.RemoveGroupsFromSource, server.RemoveGroupsFromDestination: + default: + return nil, fmt.Errorf("no operation") + } + } + return &rule, nil + }, GetAccountWithAuthorizationClaimsFunc: func(claims jwtclaims.AuthorizationClaims) (*server.Account, error) { return &server.Account{ Id: claims.AccountId, Domain: "hotmail.com", + Rules: map[string]*server.Rule{"id-existed": &server.Rule{ID: "id-existed"}}, + Groups: map[string]*server.Group{ + "F": &server.Group{ID: "F"}, + "G": &server.Group{ID: "G"}, + }, }, nil }, }, @@ -117,52 +149,118 @@ func TestRulesGetRule(t *testing.T) { t.Fatalf("I don't know what I expected; %v", err) } - var got RuleResponse + var got api.Rule if err = json.Unmarshal(content, &got); err != nil { t.Fatalf("Sent content is not in correct json format; %v", err) } - assert.Equal(t, got.ID, rule.ID) + assert.Equal(t, got.Id, rule.ID) assert.Equal(t, got.Name, rule.Name) }) } } -func TestRulesSaveRule(t *testing.T) { +func TestRulesWriteRule(t *testing.T) { tt := []struct { name string expectedStatus int expectedBody bool - expectedRule *server.Rule + expectedRule *api.Rule requestType string requestPath string requestBody io.Reader }{ { - name: "SaveRule POST OK", + name: "WriteRule POST OK", requestType: http.MethodPost, requestPath: "/api/rules", requestBody: bytes.NewBuffer( []byte(`{"Name":"Default POSTed Rule","Flow":"bidirect"}`)), expectedStatus: http.StatusOK, expectedBody: true, - expectedRule: &server.Rule{ - ID: "id-was-set", + expectedRule: &api.Rule{ + Id: "id-was-set", Name: "Default POSTed Rule", - Flow: server.TrafficFlowBidirect, + Flow: server.TrafficFlowBidirectString, }, }, { - name: "SaveRule PUT OK", - requestType: http.MethodPut, + name: "WriteRule POST Invalid Name", + requestType: http.MethodPost, requestPath: "/api/rules", requestBody: bytes.NewBuffer( - []byte(`{"ID":"id-existed","Name":"Default POSTed Rule","Flow":"bidirect"}`)), + []byte(`{"Name":"","Flow":"bidirect"}`)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "WriteRule PUT OK", + requestType: http.MethodPut, + requestPath: "/api/rules/id-existed", + requestBody: bytes.NewBuffer( + []byte(`{"Name":"Default POSTed Rule","Flow":"bidirect"}`)), expectedStatus: http.StatusOK, - expectedRule: &server.Rule{ - ID: "id-existed", + expectedBody: true, + expectedRule: &api.Rule{ + Id: "id-existed", Name: "Default POSTed Rule", - Flow: server.TrafficFlowBidirect, + Flow: server.TrafficFlowBidirectString, + }, + }, + { + name: "WriteRule PUT Invalid Name", + requestType: http.MethodPut, + requestPath: "/api/rules/id-existed", + requestBody: bytes.NewBuffer( + []byte(`{"Name":"","Flow":"bidirect"}`)), + expectedStatus: http.StatusUnprocessableEntity, + }, + { + name: "Write Rule PATCH Name OK", + requestType: http.MethodPatch, + requestPath: "/api/rules/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"replace","path":"name","value":["Default POSTed Rule"]}]`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRule: &api.Rule{ + Id: "id-existed", + Name: "Default POSTed Rule", + Flow: server.TrafficFlowBidirectString, + }, + }, + { + name: "Write Rule PATCH Invalid Name OP", + requestType: http.MethodPatch, + requestPath: "/api/rules/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"insert","path":"name","value":[""]}]`)), + expectedStatus: http.StatusBadRequest, + expectedBody: false, + }, + { + name: "Write Rule PATCH Invalid Name", + requestType: http.MethodPatch, + requestPath: "/api/rules/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"replace","path":"name","value":[]}]`)), + expectedStatus: http.StatusUnprocessableEntity, + expectedBody: false, + }, + { + name: "Write Rule PATCH Sources OK", + requestType: http.MethodPatch, + requestPath: "/api/rules/id-existed", + requestBody: bytes.NewBuffer( + []byte(`[{"op":"replace","path":"sources","value":["G","F"]}]`)), + expectedStatus: http.StatusOK, + expectedBody: true, + expectedRule: &api.Rule{ + Id: "id-existed", + Flow: server.TrafficFlowBidirectString, + Sources: []api.GroupMinimum{ + {Id: "G"}, + {Id: "F"}}, }, }, } @@ -175,7 +273,9 @@ func TestRulesSaveRule(t *testing.T) { req := httptest.NewRequest(tc.requestType, tc.requestPath, tc.requestBody) router := mux.NewRouter() - router.HandleFunc("/api/rules", p.CreateOrUpdateRuleHandler).Methods("PUT", "POST") + router.HandleFunc("/api/rules", p.CreateRuleHandler).Methods("POST") + router.HandleFunc("/api/rules/{id}", p.UpdateRuleHandler).Methods("PUT") + router.HandleFunc("/api/rules/{id}", p.PatchRuleHandler).Methods("PATCH") router.ServeHTTP(recorder, req) res := recorder.Result() @@ -196,16 +296,13 @@ func TestRulesSaveRule(t *testing.T) { return } - got := &RuleRequest{} + got := &api.Rule{} if err = json.Unmarshal(content, &got); err != nil { t.Fatalf("Sent content is not in correct json format; %v", err) } - if tc.requestType != http.MethodPost { - assert.Equal(t, got.ID, tc.expectedRule.ID) - } - assert.Equal(t, got.Name, tc.expectedRule.Name) - assert.Equal(t, got.Flow, "bidirect") + assert.Equal(t, got, tc.expectedRule) + }) } } diff --git a/management/server/http/handler/setupkeys.go b/management/server/http/handler/setupkeys.go index 1cb12e86816..b64c9b03eae 100644 --- a/management/server/http/handler/setupkeys.go +++ b/management/server/http/handler/setupkeys.go @@ -2,56 +2,34 @@ package handler import ( "encoding/json" - "fmt" - "github.com/netbirdio/netbird/management/server/jwtclaims" - "net/http" - "time" - "github.com/gorilla/mux" "github.com/netbirdio/netbird/management/server" - "github.com/netbirdio/netbird/util" + "github.com/netbirdio/netbird/management/server/http/api" + "github.com/netbirdio/netbird/management/server/jwtclaims" log "github.com/sirupsen/logrus" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "net/http" + "time" ) // SetupKeys is a handler that returns a list of setup keys of the account type SetupKeys struct { accountManager server.AccountManager + jwtExtractor jwtclaims.ClaimsExtractor authAudience string } -// SetupKeyResponse is a response sent to the client -type SetupKeyResponse struct { - Id string - Key string - Name string - Expires time.Time - Type server.SetupKeyType - Valid bool - Revoked bool - UsedTimes int - LastUsed time.Time - State string -} - -// SetupKeyRequest is a request sent by client. This object contains fields that can be modified -type SetupKeyRequest struct { - Name string - Type server.SetupKeyType - ExpiresIn *util.Duration - Revoked bool -} - func NewSetupKeysHandler(accountManager server.AccountManager, authAudience string) *SetupKeys { return &SetupKeys{ accountManager: accountManager, authAudience: authAudience, + jwtExtractor: *jwtclaims.NewClaimsExtractor(nil), } } func (h *SetupKeys) updateKey(accountId string, keyId string, w http.ResponseWriter, r *http.Request) { - req := &SetupKeyRequest{} + req := &api.PutApiSetupKeysIdJSONRequestBody{} err := json.NewDecoder(r.Body).Decode(&req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) @@ -96,19 +74,28 @@ func (h *SetupKeys) getKey(accountId string, keyId string, w http.ResponseWriter } func (h *SetupKeys) createKey(accountId string, w http.ResponseWriter, r *http.Request) { - req := &SetupKeyRequest{} + req := &api.PostApiSetupKeysJSONRequestBody{} err := json.NewDecoder(r.Body).Decode(&req) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } - if !(req.Type == server.SetupKeyReusable || req.Type == server.SetupKeyOneOff) { + if req.Name == "" { + http.Error(w, "Setup key name shouldn't be empty", http.StatusUnprocessableEntity) + return + } + + if !(server.SetupKeyType(req.Type) == server.SetupKeyReusable || + server.SetupKeyType(req.Type) == server.SetupKeyOneOff) { + http.Error(w, "unknown setup key type "+string(req.Type), http.StatusBadRequest) return } - setupKey, err := h.accountManager.AddSetupKey(accountId, req.Name, req.Type, req.ExpiresIn) + expiresIn := time.Duration(req.ExpiresIn) * time.Second + + setupKey, err := h.accountManager.AddSetupKey(accountId, req.Name, server.SetupKeyType(req.Type), expiresIn) if err != nil { errStatus, ok := status.FromError(err) if ok && errStatus.Code() == codes.NotFound { @@ -122,20 +109,8 @@ func (h *SetupKeys) createKey(accountId string, w http.ResponseWriter, r *http.R writeSuccess(w, setupKey) } -func (h *SetupKeys) getSetupKeyAccount(r *http.Request) (*server.Account, error) { - extractor := jwtclaims.NewClaimsExtractor(nil) - jwtClaims := extractor.ExtractClaimsFromRequestContext(r, h.authAudience) - - account, err := h.accountManager.GetAccountWithAuthorizationClaims(jwtClaims) - if err != nil { - return nil, fmt.Errorf("failed getting account of a user %s: %v", jwtClaims.UserId, err) - } - - return account, nil -} - func (h *SetupKeys) HandleKey(w http.ResponseWriter, r *http.Request) { - account, err := h.getSetupKeyAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) @@ -163,7 +138,7 @@ func (h *SetupKeys) HandleKey(w http.ResponseWriter, r *http.Request) { func (h *SetupKeys) GetKeys(w http.ResponseWriter, r *http.Request) { - account, err := h.getSetupKeyAccount(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) @@ -178,7 +153,7 @@ func (h *SetupKeys) GetKeys(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) w.Header().Set("Content-Type", "application/json") - respBody := []*SetupKeyResponse{} + respBody := []*api.SetupKey{} for _, key := range account.SetupKeys { respBody = append(respBody, toResponseBody(key)) } @@ -204,7 +179,7 @@ func writeSuccess(w http.ResponseWriter, key *server.SetupKey) { } } -func toResponseBody(key *server.SetupKey) *SetupKeyResponse { +func toResponseBody(key *server.SetupKey) *api.SetupKey { var state string if key.IsExpired() { state = "expired" @@ -215,12 +190,12 @@ func toResponseBody(key *server.SetupKey) *SetupKeyResponse { } else { state = "valid" } - return &SetupKeyResponse{ + return &api.SetupKey{ Id: key.Id, Key: key.Key, Name: key.Name, Expires: key.ExpiresAt, - Type: key.Type, + Type: string(key.Type), Valid: key.IsValid(), Revoked: key.Revoked, UsedTimes: key.UsedTimes, diff --git a/management/server/http/handler/users.go b/management/server/http/handler/users.go index 572f59319e7..113a0dfba05 100644 --- a/management/server/http/handler/users.go +++ b/management/server/http/handler/users.go @@ -1,7 +1,7 @@ package handler import ( - "fmt" + "github.com/netbirdio/netbird/management/server/http/api" "net/http" log "github.com/sirupsen/logrus" @@ -16,13 +16,6 @@ type UserHandler struct { jwtExtractor jwtclaims.ClaimsExtractor } -type UserResponse struct { - ID string `json:"id"` - Email string `json:"email"` - Name string `json:"name"` - Role string `json:"role"` -} - func NewUserHandler(accountManager server.AccountManager, authAudience string) *UserHandler { return &UserHandler{ accountManager: accountManager, @@ -31,37 +24,26 @@ func NewUserHandler(accountManager server.AccountManager, authAudience string) * } } -func (u *UserHandler) getAccountId(r *http.Request) (*server.Account, error) { - jwtClaims := u.jwtExtractor.ExtractClaimsFromRequestContext(r, u.authAudience) - - account, err := u.accountManager.GetAccountWithAuthorizationClaims(jwtClaims) - if err != nil { - return nil, fmt.Errorf("failed getting account of a user %s: %v", jwtClaims.UserId, err) - } - - return account, nil -} - // GetUsers returns a list of users of the account this user belongs to. // It also gathers additional user data (like email and name) from the IDP manager. -func (u *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { +func (h *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "", http.StatusBadRequest) } - account, err := u.getAccountId(r) + account, err := getJWTAccount(h.accountManager, h.jwtExtractor, h.authAudience, r) if err != nil { log.Error(err) } - data, err := u.accountManager.GetUsersFromAccount(account.Id) + data, err := h.accountManager.GetUsersFromAccount(account.Id) if err != nil { log.Error(err) http.Redirect(w, r, "/", http.StatusInternalServerError) return } - users := []*UserResponse{} + users := []*api.User{} for _, r := range data { users = append(users, toUserResponse(r)) } @@ -69,9 +51,9 @@ func (u *UserHandler) GetUsers(w http.ResponseWriter, r *http.Request) { writeJSONObject(w, users) } -func toUserResponse(user *server.UserInfo) *UserResponse { - return &UserResponse{ - ID: user.ID, +func toUserResponse(user *server.UserInfo) *api.User { + return &api.User{ + Id: user.ID, Name: user.Name, Email: user.Email, Role: user.Role, diff --git a/management/server/http/handler/util.go b/management/server/http/handler/util.go index 08b1c7d3ec1..5ae2a0d6fc8 100644 --- a/management/server/http/handler/util.go +++ b/management/server/http/handler/util.go @@ -3,6 +3,9 @@ package handler import ( "encoding/json" "errors" + "fmt" + "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/jwtclaims" "net/http" "time" ) @@ -47,3 +50,17 @@ func (d *Duration) UnmarshalJSON(b []byte) error { return errors.New("invalid duration") } } + +func getJWTAccount(accountManager server.AccountManager, + jwtExtractor jwtclaims.ClaimsExtractor, + authAudience string, r *http.Request) (*server.Account, error) { + + jwtClaims := jwtExtractor.ExtractClaimsFromRequestContext(r, authAudience) + + account, err := accountManager.GetAccountWithAuthorizationClaims(jwtClaims) + if err != nil { + return nil, fmt.Errorf("failed getting account of a user %s: %v", jwtClaims.UserId, err) + } + + return account, nil +} diff --git a/management/server/http/server.go b/management/server/http/server.go index 61ebc4dfe37..639e1d0124a 100644 --- a/management/server/http/server.go +++ b/management/server/http/server.go @@ -103,13 +103,12 @@ func (s *Server) Start() error { rulesHandler := handler.NewRules(s.accountManager, s.config.AuthAudience) peersHandler := handler.NewPeers(s.accountManager, s.config.AuthAudience) keysHandler := handler.NewSetupKeysHandler(s.accountManager, s.config.AuthAudience) + userHandler := handler.NewUserHandler(s.accountManager, s.config.AuthAudience) + r.HandleFunc("/api/peers", peersHandler.GetPeers).Methods("GET", "OPTIONS") r.HandleFunc("/api/peers/{id}", peersHandler.HandlePeer). Methods("GET", "PUT", "DELETE", "OPTIONS") - - userHandler := handler.NewUserHandler(s.accountManager, s.config.AuthAudience) r.HandleFunc("/api/users", userHandler.GetUsers).Methods("GET", "OPTIONS") - r.HandleFunc("/api/setup-keys", keysHandler.GetKeys).Methods("GET", "POST", "OPTIONS") r.HandleFunc("/api/setup-keys/{id}", keysHandler.HandleKey).Methods("GET", "PUT", "OPTIONS") @@ -118,14 +117,16 @@ func (s *Server) Start() error { Methods("GET", "PUT", "DELETE", "OPTIONS") r.HandleFunc("/api/rules", rulesHandler.GetAllRulesHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/rules", rulesHandler.CreateOrUpdateRuleHandler). - Methods("POST", "PUT", "OPTIONS") + r.HandleFunc("/api/rules", rulesHandler.CreateRuleHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/api/rules/{id}", rulesHandler.UpdateRuleHandler).Methods("PUT", "OPTIONS") + r.HandleFunc("/api/rules/{id}", rulesHandler.PatchRuleHandler).Methods("PATCH", "OPTIONS") r.HandleFunc("/api/rules/{id}", rulesHandler.GetRuleHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/rules/{id}", rulesHandler.DeleteRuleHandler).Methods("DELETE", "OPTIONS") r.HandleFunc("/api/groups", groupsHandler.GetAllGroupsHandler).Methods("GET", "OPTIONS") - r.HandleFunc("/api/groups", groupsHandler.CreateOrUpdateGroupHandler). - Methods("POST", "PUT", "OPTIONS") + r.HandleFunc("/api/groups", groupsHandler.CreateGroupHandler).Methods("POST", "OPTIONS") + r.HandleFunc("/api/groups/{id}", groupsHandler.UpdateGroupHandler).Methods("PUT", "OPTIONS") + r.HandleFunc("/api/groups/{id}", groupsHandler.PatchGroupHandler).Methods("PATCH", "OPTIONS") r.HandleFunc("/api/groups/{id}", groupsHandler.GetGroupHandler).Methods("GET", "OPTIONS") r.HandleFunc("/api/groups/{id}", groupsHandler.DeleteGroupHandler).Methods("DELETE", "OPTIONS") http.Handle("/", r) diff --git a/management/server/jwtclaims/extractor.go b/management/server/jwtclaims/extractor.go index 1a9432459ec..a794dd3b431 100644 --- a/management/server/jwtclaims/extractor.go +++ b/management/server/jwtclaims/extractor.go @@ -36,6 +36,9 @@ func NewClaimsExtractor(e ExtractClaims) *ClaimsExtractor { // ExtractClaimsFromRequestContext extracts claims from the request context previously filled by the JWT token (after auth) func ExtractClaimsFromRequestContext(r *http.Request, authAudience string) AuthorizationClaims { + if r.Context().Value(TokenUserProperty) == nil { + return AuthorizationClaims{} + } token := r.Context().Value(TokenUserProperty).(*jwt.Token) return ExtractClaimsWithToken(token, authAudience) } diff --git a/management/server/mock_server/account_mock.go b/management/server/mock_server/account_mock.go index 341f7a3f2da..c49ee5bbe24 100644 --- a/management/server/mock_server/account_mock.go +++ b/management/server/mock_server/account_mock.go @@ -3,15 +3,15 @@ package mock_server import ( "github.com/netbirdio/netbird/management/server" "github.com/netbirdio/netbird/management/server/jwtclaims" - "github.com/netbirdio/netbird/util" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "time" ) type MockAccountManager struct { GetOrCreateAccountByUserFunc func(userId, domain string) (*server.Account, error) GetAccountByUserFunc func(userId string) (*server.Account, error) - AddSetupKeyFunc func(accountId string, keyName string, keyType server.SetupKeyType, expiresIn *util.Duration) (*server.SetupKey, error) + AddSetupKeyFunc func(accountId string, keyName string, keyType server.SetupKeyType, expiresIn time.Duration) (*server.SetupKey, error) RevokeSetupKeyFunc func(accountId string, keyId string) (*server.SetupKey, error) RenameSetupKeyFunc func(accountId string, keyId string, newName string) (*server.SetupKey, error) GetAccountByIdFunc func(accountId string) (*server.Account, error) @@ -28,6 +28,7 @@ type MockAccountManager struct { AddPeerFunc func(setupKey string, userId string, peer *server.Peer) (*server.Peer, error) GetGroupFunc func(accountID, groupID string) (*server.Group, error) SaveGroupFunc func(accountID string, group *server.Group) error + UpdateGroupFunc func(accountID string, groupID string, operations []server.GroupUpdateOperation) (*server.Group, error) DeleteGroupFunc func(accountID, groupID string) error ListGroupsFunc func(accountID string) ([]*server.Group, error) GroupAddPeerFunc func(accountID, groupID, peerKey string) error @@ -35,12 +36,14 @@ type MockAccountManager struct { GroupListPeersFunc func(accountID, groupID string) ([]*server.Peer, error) GetRuleFunc func(accountID, ruleID string) (*server.Rule, error) SaveRuleFunc func(accountID string, rule *server.Rule) error + UpdateRuleFunc func(accountID string, ruleID string, operations []server.RuleUpdateOperation) (*server.Rule, error) DeleteRuleFunc func(accountID, ruleID string) error ListRulesFunc func(accountID string) ([]*server.Rule, error) GetUsersFromAccountFunc func(accountID string) ([]*server.UserInfo, error) UpdatePeerMetaFunc func(peerKey string, meta server.PeerSystemMeta) error } +// GetUsersFromAccount mock implementation of GetUsersFromAccount from server.AccountManager interface func (am *MockAccountManager) GetUsersFromAccount(accountID string) ([]*server.UserInfo, error) { if am.GetUsersFromAccountFunc != nil { return am.GetUsersFromAccountFunc(accountID) @@ -48,6 +51,7 @@ func (am *MockAccountManager) GetUsersFromAccount(accountID string) ([]*server.U return nil, status.Errorf(codes.Unimplemented, "method GetUsersFromAccount not implemented") } +// GetOrCreateAccountByUser mock implementation of GetOrCreateAccountByUser from server.AccountManager interface func (am *MockAccountManager) GetOrCreateAccountByUser( userId, domain string, ) (*server.Account, error) { @@ -60,6 +64,7 @@ func (am *MockAccountManager) GetOrCreateAccountByUser( ) } +// GetAccountByUser mock implementation of GetAccountByUser from server.AccountManager interface func (am *MockAccountManager) GetAccountByUser(userId string) (*server.Account, error) { if am.GetAccountByUserFunc != nil { return am.GetAccountByUserFunc(userId) @@ -67,11 +72,12 @@ func (am *MockAccountManager) GetAccountByUser(userId string) (*server.Account, return nil, status.Errorf(codes.Unimplemented, "method GetAccountByUser not implemented") } +// AddSetupKey mock implementation of AddSetupKey from server.AccountManager interface func (am *MockAccountManager) AddSetupKey( accountId string, keyName string, keyType server.SetupKeyType, - expiresIn *util.Duration, + expiresIn time.Duration, ) (*server.SetupKey, error) { if am.AddSetupKeyFunc != nil { return am.AddSetupKeyFunc(accountId, keyName, keyType, expiresIn) @@ -79,6 +85,7 @@ func (am *MockAccountManager) AddSetupKey( return nil, status.Errorf(codes.Unimplemented, "method AddSetupKey not implemented") } +// RevokeSetupKey mock implementation of RevokeSetupKey from server.AccountManager interface func (am *MockAccountManager) RevokeSetupKey( accountId string, keyId string, @@ -89,6 +96,7 @@ func (am *MockAccountManager) RevokeSetupKey( return nil, status.Errorf(codes.Unimplemented, "method RevokeSetupKey not implemented") } +// RenameSetupKey mock implementation of RenameSetupKey from server.AccountManager interface func (am *MockAccountManager) RenameSetupKey( accountId string, keyId string, @@ -100,6 +108,7 @@ func (am *MockAccountManager) RenameSetupKey( return nil, status.Errorf(codes.Unimplemented, "method RenameSetupKey not implemented") } +// GetAccountById mock implementation of GetAccountById from server.AccountManager interface func (am *MockAccountManager) GetAccountById(accountId string) (*server.Account, error) { if am.GetAccountByIdFunc != nil { return am.GetAccountByIdFunc(accountId) @@ -107,6 +116,7 @@ func (am *MockAccountManager) GetAccountById(accountId string) (*server.Account, return nil, status.Errorf(codes.Unimplemented, "method GetAccountById not implemented") } +// GetAccountByUserOrAccountId mock implementation of GetAccountByUserOrAccountId from server.AccountManager interface func (am *MockAccountManager) GetAccountByUserOrAccountId( userId, accountId, domain string, ) (*server.Account, error) { @@ -119,6 +129,7 @@ func (am *MockAccountManager) GetAccountByUserOrAccountId( ) } +// GetAccountWithAuthorizationClaims mock implementation of GetAccountWithAuthorizationClaims from server.AccountManager interface func (am *MockAccountManager) GetAccountWithAuthorizationClaims( claims jwtclaims.AuthorizationClaims, ) (*server.Account, error) { @@ -131,6 +142,7 @@ func (am *MockAccountManager) GetAccountWithAuthorizationClaims( ) } +// AccountExists mock implementation of AccountExists from server.AccountManager interface func (am *MockAccountManager) AccountExists(accountId string) (*bool, error) { if am.AccountExistsFunc != nil { return am.AccountExistsFunc(accountId) @@ -138,6 +150,7 @@ func (am *MockAccountManager) AccountExists(accountId string) (*bool, error) { return nil, status.Errorf(codes.Unimplemented, "method AccountExists not implemented") } +// GetPeer mock implementation of GetPeer from server.AccountManager interface func (am *MockAccountManager) GetPeer(peerKey string) (*server.Peer, error) { if am.GetPeerFunc != nil { return am.GetPeerFunc(peerKey) @@ -145,6 +158,7 @@ func (am *MockAccountManager) GetPeer(peerKey string) (*server.Peer, error) { return nil, status.Errorf(codes.Unimplemented, "method GetPeer not implemented") } +// MarkPeerConnected mock implementation of MarkPeerConnected from server.AccountManager interface func (am *MockAccountManager) MarkPeerConnected(peerKey string, connected bool) error { if am.MarkPeerConnectedFunc != nil { return am.MarkPeerConnectedFunc(peerKey, connected) @@ -152,6 +166,7 @@ func (am *MockAccountManager) MarkPeerConnected(peerKey string, connected bool) return status.Errorf(codes.Unimplemented, "method MarkPeerConnected not implemented") } +// RenamePeer mock implementation of RenamePeer from server.AccountManager interface func (am *MockAccountManager) RenamePeer( accountId string, peerKey string, @@ -163,6 +178,7 @@ func (am *MockAccountManager) RenamePeer( return nil, status.Errorf(codes.Unimplemented, "method RenamePeer not implemented") } +// DeletePeer mock implementation of DeletePeer from server.AccountManager interface func (am *MockAccountManager) DeletePeer(accountId string, peerKey string) (*server.Peer, error) { if am.DeletePeerFunc != nil { return am.DeletePeerFunc(accountId, peerKey) @@ -170,6 +186,7 @@ func (am *MockAccountManager) DeletePeer(accountId string, peerKey string) (*ser return nil, status.Errorf(codes.Unimplemented, "method DeletePeer not implemented") } +// GetPeerByIP mock implementation of GetPeerByIP from server.AccountManager interface func (am *MockAccountManager) GetPeerByIP(accountId string, peerIP string) (*server.Peer, error) { if am.GetPeerByIPFunc != nil { return am.GetPeerByIPFunc(accountId, peerIP) @@ -177,6 +194,7 @@ func (am *MockAccountManager) GetPeerByIP(accountId string, peerIP string) (*ser return nil, status.Errorf(codes.Unimplemented, "method GetPeerByIP not implemented") } +// GetNetworkMap mock implementation of GetNetworkMap from server.AccountManager interface func (am *MockAccountManager) GetNetworkMap(peerKey string) (*server.NetworkMap, error) { if am.GetNetworkMapFunc != nil { return am.GetNetworkMapFunc(peerKey) @@ -184,6 +202,7 @@ func (am *MockAccountManager) GetNetworkMap(peerKey string) (*server.NetworkMap, return nil, status.Errorf(codes.Unimplemented, "method GetNetworkMap not implemented") } +// AddPeer mock implementation of AddPeer from server.AccountManager interface func (am *MockAccountManager) AddPeer( setupKey string, userId string, @@ -195,6 +214,7 @@ func (am *MockAccountManager) AddPeer( return nil, status.Errorf(codes.Unimplemented, "method AddPeer not implemented") } +// GetGroup mock implementation of GetGroup from server.AccountManager interface func (am *MockAccountManager) GetGroup(accountID, groupID string) (*server.Group, error) { if am.GetGroupFunc != nil { return am.GetGroupFunc(accountID, groupID) @@ -202,6 +222,7 @@ func (am *MockAccountManager) GetGroup(accountID, groupID string) (*server.Group return nil, status.Errorf(codes.Unimplemented, "method GetGroup not implemented") } +// SaveGroup mock implementation of SaveGroup from server.AccountManager interface func (am *MockAccountManager) SaveGroup(accountID string, group *server.Group) error { if am.SaveGroupFunc != nil { return am.SaveGroupFunc(accountID, group) @@ -209,6 +230,15 @@ func (am *MockAccountManager) SaveGroup(accountID string, group *server.Group) e return status.Errorf(codes.Unimplemented, "method SaveGroup not implemented") } +// UpdateGroup mock implementation of UpdateGroup from server.AccountManager interface +func (am *MockAccountManager) UpdateGroup(accountID string, groupID string, operations []server.GroupUpdateOperation) (*server.Group, error) { + if am.UpdateGroupFunc != nil { + return am.UpdateGroupFunc(accountID, groupID, operations) + } + return nil, status.Errorf(codes.Unimplemented, "method UpdateGroup not implemented") +} + +// DeleteGroup mock implementation of DeleteGroup from server.AccountManager interface func (am *MockAccountManager) DeleteGroup(accountID, groupID string) error { if am.DeleteGroupFunc != nil { return am.DeleteGroupFunc(accountID, groupID) @@ -216,6 +246,7 @@ func (am *MockAccountManager) DeleteGroup(accountID, groupID string) error { return status.Errorf(codes.Unimplemented, "method DeleteGroup not implemented") } +// ListGroups mock implementation of ListGroups from server.AccountManager interface func (am *MockAccountManager) ListGroups(accountID string) ([]*server.Group, error) { if am.ListGroupsFunc != nil { return am.ListGroupsFunc(accountID) @@ -223,6 +254,7 @@ func (am *MockAccountManager) ListGroups(accountID string) ([]*server.Group, err return nil, status.Errorf(codes.Unimplemented, "method ListGroups not implemented") } +// GroupAddPeer mock implementation of GroupAddPeer from server.AccountManager interface func (am *MockAccountManager) GroupAddPeer(accountID, groupID, peerKey string) error { if am.GroupAddPeerFunc != nil { return am.GroupAddPeerFunc(accountID, groupID, peerKey) @@ -230,6 +262,7 @@ func (am *MockAccountManager) GroupAddPeer(accountID, groupID, peerKey string) e return status.Errorf(codes.Unimplemented, "method GroupAddPeer not implemented") } +// GroupDeletePeer mock implementation of GroupDeletePeer from server.AccountManager interface func (am *MockAccountManager) GroupDeletePeer(accountID, groupID, peerKey string) error { if am.GroupDeletePeerFunc != nil { return am.GroupDeletePeerFunc(accountID, groupID, peerKey) @@ -237,6 +270,7 @@ func (am *MockAccountManager) GroupDeletePeer(accountID, groupID, peerKey string return status.Errorf(codes.Unimplemented, "method GroupDeletePeer not implemented") } +// GroupListPeers mock implementation of GroupListPeers from server.AccountManager interface func (am *MockAccountManager) GroupListPeers(accountID, groupID string) ([]*server.Peer, error) { if am.GroupListPeersFunc != nil { return am.GroupListPeersFunc(accountID, groupID) @@ -244,6 +278,7 @@ func (am *MockAccountManager) GroupListPeers(accountID, groupID string) ([]*serv return nil, status.Errorf(codes.Unimplemented, "method GroupListPeers not implemented") } +// GetRule mock implementation of GetRule from server.AccountManager interface func (am *MockAccountManager) GetRule(accountID, ruleID string) (*server.Rule, error) { if am.GetRuleFunc != nil { return am.GetRuleFunc(accountID, ruleID) @@ -251,6 +286,7 @@ func (am *MockAccountManager) GetRule(accountID, ruleID string) (*server.Rule, e return nil, status.Errorf(codes.Unimplemented, "method GetRule not implemented") } +// SaveRule mock implementation of SaveRule from server.AccountManager interface func (am *MockAccountManager) SaveRule(accountID string, rule *server.Rule) error { if am.SaveRuleFunc != nil { return am.SaveRuleFunc(accountID, rule) @@ -258,6 +294,15 @@ func (am *MockAccountManager) SaveRule(accountID string, rule *server.Rule) erro return status.Errorf(codes.Unimplemented, "method SaveRule not implemented") } +// UpdateRule mock implementation of UpdateRule from server.AccountManager interface +func (am *MockAccountManager) UpdateRule(accountID string, ruleID string, operations []server.RuleUpdateOperation) (*server.Rule, error) { + if am.UpdateRuleFunc != nil { + return am.UpdateRuleFunc(accountID, ruleID, operations) + } + return nil, status.Errorf(codes.Unimplemented, "method UpdateRule not implemented") +} + +// DeleteRule mock implementation of DeleteRule from server.AccountManager interface func (am *MockAccountManager) DeleteRule(accountID, ruleID string) error { if am.DeleteRuleFunc != nil { return am.DeleteRuleFunc(accountID, ruleID) @@ -265,6 +310,7 @@ func (am *MockAccountManager) DeleteRule(accountID, ruleID string) error { return status.Errorf(codes.Unimplemented, "method DeleteRule not implemented") } +// ListRules mock implementation of ListRules from server.AccountManager interface func (am *MockAccountManager) ListRules(accountID string) ([]*server.Rule, error) { if am.ListRulesFunc != nil { return am.ListRulesFunc(accountID) @@ -272,6 +318,7 @@ func (am *MockAccountManager) ListRules(accountID string) ([]*server.Rule, error return nil, status.Errorf(codes.Unimplemented, "method ListRules not implemented") } +// UpdatePeerMeta mock implementation of UpdatePeerMeta from server.AccountManager interface func (am *MockAccountManager) UpdatePeerMeta(peerKey string, meta server.PeerSystemMeta) error { if am.UpdatePeerMetaFunc != nil { return am.UpdatePeerMetaFunc(peerKey, meta) @@ -279,6 +326,7 @@ func (am *MockAccountManager) UpdatePeerMeta(peerKey string, meta server.PeerSys return status.Errorf(codes.Unimplemented, "method UpdatePeerMetaFunc not implemented") } +// IsUserAdmin mock implementation of IsUserAdmin from server.AccountManager interface func (am *MockAccountManager) IsUserAdmin(claims jwtclaims.AuthorizationClaims) (bool, error) { if am.IsUserAdminFunc != nil { return am.IsUserAdminFunc(claims) diff --git a/management/server/peer.go b/management/server/peer.go index e76f2b067ec..735baced7ed 100644 --- a/management/server/peer.go +++ b/management/server/peer.go @@ -360,6 +360,9 @@ func (am *DefaultAccountManager) getPeersByACL(account *Account, peerKey string) groups := map[string]*Group{} for _, r := range srcRules { + if r.Disabled { + continue + } if r.Flow == TrafficFlowBidirect { for _, gid := range r.Destination { if group, ok := account.Groups[gid]; ok { @@ -370,6 +373,9 @@ func (am *DefaultAccountManager) getPeersByACL(account *Account, peerKey string) } for _, r := range dstRules { + if r.Disabled { + continue + } if r.Flow == TrafficFlowBidirect { for _, gid := range r.Source { if group, ok := account.Groups[gid]; ok { diff --git a/management/server/peer_test.go b/management/server/peer_test.go index c48ac99750d..b99b27e8417 100644 --- a/management/server/peer_test.go +++ b/management/server/peer_test.go @@ -220,4 +220,36 @@ func TestAccountManager_GetNetworkMapWithRule(t *testing.T) { networkMap2.Peers[0].Key, ) } + + rule.Disabled = true + err = manager.SaveRule(account.Id, &rule) + if err != nil { + t.Errorf("expecting rule to be added, got failure %v", err) + return + } + + networkMap1, err = manager.GetNetworkMap(peerKey1.PublicKey().String()) + if err != nil { + t.Fatal(err) + return + } + + if len(networkMap1.Peers) != 0 { + t.Errorf( + "expecting Account NetworkMap to have 0 peers, got %v: %v", + len(networkMap1.Peers), + networkMap1.Peers, + ) + return + } + + networkMap2, err = manager.GetNetworkMap(peerKey2.PublicKey().String()) + if err != nil { + t.Fatal(err) + return + } + + if len(networkMap2.Peers) != 0 { + t.Errorf("expecting Account NetworkMap to have 0 peers, got %v", len(networkMap2.Peers)) + } } diff --git a/management/server/rule.go b/management/server/rule.go index ebb8caab26e..82689f34730 100644 --- a/management/server/rule.go +++ b/management/server/rule.go @@ -3,6 +3,7 @@ package server import ( "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "strings" ) // TrafficFlowType defines allowed direction of the traffic in the rule @@ -11,6 +12,12 @@ type TrafficFlowType int const ( // TrafficFlowBidirect allows traffic to both direction TrafficFlowBidirect TrafficFlowType = iota + // TrafficFlowBidirectString allows traffic to both direction + TrafficFlowBidirectString = "bidirect" + // DefaultRuleName is a name for the Default rule that is created for every account + DefaultRuleName = "Default" + // DefaultRuleDescription is a description for the Default rule that is created for every account + DefaultRuleDescription = "This is a default rule that allows connections between all the resources" ) // Rule of ACL for groups @@ -21,6 +28,12 @@ type Rule struct { // Name of the rule visible in the UI Name string + // Description of the rule visible in the UI + Description string + + // Disabled status of rule in the system + Disabled bool + // Source list of groups IDs of peers Source []string @@ -31,10 +44,44 @@ type Rule struct { Flow TrafficFlowType } +const ( + // UpdateRuleName indicates a rule name update operation + UpdateRuleName RuleUpdateOperationType = iota + // UpdateRuleDescription indicates a rule description update operation + UpdateRuleDescription + // UpdateRuleStatus indicates a rule status update operation + UpdateRuleStatus + // UpdateRuleFlow indicates a rule flow update operation + UpdateRuleFlow + // InsertGroupsToSource indicates an insert groups to source rule operation + InsertGroupsToSource + // RemoveGroupsFromSource indicates an remove groups from source rule operation + RemoveGroupsFromSource + // UpdateSourceGroups indicates a replacement of source group list of a rule operation + UpdateSourceGroups + // InsertGroupsToDestination indicates an insert groups to destination rule operation + InsertGroupsToDestination + // RemoveGroupsFromDestination indicates an remove groups from destination rule operation + RemoveGroupsFromDestination + // UpdateDestinationGroups indicates a replacement of destination group list of a rule operation + UpdateDestinationGroups +) + +// RuleUpdateOperationType operation type +type RuleUpdateOperationType int + +// RuleUpdateOperation operation object with type and values to be applied +type RuleUpdateOperation struct { + Type RuleUpdateOperationType + Values []string +} + func (r *Rule) Copy() *Rule { return &Rule{ ID: r.ID, Name: r.Name, + Description: r.Description, + Disabled: r.Disabled, Source: r.Source[:], Destination: r.Destination[:], Flow: r.Flow, @@ -79,6 +126,81 @@ func (am *DefaultAccountManager) SaveRule(accountID string, rule *Rule) error { return am.updateAccountPeers(account) } +// UpdateRule updates a rule using a list of operations +func (am *DefaultAccountManager) UpdateRule(accountID string, ruleID string, + operations []RuleUpdateOperation) (*Rule, error) { + am.mux.Lock() + defer am.mux.Unlock() + + account, err := am.Store.GetAccount(accountID) + if err != nil { + return nil, status.Errorf(codes.NotFound, "account not found") + } + + ruleToUpdate, ok := account.Rules[ruleID] + if !ok { + return nil, status.Errorf(codes.NotFound, "rule %s no longer exists", ruleID) + } + + rule := ruleToUpdate.Copy() + + for _, operation := range operations { + switch operation.Type { + case UpdateRuleName: + rule.Name = operation.Values[0] + case UpdateRuleDescription: + rule.Description = operation.Values[0] + case UpdateRuleFlow: + if operation.Values[0] != TrafficFlowBidirectString { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse flow") + } + rule.Flow = TrafficFlowBidirect + case UpdateRuleStatus: + if strings.ToLower(operation.Values[0]) == "true" { + rule.Disabled = true + } else if strings.ToLower(operation.Values[0]) == "false" { + rule.Disabled = false + } else { + return nil, status.Errorf(codes.InvalidArgument, "failed to parse status") + } + case UpdateSourceGroups: + rule.Source = operation.Values + case InsertGroupsToSource: + sourceList := rule.Source + resultList := removeFromList(sourceList, operation.Values) + rule.Source = append(resultList, operation.Values...) + case RemoveGroupsFromSource: + sourceList := rule.Source + resultList := removeFromList(sourceList, operation.Values) + rule.Source = resultList + case UpdateDestinationGroups: + rule.Destination = operation.Values + case InsertGroupsToDestination: + sourceList := rule.Destination + resultList := removeFromList(sourceList, operation.Values) + rule.Destination = append(resultList, operation.Values...) + case RemoveGroupsFromDestination: + sourceList := rule.Destination + resultList := removeFromList(sourceList, operation.Values) + rule.Destination = resultList + } + } + + account.Rules[ruleID] = rule + + account.Network.IncSerial() + if err = am.Store.SaveAccount(account); err != nil { + return nil, err + } + + err = am.updateAccountPeers(account) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to update account peers") + } + + return rule, nil +} + // DeleteRule of ACL from the store func (am *DefaultAccountManager) DeleteRule(accountID, ruleID string) error { am.mux.Lock()