diff --git a/.gitignore b/.gitignore index 3f69f2ac2a..377188452a 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ .idea/ /skywire.json +/skywire-config.json +/manager-config.json /apps/ /skywire/ /local/ diff --git a/README.md b/README.md index bf59ee97b8..d64786e8d7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ $ make install # compiles and installs all binaries **Generate default json config** ```bash -skywire-cli config +skywire-cli gen-config ``` ### Run `skywire-node` @@ -71,8 +71,8 @@ skywire-cli config `skywire-node` hosts apps, proxies app's requests to remote nodes and exposes communication API that apps can use to implement communication protocols. App binaries are spawned by the node, communication between node and app is performed via unix pipes provided on app startup. ```bash -# Run skywire-node. It takes one argument; the path of a configuration file (`skywire.json` if unspecified). -$ skywire-node skywire.json +# Run skywire-node. It takes one argument; the path of a configuration file (`skywire-config.json` if unspecified). +$ skywire-node skywire-config.json ``` ### Run `skywire-node` in docker container @@ -88,42 +88,42 @@ The `skywire-cli` tool is used to control the `skywire-node`. Refer to the help ```bash $ skywire-cli -h -# Command Line Interface for skywire -# -# Usage: -# skywire-cli [command] -# -# Available Commands: -# add-rule adds a new routing rule -# add-transport adds a new transport -# apps lists apps running on the node -# config Generate default config file -# find-routes lists available routes between two nodes via route finder service -# find-transport finds and lists transport(s) of given transport ID or edge public key from transport discovery -# help Help about any command -# list-rules lists the local node's routing rules -# list-transports lists the available transports with optional filter flags -# messaging manage operations with messaging services -# rm-rule removes a routing rule via route ID key -# rm-transport removes transport with given id -# rule returns a routing rule via route ID key -# set-app-autostart sets the autostart flag for an app of given name -# start-app starts an app of given name -# stop-app stops an app of given name -# transport returns summary of given transport by id -# transport-types lists transport types used by the local node -# -# Flags: -# -h, --help help for skywire-cli -# --rpc string RPC server address (default "localhost:3435") -# -# Use "skywire-cli [command] --help" for more information about a command. - +# Command Line Interface for skywire +# +# Usage: +# skywire-cli [command] +# +# Available Commands: +# add-rule adds a new routing rule +# add-transport adds a new transport +# apps lists apps running on the node +# find-routes lists available routes between two nodes via route finder service +# find-transport finds and lists transport(s) of given transport ID or edge public key from transport discovery +# gen-config Generate default config file +# help Help about any command +# list-rules lists the local node's routing rules +# list-transports lists the available transports with optional filter flags +# messaging manage operations with messaging services +# pk get public key of node +# rm-rule removes a routing rule via route ID key +# rm-transport removes transport with given id +# rule returns a routing rule via route ID key +# set-app-autostart sets the autostart flag for an app of given name +# start-app starts an app of given name +# stop-app stops an app of given name +# transport returns summary of given transport by id +# transport-types lists transport types used by the local node +# +# Flags: +# -h, --help help for skywire-cli +# --rpc string RPC server address (default "localhost:3435") +# +# Use "skywire-cli [command] --help" for more information about a command. ``` ### Apps -After `skywire-node` is up and running with default environment, default apps are run with the configuration specified in `skywire.json`. Refer to the following for usage of the default apps: +After `skywire-node` is up and running with default environment, default apps are run with the configuration specified in `skywire-config.json`. Refer to the following for usage of the default apps: - [Chat](/cmd/apps/chat) - [Hello World](/cmd/apps/helloworld) @@ -193,7 +193,7 @@ This will: #### Structure of `./node` -```bash +``` ./node ├── apps # node `apps` compiled with DOCKER_OPTS │   ├── chat.v1.0 # @@ -203,14 +203,14 @@ This will: │   ├── therealssh-client.v1.0 # │   └── therealssh.v1.0 # ├── local # **Created inside docker** -│   ├── chat # according to "local_path" in skywire.json +│   ├── chat # according to "local_path" in skywire-config.json │   ├── therealproxy # │   └── therealssh # ├── PK # contains public key of node ├── skywire # db & logs. **Created inside docker** │   ├── routing.db # │   └── transport_logs # -├── skywire.json # config of node +├── skywire-config.json # config of node └── skywire-node # `skywire-node binary` compiled with DOCKER_OPTS ``` @@ -275,7 +275,7 @@ Default value: "GO111MODULE=on GOOS=linux" #### 1. Get Public Key of docker-node ```bash -$ cat ./node/skywire.json|grep static_public_key |cut -d ':' -f2 |tr -d '"'','' ' +$ cat ./node/skywire-config.json|grep static_public_key |cut -d ':' -f2 |tr -d '"'','' ' # 029be6fa68c13e9222553035cc1636d98fb36a888aa569d9ce8aa58caa2c651b45 ``` @@ -308,8 +308,8 @@ $ GO111MODULE=on GOOS=linux go build -o /tmp/SKYNODE/apps/helloworld.v1.0 ./cmd/ $ GO111MODULE=on GOOS=linux go build -o /tmp/SKYNODE/apps/therealproxy.v1.0 ./cmd/apps/therealproxy $ GO111MODULE=on GOOS=linux go build -o /tmp/SKYNODE/apps/therealssh.v1.0 ./cmd/apps/therealssh $ GO111MODULE=on GOOS=linux go build -o /tmp/SKYNODE/apps/therealssh-client.v1.0 ./cmd/apps/therealssh-client -# 4. Create skywire.json for node -$ skywire-cli config /tmp/SKYNODE/skywire.json +# 4. Create skywire-config.json for node +$ skywire-cli gen-config -o /tmp/SKYNODE/skywire-config.json # 2019/03/15 16:43:49 Done! $ tree /tmp/SKYNODE # /tmp/SKYNODE @@ -319,7 +319,7 @@ $ tree /tmp/SKYNODE # │   ├── therealproxy.v1.0 # │   ├── therealssh-client.v1.0 # │   └── therealssh.v1.0 -# ├── skywire.json +# ├── skywire-config.json # └── skywire-node # So far so good. We prepared docker volume. Now we can: $ docker run -it -v /tmp/SKYNODE:/sky --network=SKYNET --name=SKYNODE skywire-runner bash -c "cd /sky && ./skywire-node" @@ -353,9 +353,9 @@ Instead of skywire-runner you can use: ```bash export SW_NODE_A=127.0.0.1 -export SW_NODE_A_PK=$(cat ./skywire.json|grep static_public_key |cut -d ':' -f2 |tr -d '"'','' ') +export SW_NODE_A_PK=$(cat ./skywire-config.json|grep static_public_key |cut -d ':' -f2 |tr -d '"'','' ') export SW_NODE_B=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' SKY01) -export SW_NODE_B_PK=$(cat ./node/skywire.json|grep static_public_key |cut -d ':' -f2 |tr -d '"'','' ') +export SW_NODE_B_PK=$(cat ./node/skywire-config.json|grep static_public_key |cut -d ':' -f2 |tr -d '"'','' ') ``` #### 6. "Hello-Mike-Hello-Joe" test diff --git a/cmd/manager-node/README.md b/cmd/manager-node/README.md index d114171568..10af50618d 100644 --- a/cmd/manager-node/README.md +++ b/cmd/manager-node/README.md @@ -2,9 +2,19 @@ Manager node exposes node management operations via web API. +**Generate config file:** + +```bash + +``` + **Run with mock data:** ```bash +# Generate config file. +$ manager-node gen-config + +# Run. $ manager-node --mock ``` diff --git a/cmd/manager-node/commands/gen-config.go b/cmd/manager-node/commands/gen-config.go new file mode 100644 index 0000000000..39ab4a1df6 --- /dev/null +++ b/cmd/manager-node/commands/gen-config.go @@ -0,0 +1,54 @@ +package commands + +import ( + "fmt" + "path/filepath" + + "github.com/skycoin/skywire/internal/pathutil" + + "github.com/spf13/cobra" + + "github.com/skycoin/skywire/pkg/manager" +) + +var ( + output string + replace bool + configLocType = pathutil.WorkingDirLoc +) + +func init() { + rootCmd.AddCommand(genConfigCmd) + genConfigCmd.Flags().StringVarP(&output, "output", "o", "", "path of output config file. Uses default of 'type' flag if unspecified.") + genConfigCmd.Flags().BoolVarP(&replace, "replace", "r", false, "whether to allow rewrite of a file that already exists.") + genConfigCmd.Flags().VarP(&configLocType, "type", "m", fmt.Sprintf("config generation mode. Valid values: %v", pathutil.AllConfigLocationTypes())) +} + +var genConfigCmd = &cobra.Command{ + Use: "gen-config", + Short: "generates a configuration file", + PreRun: func(_ *cobra.Command, _ []string) { + if output == "" { + output = pathutil.ManagerDefaults().Get(configLocType) + log.Infof("no 'output,o' flag is empty, using default path: %s", output) + } + var err error + if output, err = filepath.Abs(output); err != nil { + log.WithError(err).Fatalln("invalid output provided") + } + }, + Run: func(_ *cobra.Command, _ []string) { + var conf manager.Config + switch configLocType { + case pathutil.WorkingDirLoc: + conf = manager.GenerateWorkDirConfig() + case pathutil.HomeLoc: + conf = manager.GenerateHomeConfig() + case pathutil.LocalLoc: + conf = manager.GenerateLocalConfig() + default: + log.Fatalln("invalid config type:", configLocType) + } + pathutil.WriteJSONConfig(conf, output, replace) + }, +} diff --git a/cmd/manager-node/commands/root.go b/cmd/manager-node/commands/root.go index f2a570c271..58fd48530b 100644 --- a/cmd/manager-node/commands/root.go +++ b/cmd/manager-node/commands/root.go @@ -9,29 +9,22 @@ import ( "github.com/skycoin/skycoin/src/util/logging" "github.com/spf13/cobra" - "github.com/skycoin/skywire/pkg/cipher" + "github.com/skycoin/skywire/internal/pathutil" "github.com/skycoin/skywire/pkg/manager" ) +const configEnv = "SW_MANAGER_CONFIG" + var ( - pk cipher.PubKey - sk cipher.SecKey - rpcAddr string - httpAddr string + log = logging.MustGetLogger("manager-node") + mock bool mockNodes int mockMaxTps int mockMaxRoutes int - - log = logging.MustGetLogger("manager-node") ) func init() { - rootCmd.PersistentFlags().Var(&pk, "pk", "manager node's public key") - rootCmd.PersistentFlags().Var(&sk, "sk", "manager node's secret key") - rootCmd.PersistentFlags().StringVar(&httpAddr, "http-addr", ":8080", "address to serve HTTP RESTful API and Web interface") - - rootCmd.Flags().StringVar(&rpcAddr, "rpc-addr", ":7080", "address to serve RPC client interface") rootCmd.Flags().BoolVar(&mock, "mock", false, "whether to run manager node with mock data") rootCmd.Flags().IntVar(&mockNodes, "mock-nodes", 5, "number of app nodes to have in mock mode") rootCmd.Flags().IntVar(&mockMaxTps, "mock-max-tps", 10, "max number of transports per mock app node") @@ -39,25 +32,29 @@ func init() { } var rootCmd = &cobra.Command{ - Use: "manager-node", + Use: "manager-node [config-path]", Short: "Manages Skywire App Nodes", - PreRun: func(_ *cobra.Command, _ []string) { - if pk.Null() && sk.Null() { - pk, sk = cipher.GenerateKeyPair() - log.Println("No keys are set. Randomly generating...") + Run: func(_ *cobra.Command, args []string) { + configPath := pathutil.FindConfigPath(args, 0, configEnv, pathutil.ManagerDefaults()) + + var config manager.Config + config.FillDefaults() + if err := config.Parse(configPath); err != nil { + log.WithError(err).Fatalln("failed to parse config file") } - cPK, err := sk.PubKey() + fmt.Println(config) + + var ( + httpAddr = config.Interfaces.HTTPAddr + rpcAddr = config.Interfaces.RPCAddr + ) + + m, err := manager.NewNode(config) if err != nil { - log.Fatalln("Key pair check failed:", err) - } - if cPK != pk { - log.Fatalln("SK and PK provided do not match.") + log.Fatalln("Failed to start manager:", err) } - log.Println("PK:", pk) - log.Println("SK:", sk) - }, - Run: func(_ *cobra.Command, _ []string) { - m := manager.NewNode(pk, sk) + + log.Infof("serving RPC on '%s'", rpcAddr) go func() { l, err := net.Listen("tcp", rpcAddr) if err != nil { @@ -67,8 +64,9 @@ var rootCmd = &cobra.Command{ log.Fatalln("Failed to serve RPC:", err) } }() + if mock { - err := m.AddMockData(&manager.MockConfig{ + err := m.AddMockData(manager.MockConfig{ Nodes: mockNodes, MaxTpsPerNode: mockMaxTps, MaxRoutesPerNode: mockMaxRoutes, @@ -77,9 +75,12 @@ var rootCmd = &cobra.Command{ log.Fatalln("Failed to add mock data:", err) } } + + log.Infof("serving HTTP on '%s'", httpAddr) if err := http.ListenAndServe(httpAddr, m); err != nil { log.Fatalln("Manager exited with error:", err) } + log.Println("Good bye!") }, } diff --git a/cmd/skywire-cli/commands/config.go b/cmd/skywire-cli/commands/config.go deleted file mode 100644 index 4db6784209..0000000000 --- a/cmd/skywire-cli/commands/config.go +++ /dev/null @@ -1,84 +0,0 @@ -package commands - -import ( - "encoding/base64" - "encoding/json" - "log" - "os" - - "github.com/spf13/cobra" - - "github.com/skycoin/skywire/pkg/cipher" - "github.com/skycoin/skywire/pkg/node" -) - -func init() { - rootCmd.AddCommand(configCmd) -} - -var configCmd = &cobra.Command{ - Use: "config [skywire.json]", - Short: "Generate default config file", - Run: func(_ *cobra.Command, args []string) { - configFile := "skywire.json" - if len(args) > 0 { - configFile = args[0] - } - - conf := defaultConfig() - confFile, err := os.OpenFile(configFile, os.O_RDWR|os.O_CREATE, 0600) - if err != nil { - log.Fatal("Failed to create config files: ", err) - } - - enc := json.NewEncoder(confFile) - enc.SetIndent("", " ") - if err := enc.Encode(conf); err != nil { - log.Fatal("Failed to encode json config: ", err) - } - - log.Println("Done!") - }, -} - -func defaultConfig() *node.Config { - conf := &node.Config{} - conf.Version = "1.0" - - pk, sk := cipher.GenerateKeyPair() - conf.Node.StaticPubKey = pk - conf.Node.StaticSecKey = sk - - conf.Messaging.Discovery = "https://messaging.discovery.skywire.skycoin.net" - conf.Messaging.ServerCount = 1 - - passcode := base64.StdEncoding.EncodeToString(cipher.RandByte(8)) - conf.Apps = []node.AppConfig{ - {App: "chat", Version: "1.0", Port: 1, AutoStart: true, Args: []string{}}, - {App: "therealssh", Version: "1.0", Port: 2, AutoStart: true, Args: []string{}}, - {App: "therealproxy", Version: "1.0", Port: 3, AutoStart: true, Args: []string{"-passcode", passcode}}, - } - conf.TrustedNodes = []cipher.PubKey{} - - conf.Transport.Discovery = "https://transport.discovery.skywire.skycoin.net" - conf.Transport.LogStore.Type = "file" - conf.Transport.LogStore.Location = "./skywire/transport_logs" - - conf.Routing.RouteFinder = "https://routefinder.skywire.skycoin.net/" - sPK := cipher.PubKey{} - sPK.UnmarshalText([]byte("0324579f003e6b4048bae2def4365e634d8e0e3054a20fc7af49daf2a179658557")) // nolint: errcheck - conf.Routing.SetupNodes = []cipher.PubKey{sPK} - conf.Routing.Table.Type = "boltdb" - conf.Routing.Table.Location = "./skywire/routing.db" - - conf.ManagerNodes = []node.ManagerConfig{} - - conf.AppsPath = "./apps" - conf.LocalPath = "./local" - - conf.LogLevel = "info" - - conf.Interfaces.RPCAddress = "localhost:3435" - - return conf -} diff --git a/cmd/skywire-cli/commands/gen-config.go b/cmd/skywire-cli/commands/gen-config.go new file mode 100644 index 0000000000..c7e3868c0f --- /dev/null +++ b/cmd/skywire-cli/commands/gen-config.go @@ -0,0 +1,115 @@ +package commands + +import ( + "encoding/base64" + "fmt" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/skycoin/skywire/internal/pathutil" + "github.com/skycoin/skywire/pkg/cipher" + "github.com/skycoin/skywire/pkg/node" +) + +var ( + output string + replace bool + configLocType = pathutil.WorkingDirLoc +) + +func init() { + rootCmd.AddCommand(genConfigCmd) + genConfigCmd.Flags().StringVarP(&output, "output", "o", "", "path of output config file. Uses default of 'type' flag if unspecified.") + genConfigCmd.Flags().BoolVarP(&replace, "replace", "r", false, "whether to allow rewrite of a file that already exists.") + genConfigCmd.Flags().VarP(&configLocType, "type", "m", fmt.Sprintf("config generation mode. Valid values: %v", pathutil.AllConfigLocationTypes())) +} + +var genConfigCmd = &cobra.Command{ + Use: "gen-config", + Short: "Generate default config file", + PreRun: func(_ *cobra.Command, _ []string) { + if output == "" { + output = pathutil.NodeDefaults().Get(configLocType) + log.Infof("No 'output' set; using default path: %s", output) + } + var err error + if output, err = filepath.Abs(output); err != nil { + log.WithError(err).Fatalln("invalid output provided") + } + }, + Run: func(_ *cobra.Command, _ []string) { + var conf *node.Config + switch configLocType { + case pathutil.WorkingDirLoc: + conf = defaultConfig() + case pathutil.HomeLoc: + conf = homeConfig() + case pathutil.LocalLoc: + conf = localConfig() + default: + log.Fatalln("invalid config type:", configLocType) + } + pathutil.WriteJSONConfig(conf, output, replace) + }, +} + +func homeConfig() *node.Config { + c := defaultConfig() + c.AppsPath = filepath.Join(pathutil.HomeDir(), ".skycoin/skywire/apps") + c.Transport.LogStore.Location = filepath.Join(pathutil.HomeDir(), ".skycoin/skywire/transport_logs") + c.Routing.Table.Location = filepath.Join(pathutil.HomeDir(), ".skycoin/skywire/routing.db") + + return c +} + +func localConfig() *node.Config { + c := defaultConfig() + c.AppsPath = "/usr/local/skycoin/skywire/apps" + c.Transport.LogStore.Location = "/usr/local/skycoin/skywire/transport_logs" + c.Routing.Table.Location = "/usr/local/skycoin/skywire/routing.db" + + return c +} + +func defaultConfig() *node.Config { + conf := &node.Config{} + conf.Version = "1.0" + + pk, sk := cipher.GenerateKeyPair() + conf.Node.StaticPubKey = pk + conf.Node.StaticSecKey = sk + + conf.Messaging.Discovery = "https://messaging.discovery.skywire.skycoin.net" + conf.Messaging.ServerCount = 1 + + passcode := base64.StdEncoding.EncodeToString(cipher.RandByte(8)) + conf.Apps = []node.AppConfig{ + {App: "chat", Version: "1.0", Port: 1, AutoStart: true, Args: []string{}}, + {App: "therealssh", Version: "1.0", Port: 2, AutoStart: true, Args: []string{}}, + {App: "therealproxy", Version: "1.0", Port: 3, AutoStart: true, Args: []string{"-passcode", passcode}}, + } + conf.TrustedNodes = []cipher.PubKey{} + + conf.Transport.Discovery = "https://transport.discovery.skywire.skycoin.net" + conf.Transport.LogStore.Type = "file" + conf.Transport.LogStore.Location = "./skywire/transport_logs" + + conf.Routing.RouteFinder = "https://routefinder.skywire.skycoin.net/" + sPK := cipher.PubKey{} + sPK.UnmarshalText([]byte("0324579f003e6b4048bae2def4365e634d8e0e3054a20fc7af49daf2a179658557")) // nolint: errcheck + conf.Routing.SetupNodes = []cipher.PubKey{sPK} + conf.Routing.Table.Type = "boltdb" + conf.Routing.Table.Location = "./skywire/routing.db" + + conf.ManagerNodes = []node.ManagerConfig{} + + conf.AppsPath = "./apps" + conf.LocalPath = "./local" + + conf.LogLevel = "info" + + conf.Interfaces.RPCAddress = "localhost:3435" + + return conf +} diff --git a/cmd/skywire-cli/commands/pk.go b/cmd/skywire-cli/commands/pk.go index 7bd96fc1f9..91c2e5f346 100644 --- a/cmd/skywire-cli/commands/pk.go +++ b/cmd/skywire-cli/commands/pk.go @@ -2,7 +2,6 @@ package commands import ( "fmt" - "log" "github.com/spf13/cobra" ) diff --git a/cmd/skywire-cli/commands/root.go b/cmd/skywire-cli/commands/root.go index c1ab163fd2..e184bf05f3 100644 --- a/cmd/skywire-cli/commands/root.go +++ b/cmd/skywire-cli/commands/root.go @@ -2,17 +2,19 @@ package commands import ( "fmt" - "log" "net/rpc" "strconv" "github.com/google/uuid" + "github.com/skycoin/skycoin/src/util/logging" "github.com/spf13/cobra" "github.com/skycoin/skywire/pkg/cipher" "github.com/skycoin/skywire/pkg/node" ) +var log = logging.MustGetLogger("skywire-cli") + var rpcAddr string var rootCmd = &cobra.Command{ diff --git a/cmd/skywire-node/commands/root.go b/cmd/skywire-node/commands/root.go index aac57eb323..6c00443052 100644 --- a/cmd/skywire-node/commands/root.go +++ b/cmd/skywire-node/commands/root.go @@ -2,35 +2,38 @@ package commands import ( "encoding/json" - "log" "os" "os/signal" "strings" "syscall" "time" + "github.com/skycoin/skycoin/src/util/logging" + "github.com/spf13/cobra" + "github.com/skycoin/skywire/internal/pathutil" "github.com/skycoin/skywire/pkg/node" ) +const configEnv = "SW_CONFIG" + +var log = logging.MustGetLogger("skywire-node") + var rootCmd = &cobra.Command{ - Use: "skywire-node [skywire.json]", + Use: "skywire-node [config-path]", Short: "App Node for skywire", Run: func(_ *cobra.Command, args []string) { - configFile := "skywire.json" - if len(args) > 0 { - configFile = args[0] - } + configPath := pathutil.FindConfigPath(args, 0, configEnv, pathutil.NodeDefaults()) - file, err := os.Open(configFile) + file, err := os.Open(configPath) if err != nil { log.Fatalf("Failed to open config: %s", err) } conf := &node.Config{} if err := json.NewDecoder(file).Decode(&conf); err != nil { - log.Fatalf("Failed to decode %s: %s", configFile, err) + log.Fatalf("Failed to decode %s: %s", configPath, err) } node, err := node.NewNode(conf) diff --git a/go.mod b/go.mod index ff0a7f33ce..30d355c698 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,8 @@ require ( github.com/flynn/noise v0.0.0-20180327030543-2492fe189ae6 github.com/go-chi/chi v4.0.2+incompatible github.com/google/uuid v1.1.1 + github.com/gorilla/securecookie v1.1.1 + github.com/gorilla/sessions v1.1.3 // indirect github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d github.com/inconshreveable/mousetrap v1.0.0 // indirect github.com/kr/pretty v0.1.0 // indirect diff --git a/go.sum b/go.sum index 259f98dfc6..2d4bbd63e1 100644 --- a/go.sum +++ b/go.sum @@ -10,6 +10,12 @@ github.com/go-chi/chi v4.0.2+incompatible h1:maB6vn6FqCxrpz4FqWdh4+lwpyZIQS7YEAU github.com/go-chi/chi v4.0.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/context v1.1.1 h1:AWwleXJkX/nhcU9bZSnZoi3h/qGYqQAGhq6zZe/aQW8= +github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= +github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ= +github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= +github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9RU= +github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d h1:kJCB4vdITiW1eC1vq2e6IsrXKrZit1bv/TDYFGMp4BQ= github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= diff --git a/internal/pathutil/configpath.go b/internal/pathutil/configpath.go new file mode 100644 index 0000000000..a29c0c6207 --- /dev/null +++ b/internal/pathutil/configpath.go @@ -0,0 +1,148 @@ +package pathutil + +import ( + "encoding/json" + "io/ioutil" + "os" + "path/filepath" + + "github.com/skycoin/skycoin/src/util/logging" +) + +var log = logging.MustGetLogger("pathutil") + +// ConfigLocationType describes a config path's location type. +type ConfigLocationType string + +const ( + // WorkingDirLoc represents the default working directory location for a configuration file. + WorkingDirLoc = ConfigLocationType("WD") + + // HomeLoc represents the default home folder location for a configuration file. + HomeLoc = ConfigLocationType("HOME") + + // LocalLoc represents the default /usr/local location for a configuration file. + LocalLoc = ConfigLocationType("LOCAL") +) + +// String implements fmt.Stringer for ConfigLocationType. +func (t ConfigLocationType) String() string { + return string(t) +} + +// Set implements pflag.Value for ConfigLocationType. +func (t *ConfigLocationType) Set(s string) error { + *t = ConfigLocationType(s) + return nil +} + +// Type implements pflag.Value for ConfigLocationType. +func (t ConfigLocationType) Type() string { + return "pathutil.ConfigLocationType" +} + +// AllConfigLocationTypes returns all valid config location types. +func AllConfigLocationTypes() []ConfigLocationType { + return []ConfigLocationType{ + WorkingDirLoc, + HomeLoc, + LocalLoc, + } +} + +// ConfigPaths contains a map of configuration paths, based on ConfigLocationTypes. +type ConfigPaths map[ConfigLocationType]string + +// String implements fmt.Stringer for ConfigPaths. +func (dp ConfigPaths) String() string { + raw, err := json.MarshalIndent(dp, "", "\t") + if err != nil { + log.Fatalf("cannot marshal default paths: %s", err.Error()) + } + return string(raw) +} + +// Get obtains a path stored under given configuration location type. +func (dp ConfigPaths) Get(cpType ConfigLocationType) string { + if path, ok := dp[cpType]; ok { + return path + } + log.Fatalf("invalid config type '%s' provided. Valid types: %v", cpType, AllConfigLocationTypes()) + return "" +} + +// NodeDefaults returns the default config paths for skywire-node. +func NodeDefaults() ConfigPaths { + paths := make(ConfigPaths) + if wd, err := os.Getwd(); err == nil { + paths[WorkingDirLoc] = filepath.Join(wd, "skywire-config.json") + } + paths[HomeLoc] = filepath.Join(HomeDir(), ".skycoin/skywire/skywire-config.json") + paths[LocalLoc] = "/usr/local/skycoin/skywire/skywire-config.json" + return paths +} + +// ManagerDefaults returns the default config paths for manager-node. +func ManagerDefaults() ConfigPaths { + paths := make(ConfigPaths) + if wd, err := os.Getwd(); err == nil { + paths[WorkingDirLoc] = filepath.Join(wd, "manager-config.json") + } + paths[HomeLoc] = filepath.Join(HomeDir(), ".skycoin/skywire-manager/manager-config.json") + paths[LocalLoc] = "/usr/local/skycoin/skywire-manager/manager-config.json" + return paths +} + +// FindConfigPath is used by a service to find a config file path in the following order: +// - From CLI argument. +// - From ENV. +// - From a list of default paths. +func FindConfigPath(args []string, argsIndex int, env string, defaults ConfigPaths) string { + if len(args) > argsIndex { + path := args[argsIndex] + log.Infof("using args[%d] as config path: %s", argsIndex, path) + return path + } + if env != "" { + if path, ok := os.LookupEnv(env); ok { + log.Infof("using $%s as config path: %s", env, path) + return path + } + } + log.Debugf("config path is not explicitly specified, trying default paths...") + for i, cpType := range []ConfigLocationType{WorkingDirLoc, HomeLoc, LocalLoc} { + path, ok := defaults[cpType] + if !ok { + continue + } + if _, err := os.Stat(path); err != nil { + log.Debugf("- [%d/%d] '%s' cannot be accessed: %s", i+1, len(defaults), path, err.Error()) + } else { + log.Debugf("- [%d/%d] '%s' is found", i+1, len(defaults), path) + log.Printf("using fallback config path: %s", path) + return path + } + } + log.Fatalf("config not found in any of the following paths: %s", defaults.String()) + return "" +} + +// WriteJSONConfig is used by config file generators. +// 'output' specifies the path to save generated config files. +// 'replace' is true if replacing files is allowed. +func WriteJSONConfig(conf interface{}, output string, replace bool) { + raw, err := json.MarshalIndent(conf, "", "\t") + if err != nil { + log.WithError(err).Fatal("unexpected error, report to dev") + } + if _, err := os.Stat(output); !replace && err == nil { + log.Fatalf("file %s already exists, stopping as 'replace,r' flag is not set", output) + } + if err := os.MkdirAll(filepath.Dir(output), 0750); err != nil { + log.WithError(err).Fatalln("failed to create output directory") + } + if err := ioutil.WriteFile(output, raw, 0744); err != nil { + log.WithError(err).Fatalln("failed to write file") + } + log.Infof("Wrote %d bytes to %s\n%s", len(raw), output, string(raw)) +} diff --git a/internal/pathutil/homedir.go b/internal/pathutil/homedir.go new file mode 100644 index 0000000000..442a989938 --- /dev/null +++ b/internal/pathutil/homedir.go @@ -0,0 +1,19 @@ +package pathutil + +import ( + "os" + "runtime" +) + +// HomeDir obtains the path to the user's home directory via ENVs. +// SRC: https://github.com/spf13/viper/blob/80ab6657f9ec7e5761f6603320d3d58dfe6970f6/util.go#L144-L153 +func HomeDir() string { + if runtime.GOOS == "windows" { + home := os.Getenv("HOMEDRIVE") + os.Getenv("HOMEPATH") + if home == "" { + home = os.Getenv("USERPROFILE") + } + return home + } + return os.Getenv("HOME") +} diff --git a/pkg/manager/config.go b/pkg/manager/config.go new file mode 100644 index 0000000000..6ef5970495 --- /dev/null +++ b/pkg/manager/config.go @@ -0,0 +1,134 @@ +package manager + +import ( + "encoding/hex" + "encoding/json" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/skycoin/skywire/internal/pathutil" + + "github.com/skycoin/skywire/pkg/cipher" +) + +// Key allows a byte slice to be marshaled or unmarshaled from a hex string. +type Key []byte + +// String implements fmt.Stringer +func (hk Key) String() string { + return hex.EncodeToString(hk) +} + +// MarshalText implements encoding.TextMarshaler +func (hk Key) MarshalText() ([]byte, error) { + return []byte(hk.String()), nil +} + +// UnmarshalText implements encoding.TextUnmarshaler +func (hk *Key) UnmarshalText(text []byte) error { + *hk = make([]byte, hex.DecodedLen(len(text))) + _, err := hex.Decode(*hk, text) + return err +} + +// Config configures the manager node. +type Config struct { + PK cipher.PubKey `json:"public_key"` + SK cipher.SecKey `json:"secret_key"` + DBPath string `json:"db_path"` // Path to store database file. + Cookies CookieConfig `json:"cookies"` // Configures cookies (for session management). + Interfaces InterfaceConfig `json:"interfaces"` // Configures exposed interfaces. +} + +func makeConfig() Config { + var c Config + pk, sk := cipher.GenerateKeyPair() + c.PK = pk + c.SK = sk + c.Cookies.HashKey = cipher.RandByte(64) + c.Cookies.BlockKey = cipher.RandByte(32) + c.FillDefaults() + return c +} + +// GenerateWorkDirConfig generates a config with default values and uses db from current working directory. +func GenerateWorkDirConfig() Config { + dir, err := os.Getwd() + if err != nil { + log.Fatalf("failed to generate WD config: %s", dir) + } + c := makeConfig() + c.DBPath = filepath.Join(dir, "users.db") + return c +} + +// GenerateHomeConfig generates a config with default values and uses db from user's home folder. +func GenerateHomeConfig() Config { + c := makeConfig() + c.DBPath = filepath.Join(pathutil.HomeDir(), ".skycoin/skywire-manager/users.db") + return c +} + +// GenerateLocalConfig generates a config with default values and uses db from shared folder. +func GenerateLocalConfig() Config { + c := makeConfig() + c.DBPath = "/usr/local/skycoin/skywire-manager/users.db" + return c +} + +// FillDefaults fills the config with default values. +func (c *Config) FillDefaults() { + c.Cookies.FillDefaults() + c.Interfaces.FillDefaults() +} + +// Parse parses the file in path, and decodes to the config. +func (c *Config) Parse(path string) error { + var err error + if path, err = filepath.Abs(path); err != nil { + return err + } + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { catch(f.Close()) }() + return json.NewDecoder(f).Decode(c) +} + +// CookieConfig configures cookies used for manager. +type CookieConfig struct { + HashKey Key `json:"hash_key"` // Signs the cookie: 32 or 64 bytes. + BlockKey Key `json:"block_key"` // Encrypts the cookie: 16 (AES-128), 24 (AES-192), 32 (AES-256) bytes. (optional) + + ExpiresDuration time.Duration `json:"expires_duration"` // Used for determining the 'expires' value for cookies. + + Path string `json:"path"` // optional + Domain string `json:"domain"` // optional + Secure bool `json:"secure"` + HTTPOnly bool `json:"http_only"` + SameSite http.SameSite `json:"same_site"` +} + +// FillDefaults fills config with default values. +func (c *CookieConfig) FillDefaults() { + c.ExpiresDuration = time.Hour * 12 + c.Path = "/" + c.Secure = true + c.HTTPOnly = true + c.SameSite = http.SameSiteDefaultMode +} + +// InterfaceConfig configures the interfaces exposed by manager. +type InterfaceConfig struct { + HTTPAddr string `json:"http_address"` + RPCAddr string `json:"rpc_addr"` +} + +// FillDefaults fills config with default values. +func (c *InterfaceConfig) FillDefaults() { + c.HTTPAddr = ":8080" + c.RPCAddr = ":7080" +} diff --git a/pkg/manager/node.go b/pkg/manager/node.go index 60b5058db4..2763b9d54b 100644 --- a/pkg/manager/node.go +++ b/pkg/manager/node.go @@ -5,7 +5,6 @@ import ( "encoding/hex" "errors" "fmt" - "log" "math/rand" "net" "net/http" @@ -14,40 +13,51 @@ import ( "sync" "time" + "github.com/skycoin/skycoin/src/util/logging" + "github.com/go-chi/chi" "github.com/go-chi/chi/middleware" "github.com/google/uuid" - "github.com/skycoin/skywire/pkg/cipher" - "github.com/skycoin/skywire/internal/httputil" "github.com/skycoin/skywire/internal/noise" + "github.com/skycoin/skywire/pkg/cipher" "github.com/skycoin/skywire/pkg/node" "github.com/skycoin/skywire/pkg/routing" ) +var ( + log = logging.MustGetLogger("manager") +) + // Node manages AppNodes. type Node struct { - pk cipher.PubKey - sk cipher.SecKey + c Config nodes map[cipher.PubKey]node.RPCClient // connected remote nodes. + users *UserManager mu *sync.RWMutex } // NewNode creates a new Node. -func NewNode(pk cipher.PubKey, sk cipher.SecKey) *Node { +func NewNode(config Config) (*Node, error) { + boltUserDB, err := NewBoltUserStore(config.DBPath) + if err != nil { + return nil, err + } + singleUserDB := NewSingleUserStore("admin", boltUserDB) + return &Node{ - pk: pk, - sk: sk, + c: config, nodes: make(map[cipher.PubKey]node.RPCClient), + users: NewUserManager(singleUserDB, config.Cookies), mu: new(sync.RWMutex), - } + }, nil } // ServeRPC serves RPC of a Node. func (m *Node) ServeRPC(lis net.Listener) error { for { - conn, err := noise.WrapListener(lis, m.pk, m.sk, false, noise.HandshakeXK).Accept() + conn, err := noise.WrapListener(lis, m.c.PK, m.c.SK, false, noise.HandshakeXK).Accept() if err != nil { return err } @@ -66,7 +76,7 @@ type MockConfig struct { } // AddMockData adds mock data to Manager Node. -func (m *Node) AddMockData(config *MockConfig) error { +func (m *Node) AddMockData(config MockConfig) error { r := rand.New(rand.NewSource(time.Now().UnixNano())) for i := 0; i < config.Nodes; i++ { pk, client := node.NewMockRPCClient(r, config.MaxTpsPerNode, config.MaxRoutesPerNode) @@ -78,41 +88,38 @@ func (m *Node) AddMockData(config *MockConfig) error { } // ServeHTTP implements http.Handler -func (m *Node) ServeHTTP(w http.ResponseWriter, r *http.Request) { - mux := chi.NewRouter() - - mux.Use(middleware.Timeout(time.Second * 30)) - mux.Use(middleware.Logger) - - mux.Mount("/api", m.apiRouter()) - - mux.ServeHTTP(w, r) -} - -func (m *Node) apiRouter() http.Handler { +func (m *Node) ServeHTTP(w http.ResponseWriter, req *http.Request) { r := chi.NewRouter() - - r.Get("/nodes", m.getNodes()) - r.Get("/nodes/{pk}", m.getNode()) - - r.Get("/nodes/{pk}/apps", m.getApps()) - r.Get("/nodes/{pk}/apps/{app}", m.getApp()) - r.Put("/nodes/{pk}/apps/{app}", m.putApp()) - - r.Get("/nodes/{pk}/transport-types", m.getTransportTypes()) - - r.Get("/nodes/{pk}/transports", m.getTransports()) - r.Post("/nodes/{pk}/transports", m.postTransport()) - r.Get("/nodes/{pk}/transports/{tid}", m.getTransport()) - r.Delete("/nodes/{pk}/transports/{tid}", m.deleteTransport()) - - r.Get("/nodes/{pk}/routes", m.getRoutes()) - r.Post("/nodes/{pk}/routes", m.postRoute()) - r.Get("/nodes/{pk}/routes/{rid}", m.getRoute()) - r.Put("/nodes/{pk}/routes/{rid}", m.putRoute()) - r.Delete("/nodes/{pk}/routes/{rid}", m.deleteRoute()) - - return r + r.Use(middleware.Timeout(time.Second * 30)) + r.Use(middleware.Logger) + r.Route("/api", func(r chi.Router) { + r.Group(func(r chi.Router) { + r.Post("/create-account", m.users.CreateAccount()) + r.Post("/login", m.users.Login()) + r.Post("/logout", m.users.Logout()) + }) + r.Group(func(r chi.Router) { + r.Use(m.users.Authorize) + r.Get("/user", m.users.UserInfo()) + r.Post("/change-password", m.users.ChangePassword()) + r.Get("/nodes", m.getNodes()) + r.Get("/nodes/{pk}", m.getNode()) + r.Get("/nodes/{pk}/apps", m.getApps()) + r.Get("/nodes/{pk}/apps/{app}", m.getApp()) + r.Put("/nodes/{pk}/apps/{app}", m.putApp()) + r.Get("/nodes/{pk}/transport-types", m.getTransportTypes()) + r.Get("/nodes/{pk}/transports", m.getTransports()) + r.Post("/nodes/{pk}/transports", m.postTransport()) + r.Get("/nodes/{pk}/transports/{tid}", m.getTransport()) + r.Delete("/nodes/{pk}/transports/{tid}", m.deleteTransport()) + r.Get("/nodes/{pk}/routes", m.getRoutes()) + r.Post("/nodes/{pk}/routes", m.postRoute()) + r.Get("/nodes/{pk}/routes/{rid}", m.getRoute()) + r.Put("/nodes/{pk}/routes/{rid}", m.putRoute()) + r.Delete("/nodes/{pk}/routes/{rid}", m.deleteRoute()) + }) + }) + r.ServeHTTP(w, req) } // provides summary of all nodes. @@ -135,7 +142,7 @@ func (m *Node) getNodes() http.HandlerFunc { // provides summary of single node. func (m *Node) getNode() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { summary, err := ctx.RPC.Summary() if err != nil { httputil.WriteJSON(w, r, http.StatusInternalServerError, err) @@ -147,7 +154,7 @@ func (m *Node) getNode() http.HandlerFunc { // returns app summaries of a given node of pk func (m *Node) getApps() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { apps, err := ctx.RPC.Apps() if err != nil { httputil.WriteJSON(w, r, http.StatusInternalServerError, err) @@ -159,13 +166,13 @@ func (m *Node) getApps() http.HandlerFunc { // returns an app summary of a given node's pk and app name func (m *Node) getApp() http.HandlerFunc { - return m.ctxApp(func(w http.ResponseWriter, r *http.Request, ctx appCtx) { + return m.withCtx(m.appCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { httputil.WriteJSON(w, r, http.StatusOK, ctx.App) }) } func (m *Node) putApp() http.HandlerFunc { - return m.ctxApp(func(w http.ResponseWriter, r *http.Request, ctx appCtx) { + return m.withCtx(m.appCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { var reqBody struct { Autostart *bool `json:"autostart,omitempty"` Status *int `json:"status,omitempty"` @@ -204,7 +211,7 @@ func (m *Node) putApp() http.HandlerFunc { } func (m *Node) getTransportTypes() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { types, err := ctx.RPC.TransportTypes() if err != nil { httputil.WriteJSON(w, r, http.StatusInternalServerError, err) @@ -215,7 +222,7 @@ func (m *Node) getTransportTypes() http.HandlerFunc { } func (m *Node) getTransports() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { var ( qTypes []string qPKs []cipher.PubKey @@ -241,7 +248,7 @@ func (m *Node) getTransports() http.HandlerFunc { } func (m *Node) postTransport() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { var reqBody struct { Remote cipher.PubKey `json:"remote_pk"` TpType string `json:"transport_type"` @@ -251,7 +258,7 @@ func (m *Node) postTransport() http.HandlerFunc { httputil.WriteJSON(w, r, http.StatusBadRequest, err) return } - summary, err := ctx.RPC.AddTransport(reqBody.Remote, reqBody.TpType, reqBody.Public, 30*time.Second) // TODO(evanlinjin): add timeout + summary, err := ctx.RPC.AddTransport(reqBody.Remote, reqBody.TpType, reqBody.Public, 30*time.Second) if err != nil { httputil.WriteJSON(w, r, http.StatusInternalServerError, err) return @@ -261,13 +268,13 @@ func (m *Node) postTransport() http.HandlerFunc { } func (m *Node) getTransport() http.HandlerFunc { - return m.ctxTransport(func(w http.ResponseWriter, r *http.Request, ctx transportCtx) { + return m.withCtx(m.tpCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { httputil.WriteJSON(w, r, http.StatusOK, ctx.Tp) }) } func (m *Node) deleteTransport() http.HandlerFunc { - return m.ctxTransport(func(w http.ResponseWriter, r *http.Request, ctx transportCtx) { + return m.withCtx(m.tpCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { if err := ctx.RPC.RemoveTransport(ctx.Tp.ID); err != nil { httputil.WriteJSON(w, r, http.StatusInternalServerError, err) return @@ -294,7 +301,7 @@ func makeRoutingRuleResp(key routing.RouteID, rule routing.Rule, summary bool) r } func (m *Node) getRoutes() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { qSummary, err := httputil.BoolFromQuery(r, "summary", false) if err != nil { httputil.WriteJSON(w, r, http.StatusBadRequest, err) @@ -314,7 +321,7 @@ func (m *Node) getRoutes() http.HandlerFunc { } func (m *Node) postRoute() http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { + return m.withCtx(m.nodeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { var summary routing.RuleSummary if err := httputil.ReadJSON(r, &summary); err != nil { httputil.WriteJSON(w, r, http.StatusBadRequest, err) @@ -335,7 +342,7 @@ func (m *Node) postRoute() http.HandlerFunc { } func (m *Node) getRoute() http.HandlerFunc { - return m.ctxRoute(func(w http.ResponseWriter, r *http.Request, ctx routeCtx) { + return m.withCtx(m.routeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { qSummary, err := httputil.BoolFromQuery(r, "summary", true) if err != nil { httputil.WriteJSON(w, r, http.StatusBadRequest, err) @@ -351,7 +358,7 @@ func (m *Node) getRoute() http.HandlerFunc { } func (m *Node) putRoute() http.HandlerFunc { - return m.ctxRoute(func(w http.ResponseWriter, r *http.Request, ctx routeCtx) { + return m.withCtx(m.routeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { var summary routing.RuleSummary if err := httputil.ReadJSON(r, &summary); err != nil { httputil.WriteJSON(w, r, http.StatusBadRequest, err) @@ -371,7 +378,7 @@ func (m *Node) putRoute() http.HandlerFunc { } func (m *Node) deleteRoute() http.HandlerFunc { - return m.ctxRoute(func(w http.ResponseWriter, r *http.Request, ctx routeCtx) { + return m.withCtx(m.routeCtx, func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) { if err := ctx.RPC.RemoveRoutingRule(ctx.RtKey); err != nil { httputil.WriteJSON(w, r, http.StatusNotFound, err) return @@ -391,6 +398,110 @@ func (m *Node) client(pk cipher.PubKey) (node.RPCClient, bool) { return client, ok } +type httpCtx struct { + // Node + PK cipher.PubKey + RPC node.RPCClient + + // App + App *node.AppState + + // Transport + Tp *node.TransportSummary + + // Route + RtKey routing.RouteID +} + +type ( + valuesFunc func(w http.ResponseWriter, r *http.Request) (*httpCtx, bool) + handlerFunc func(w http.ResponseWriter, r *http.Request, ctx *httpCtx) +) + +func (m *Node) withCtx(vFunc valuesFunc, hFunc handlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if rv, ok := vFunc(w, r); ok { + hFunc(w, r, rv) + } + } +} + +func (m *Node) nodeCtx(w http.ResponseWriter, r *http.Request) (*httpCtx, bool) { + pk, err := pkFromParam(r, "pk") + if err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, err) + return nil, false + } + client, ok := m.client(pk) + if !ok { + httputil.WriteJSON(w, r, http.StatusNotFound, fmt.Errorf("node of pk '%s' not found", pk)) + return nil, false + } + return &httpCtx{ + PK: pk, + RPC: client, + }, true +} + +func (m *Node) appCtx(w http.ResponseWriter, r *http.Request) (*httpCtx, bool) { + ctx, ok := m.nodeCtx(w, r) + if !ok { + return nil, false + } + appName := chi.URLParam(r, "app") + apps, err := ctx.RPC.Apps() + if err != nil { + httputil.WriteJSON(w, r, http.StatusInternalServerError, err) + return nil, false + } + for _, app := range apps { + if app.Name == appName { + ctx.App = app + return ctx, true + } + } + httputil.WriteJSON(w, r, http.StatusNotFound, + fmt.Errorf("can not find app of name %s from node %s", appName, ctx.PK)) + return nil, false +} + +func (m *Node) tpCtx(w http.ResponseWriter, r *http.Request) (*httpCtx, bool) { + ctx, ok := m.appCtx(w, r) + if !ok { + return nil, false + } + tid, err := uuidFromParam(r, "tid") + if err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, err) + return nil, false + } + tp, err := ctx.RPC.Transport(tid) + if err != nil { + if err.Error() == node.ErrNotFound.Error() { + httputil.WriteJSON(w, r, http.StatusNotFound, + fmt.Errorf("transport of ID %s is not found", tid)) + return nil, false + } + httputil.WriteJSON(w, r, http.StatusInternalServerError, err) + return nil, false + } + ctx.Tp = tp + return ctx, true +} + +func (m *Node) routeCtx(w http.ResponseWriter, r *http.Request) (*httpCtx, bool) { + ctx, ok := m.tpCtx(w, r) + if !ok { + return nil, false + } + rid, err := ridFromParam(r, "key") + if err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, err) + } + ctx.RtKey = rid + return ctx, true +} + func pkFromParam(r *http.Request, key string) (cipher.PubKey, error) { pk := cipher.PubKey{} err := pk.UnmarshalText([]byte(chi.URLParam(r, key))) @@ -433,96 +544,12 @@ func pkSliceFromQuery(r *http.Request, key string, defaultVal []cipher.PubKey) ( return pks, nil } -type nodeCtx struct { - PK cipher.PubKey - RPC node.RPCClient -} - -type nodeHandlerFunc func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) - -func (m *Node) ctxNode(next nodeHandlerFunc) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - pk, err := pkFromParam(r, "pk") - if err != nil { - httputil.WriteJSON(w, r, http.StatusBadRequest, err) - return - } - client, ok := m.client(pk) - if !ok { - httputil.WriteJSON(w, r, http.StatusNotFound, fmt.Errorf("node of pk '%s' not found", pk)) - return +func catch(err error, msgs ...string) { + if err != nil { + if len(msgs) > 0 { + log.Fatalln(append(msgs, err.Error())) + } else { + log.Fatalln(err) } - next(w, r, nodeCtx{PK: pk, RPC: client}) } } - -type appCtx struct { - nodeCtx - App *node.AppState -} - -type appHandlerFunc func(w http.ResponseWriter, r *http.Request, ctx appCtx) - -func (m *Node) ctxApp(next appHandlerFunc) http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { - appName := chi.URLParam(r, "app") - apps, err := ctx.RPC.Apps() - if err != nil { - httputil.WriteJSON(w, r, http.StatusInternalServerError, err) - return - } - for _, app := range apps { - if app.Name == appName { - next(w, r, appCtx{nodeCtx: ctx, App: app}) - return - } - } - httputil.WriteJSON(w, r, http.StatusNotFound, - fmt.Errorf("can not find app of name %s from node %s", appName, ctx.PK)) - }) -} - -type transportCtx struct { - nodeCtx - Tp *node.TransportSummary -} - -type transportHandlerFunc func(w http.ResponseWriter, r *http.Request, ctx transportCtx) - -func (m *Node) ctxTransport(next transportHandlerFunc) http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { - tid, err := uuidFromParam(r, "tid") - if err != nil { - httputil.WriteJSON(w, r, http.StatusBadRequest, err) - return - } - tp, err := ctx.RPC.Transport(tid) - if err != nil { - if err.Error() == node.ErrNotFound.Error() { - httputil.WriteJSON(w, r, http.StatusNotFound, - fmt.Errorf("transport of ID %s is not found", tid)) - return - } - httputil.WriteJSON(w, r, http.StatusInternalServerError, err) - return - } - next(w, r, transportCtx{nodeCtx: ctx, Tp: tp}) - }) -} - -type routeCtx struct { - nodeCtx - RtKey routing.RouteID -} - -type routeHandlerFunc func(w http.ResponseWriter, r *http.Request, ctx routeCtx) - -func (m *Node) ctxRoute(next routeHandlerFunc) http.HandlerFunc { - return m.ctxNode(func(w http.ResponseWriter, r *http.Request, ctx nodeCtx) { - rid, err := ridFromParam(r, "key") - if err != nil { - httputil.WriteJSON(w, r, http.StatusBadRequest, err) - } - next(w, r, routeCtx{nodeCtx: ctx, RtKey: rid}) - }) -} diff --git a/pkg/manager/node_test.go b/pkg/manager/node_test.go new file mode 100644 index 0000000000..ef5e02ac07 --- /dev/null +++ b/pkg/manager/node_test.go @@ -0,0 +1,357 @@ +package manager + +import ( + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewNode(t *testing.T) { + config := makeConfig() + + confDir, err := ioutil.TempDir(os.TempDir(), "SWM") + require.NoError(t, err) + config.DBPath = filepath.Join(confDir, "users.db") + + startNode := func(mock MockConfig) (string, *http.Client, func()) { + node, err := NewNode(config) + require.NoError(t, err) + require.NoError(t, node.AddMockData(mock)) + + srv := httptest.NewTLSServer(node) + node.c.Cookies.Domain = srv.Listener.Addr().String() + + client := srv.Client() + jar, err := cookiejar.New(&cookiejar.Options{}) + require.NoError(t, err) + client.Jar = jar + + return srv.Listener.Addr().String(), client, func() { + srv.Close() + require.NoError(t, os.Remove(config.DBPath)) + } + } + + type TestCase struct { + Method string + URI string + Body io.Reader + RespStatus int + RespBody func(t *testing.T, resp *http.Response) + } + + testCases := func(t *testing.T, addr string, client *http.Client, cases []TestCase) { + for i, tc := range cases { + testTag := fmt.Sprintf("[%d] %s", i, tc.URI) + + req, err := http.NewRequest(tc.Method, "https://"+addr+tc.URI, tc.Body) + require.NoError(t, err, testTag) + + resp, err := client.Do(req) + require.NoError(t, err, testTag) + + assert.Equal(t, tc.RespStatus, resp.StatusCode, testTag) + if tc.RespBody != nil { + tc.RespBody(t, resp) + } + } + } + + t.Run("no_access_without_login", func(t *testing.T) { + addr, client, stop := startNode(MockConfig{5, 10, 10}) + defer stop() + + makeCase := func(method string, uri string, body io.Reader) TestCase { + return TestCase{ + Method: method, + URI: uri, + Body: body, + RespStatus: http.StatusUnauthorized, + RespBody: func(t *testing.T, r *http.Response) { + body, err := decodeErrorBody(r.Body) + assert.NoError(t, err) + assert.Equal(t, ErrBadSession.Error(), body.Error) + }, + } + } + + testCases(t, addr, client, []TestCase{ + makeCase(http.MethodGet, "/api/user", nil), + makeCase(http.MethodPost, "/api/change-password", strings.NewReader(`{"old_password":"old","new_password":"new"}`)), + makeCase(http.MethodGet, "/api/nodes", nil), + }) + }) + + t.Run("only_admin_account_allowed", func(t *testing.T) { + addr, client, stop := startNode(MockConfig{5, 10, 10}) + defer stop() + + testCases(t, addr, client, []TestCase{ + { + Method: http.MethodPost, + URI: "/api/create-account", + Body: strings.NewReader(`{"username":"invalid_user","password":"Secure1234"}`), + RespStatus: http.StatusForbidden, + RespBody: func(t *testing.T, r *http.Response) { + body, err := decodeErrorBody(r.Body) + assert.NoError(t, err) + assert.Equal(t, ErrUserNotCreated.Error(), body.Error) + }, + }, + { + Method: http.MethodPost, + URI: "/api/create-account", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + }) + }) + + t.Run("cannot_login_twice", func(t *testing.T) { + addr, client, stop := startNode(MockConfig{5, 10, 10}) + defer stop() + + testCases(t, addr, client, []TestCase{ + { + Method: http.MethodPost, + URI: "/api/create-account", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusForbidden, + RespBody: func(t *testing.T, r *http.Response) { + body, err := decodeErrorBody(r.Body) + assert.NoError(t, err) + assert.Equal(t, ErrNotLoggedOut.Error(), body.Error) + }, + }, + }) + }) + + t.Run("access_after_login", func(t *testing.T) { + addr, client, stop := startNode(MockConfig{5, 10, 10}) + defer stop() + + testCases(t, addr, client, []TestCase{ + { + Method: http.MethodPost, + URI: "/api/create-account", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodGet, + URI: "/api/user", + RespStatus: http.StatusOK, + }, + { + Method: http.MethodGet, + URI: "/api/nodes", + RespStatus: http.StatusOK, + }, + }) + }) + + t.Run("no_access_after_logout", func(t *testing.T) { + addr, client, stop := startNode(MockConfig{5, 10, 10}) + defer stop() + + testCases(t, addr, client, []TestCase{ + { + Method: http.MethodPost, + URI: "/api/create-account", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/logout", + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodGet, + URI: "/api/user", + RespStatus: http.StatusUnauthorized, + RespBody: func(t *testing.T, r *http.Response) { + body, err := decodeErrorBody(r.Body) + assert.NoError(t, err) + assert.Equal(t, ErrBadSession.Error(), body.Error) + }, + }, + { + Method: http.MethodGet, + URI: "/api/nodes", + RespStatus: http.StatusUnauthorized, + RespBody: func(t *testing.T, r *http.Response) { + body, err := decodeErrorBody(r.Body) + assert.NoError(t, err) + assert.Equal(t, ErrBadSession.Error(), body.Error) + }, + }, + }) + }) + + t.Run("change_password", func(t *testing.T) { + // - Create account. + // - Login. + // - Change Password. + // - Logout. + // - Login with old password (should fail). + // - Login with new password (should succeed). + + addr, client, stop := startNode(MockConfig{5, 10, 10}) + defer stop() + + testCases(t, addr, client, []TestCase{ + { + Method: http.MethodPost, + URI: "/api/create-account", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/change-password", + Body: strings.NewReader(`{"old_password":"Secure1234","new_password":"NewSecure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/logout", + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"Secure1234"}`), + RespStatus: http.StatusUnauthorized, + RespBody: func(t *testing.T, r *http.Response) { + b, err := decodeErrorBody(r.Body) + assert.NoError(t, err) + require.Equal(t, ErrBadLogin.Error(), b.Error) + }, + }, + { + Method: http.MethodPost, + URI: "/api/login", + Body: strings.NewReader(`{"username":"admin","password":"NewSecure1234"}`), + RespStatus: http.StatusOK, + RespBody: func(t *testing.T, r *http.Response) { + var ok bool + assert.NoError(t, json.NewDecoder(r.Body).Decode(&ok)) + assert.True(t, ok) + }, + }, + }) + }) +} + +type ErrorBody struct { + Error string `json:"error"` +} + +func decodeErrorBody(rb io.Reader) (*ErrorBody, error) { + b := new(ErrorBody) + dec := json.NewDecoder(rb) + dec.DisallowUnknownFields() + return b, dec.Decode(b) +} diff --git a/pkg/manager/user.go b/pkg/manager/user.go new file mode 100644 index 0000000000..d0654f27c0 --- /dev/null +++ b/pkg/manager/user.go @@ -0,0 +1,216 @@ +package manager + +import ( + "bytes" + "encoding/gob" + "os" + "path/filepath" + "regexp" + "time" + + "go.etcd.io/bbolt" + + "github.com/skycoin/skywire/pkg/cipher" +) + +const ( + boltTimeout = 10 * time.Second + boltUserBucketName = "users" + passwordSaltLen = 16 +) + +func init() { + gob.Register(User{}) +} + +// User represents a user of the manager. +type User struct { + Name string + PwSalt []byte + PwHash cipher.SHA256 +} + +// SetName checks the provided name, and sets the name if format is valid. +func (u *User) SetName(name string) bool { + if !UsernameFormatOkay(name) { + return false + } + u.Name = name + return true +} + +// SetPassword checks the provided password, and sets the password if format is valid. +func (u *User) SetPassword(password string) bool { + if !PasswordFormatOkay(password) { + return false + } + u.PwSalt = cipher.RandByte(passwordSaltLen) + u.PwHash = cipher.SumSHA256(append([]byte(password), u.PwSalt...)) + return true +} + +// VerifyPassword verifies the password input with hash and salt. +func (u *User) VerifyPassword(password string) bool { + return cipher.SumSHA256(append([]byte(password), u.PwSalt...)) == u.PwHash +} + +// Encode encodes the user to bytes. +func (u *User) Encode() []byte { + var buf bytes.Buffer + if err := gob.NewEncoder(&buf).Encode(u); err != nil { + catch(err, "unexpected user encode error:") + } + return buf.Bytes() +} + +// DecodeUser decodes the user from bytes. +func DecodeUser(raw []byte) User { + var user User + if err := gob.NewDecoder(bytes.NewReader(raw)).Decode(&user); err != nil { + catch(err, "unexpected decode user error:") + } + return user +} + +// UserStore stores users. +type UserStore interface { + User(name string) (User, bool) + AddUser(user User) bool + SetUser(user User) bool + RemoveUser(name string) +} + +// BoltUserStore implements UserStore, storing users in a bbolt database file. +type BoltUserStore struct { + *bbolt.DB +} + +// NewBoltUserStore creates a new BoltUserStore. +func NewBoltUserStore(path string) (*BoltUserStore, error) { + if err := os.MkdirAll(filepath.Dir(path), os.FileMode(0700)); err != nil { + return nil, err + } + db, err := bbolt.Open(path, os.FileMode(0600), &bbolt.Options{Timeout: boltTimeout}) + if err != nil { + return nil, err + } + err = db.Update(func(tx *bbolt.Tx) error { + _, err := tx.CreateBucketIfNotExists([]byte(boltUserBucketName)) + return err + }) + return &BoltUserStore{DB: db}, err +} + +// User obtains a single user. Returns true if user exists. +func (s *BoltUserStore) User(name string) (user User, ok bool) { + catch(s.View(func(tx *bbolt.Tx) error { //nolint:unparam + users := tx.Bucket([]byte(boltUserBucketName)) + rawUser := users.Get([]byte(name)) + if rawUser == nil { + ok = false + return nil + } + user = DecodeUser(rawUser) + ok = true + return nil + })) + return user, ok +} + +// AddUser adds a new user; ok is true when successful. +func (s *BoltUserStore) AddUser(user User) (ok bool) { + catch(s.Update(func(tx *bbolt.Tx) error { + users := tx.Bucket([]byte(boltUserBucketName)) + if users.Get([]byte(user.Name)) != nil { + ok = false + return nil + } + ok = true + return users.Put([]byte(user.Name), user.Encode()) + })) + return ok +} + +// SetUser changes an existing user. Returns true on success. +func (s *BoltUserStore) SetUser(user User) (ok bool) { + catch(s.Update(func(tx *bbolt.Tx) error { + users := tx.Bucket([]byte(boltUserBucketName)) + if users.Get([]byte(user.Name)) == nil { + ok = false + return nil + } + ok = true + return users.Put([]byte(user.Name), user.Encode()) + })) + return ok +} + +// RemoveUser removes a user of given username. +func (s *BoltUserStore) RemoveUser(name string) { + catch(s.Update(func(tx *bbolt.Tx) error { + return tx.Bucket([]byte(boltUserBucketName)).Delete([]byte(name)) + })) +} + +// SingleUserStore implements UserStore while enforcing only having a single user. +type SingleUserStore struct { + username string + UserStore +} + +// NewSingleUserStore creates a new SingleUserStore with provided username and UserStore. +func NewSingleUserStore(username string, users UserStore) *SingleUserStore { + return &SingleUserStore{ + username: username, + UserStore: users, + } +} + +// User gets a user. +func (s *SingleUserStore) User(name string) (User, bool) { + if s.allowName(name) { + return s.UserStore.User(name) + } + return User{}, false +} + +// AddUser adds a new user. +func (s *SingleUserStore) AddUser(user User) bool { + if s.allowName(user.Name) { + return s.UserStore.AddUser(user) + } + return false +} + +// SetUser sets an existing user. +func (s *SingleUserStore) SetUser(user User) bool { + if s.allowName(user.Name) { + return s.UserStore.SetUser(user) + } + return false +} + +// RemoveUser removes a user. +func (s *SingleUserStore) RemoveUser(name string) { + if s.allowName(name) { + s.UserStore.RemoveUser(name) + } +} + +func (s *SingleUserStore) allowName(name string) bool { + return name == s.username +} + +// UsernameFormatOkay checks if the username format is valid. +func UsernameFormatOkay(name string) bool { + return regexp.MustCompile(`^[a-z0-9_-]{4,21}$`).MatchString(name) +} + +// PasswordFormatOkay checks if the password format is valid. +func PasswordFormatOkay(pass string) bool { + if len(pass) < 6 || len(pass) > 64 { + return false + } + // TODO: implement more advanced password checking. + return true +} diff --git a/pkg/manager/user_manager.go b/pkg/manager/user_manager.go new file mode 100644 index 0000000000..74a72c2a8f --- /dev/null +++ b/pkg/manager/user_manager.go @@ -0,0 +1,275 @@ +package manager + +import ( + "context" + "errors" + "net/http" + "sync" + "time" + + "github.com/google/uuid" + "github.com/gorilla/securecookie" + + "github.com/skycoin/skywire/internal/httputil" +) + +const ( + sessionCookieName = "swm-session" +) + +// Errors associated with user management. +var ( + ErrBadBody = errors.New("ill-formatted request body") + ErrNotLoggedOut = errors.New("not logged out") + ErrBadLogin = errors.New("incorrect username or password") + ErrBadSession = errors.New("session cookie is either non-existent, expired, or ill-formatted") + ErrBadUsernameFormat = errors.New("format of 'username' is not accepted") + ErrBadPasswordFormat = errors.New("format of 'password' is not accepted") + ErrUserNotCreated = errors.New("failed to create new user: username is either already taken, or unaccepted") + ErrUserNotFound = errors.New("user is either deleted or not found") +) + +// for use with context.Context +type ctxKey string + +const ( + userKey = ctxKey("user") + sessionKey = ctxKey("session") +) + +// Session represents a user session. +type Session struct { + SID uuid.UUID `json:"sid"` + User string `json:"username"` + Expiry time.Time `json:"expiry"` +} + +// UserManager manages the users and sessions. +type UserManager struct { + c CookieConfig + db UserStore + sessions map[uuid.UUID]Session + crypto *securecookie.SecureCookie + mu *sync.RWMutex +} + +// NewUserManager creates a new UserManager. +func NewUserManager(users UserStore, config CookieConfig) *UserManager { + return &UserManager{ + db: users, + c: config, + sessions: make(map[uuid.UUID]Session), + crypto: securecookie.New(config.HashKey, config.BlockKey), + mu: new(sync.RWMutex), + } +} + +// Login returns a HandlerFunc for login operations. +func (s *UserManager) Login() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if _, _, ok := s.session(r); ok { + httputil.WriteJSON(w, r, http.StatusForbidden, ErrNotLoggedOut) + return + } + var rb struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := httputil.ReadJSON(r, &rb); err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, ErrBadBody) + return + } + user, ok := s.db.User(rb.Username) + if !ok || !user.VerifyPassword(rb.Password) { + httputil.WriteJSON(w, r, http.StatusUnauthorized, ErrBadLogin) + return + } + s.newSession(w, Session{ + User: rb.Username, + Expiry: time.Now().Add(s.c.ExpiresDuration), + }) + //http.SetCookie() + httputil.WriteJSON(w, r, http.StatusOK, ok) + } +} + +// Logout returns a HandlerFunc of logout operations. +func (s *UserManager) Logout() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := s.delSession(w, r); err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, errors.New("not logged in")) + return + } + httputil.WriteJSON(w, r, http.StatusOK, true) + } +} + +// Authorize is an http middleware for authorizing requests. +func (s *UserManager) Authorize(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, session, ok := s.session(r) + if !ok { + httputil.WriteJSON(w, r, http.StatusUnauthorized, ErrBadSession) + return + } + ctx := r.Context() + ctx = context.WithValue(ctx, userKey, user) + ctx = context.WithValue(ctx, sessionKey, session) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// ChangePassword returns a HandlerFunc for changing the user's password. +func (s *UserManager) ChangePassword() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + user = r.Context().Value(userKey).(User) + ) + var rb struct { + OldPassword string `json:"old_password"` + NewPassword string `json:"new_password"` + } + if err := httputil.ReadJSON(r, &rb); err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, err) + return + } + if ok := user.VerifyPassword(rb.OldPassword); !ok { + httputil.WriteJSON(w, r, http.StatusUnauthorized, ErrBadLogin) + return + } + if ok := user.SetPassword(rb.NewPassword); !ok { + httputil.WriteJSON(w, r, http.StatusBadRequest, ErrBadPasswordFormat) + return + } + if ok := s.db.SetUser(user); !ok { + httputil.WriteJSON(w, r, http.StatusForbidden, ErrUserNotFound) + return + } + httputil.WriteJSON(w, r, http.StatusOK, true) + } +} + +// CreateAccount returns a HandlerFunc for account creation. +func (s *UserManager) CreateAccount() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var rb struct { + Username string `json:"username"` + Password string `json:"password"` + } + if err := httputil.ReadJSON(r, &rb); err != nil { + httputil.WriteJSON(w, r, http.StatusBadRequest, err) + return + } + var user User + if ok := user.SetName(rb.Username); !ok { + httputil.WriteJSON(w, r, http.StatusBadRequest, ErrBadUsernameFormat) + return + } + if ok := user.SetPassword(rb.Password); !ok { + httputil.WriteJSON(w, r, http.StatusBadRequest, ErrBadPasswordFormat) + return + } + if ok := s.db.AddUser(user); !ok { + httputil.WriteJSON(w, r, http.StatusForbidden, ErrUserNotCreated) + return + } + httputil.WriteJSON(w, r, http.StatusOK, true) + } +} + +// UserInfo returns a HandlerFunc for obtaining user info. +func (s *UserManager) UserInfo() http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var ( + user = r.Context().Value(userKey).(User) + session = r.Context().Value(sessionKey).(Session) + ) + var otherSessions []Session + s.mu.RLock() + for _, s := range s.sessions { + if s.User == user.Name && s.SID != session.SID { + otherSessions = append(otherSessions, s) + } + } + s.mu.RUnlock() + httputil.WriteJSON(w, r, http.StatusOK, struct { + Username string `json:"username"` + Current Session `json:"current_session"` + Sessions []Session `json:"other_sessions"` + }{ + Username: user.Name, + Current: session, + Sessions: otherSessions, + }) + } +} + +func (s *UserManager) newSession(w http.ResponseWriter, session Session) { + session.SID = uuid.New() + s.mu.Lock() + s.sessions[session.SID] = session + s.mu.Unlock() + value, err := s.crypto.Encode(sessionCookieName, session.SID) + catch(err) + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: value, + Domain: s.c.Domain, + Expires: time.Now().Add(s.c.ExpiresDuration), + Secure: s.c.Secure, + HttpOnly: s.c.HTTPOnly, + SameSite: s.c.SameSite, + }) +} + +func (s *UserManager) delSession(w http.ResponseWriter, r *http.Request) error { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return err + } + var sid uuid.UUID + if err := s.crypto.Decode(sessionCookieName, cookie.Value, &sid); err != nil { + return err + } + s.mu.Lock() + delete(s.sessions, sid) + s.mu.Unlock() + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Domain: s.c.Domain, + MaxAge: -1, + Secure: s.c.Secure, + HttpOnly: s.c.HTTPOnly, + SameSite: s.c.SameSite, + }) + return nil +} + +func (s *UserManager) session(r *http.Request) (User, Session, bool) { + cookie, err := r.Cookie(sessionCookieName) + if err != nil { + return User{}, Session{}, false + } + var sid uuid.UUID + if err := s.crypto.Decode(sessionCookieName, cookie.Value, &sid); err != nil { + log.WithError(err).Warn("failed to decode session cookie value") + return User{}, Session{}, false + } + s.mu.RLock() + session, ok := s.sessions[sid] + s.mu.RUnlock() + if !ok { + return User{}, Session{}, false + } + user, ok := s.db.User(session.User) + if !ok { + return User{}, Session{}, false + } + if time.Now().After(session.Expiry) { + s.mu.Lock() + delete(s.sessions, sid) + s.mu.Unlock() + return User{}, Session{}, false + } + return user, session, true +} diff --git a/pkg/routing/boltdb_routing_table.go b/pkg/routing/boltdb_routing_table.go index 0c045fdfc5..e2394740bc 100644 --- a/pkg/routing/boltdb_routing_table.go +++ b/pkg/routing/boltdb_routing_table.go @@ -6,24 +6,24 @@ import ( "fmt" "math" - bolt "go.etcd.io/bbolt" + "go.etcd.io/bbolt" ) var boltDBBucket = []byte("routing") // BoltDBRoutingTable implements RoutingTable on top of BoltDB. type boltDBRoutingTable struct { - db *bolt.DB + db *bbolt.DB } // BoltDBRoutingTable consturcts a new BoldDBRoutingTable. func BoltDBRoutingTable(path string) (Table, error) { - db, err := bolt.Open(path, 0600, nil) + db, err := bbolt.Open(path, 0600, nil) if err != nil { return nil, err } - err = db.Update(func(tx *bolt.Tx) error { + err = db.Update(func(tx *bbolt.Tx) error { if _, err := tx.CreateBucketIfNotExists(boltDBBucket); err != nil { return fmt.Errorf("failed to create bucket: %s", err) } @@ -39,7 +39,7 @@ func BoltDBRoutingTable(path string) (Table, error) { // AddRule adds routing rule to the table and returns assigned Route ID. func (rt *boltDBRoutingTable) AddRule(rule Rule) (routeID RouteID, err error) { - err = rt.db.Update(func(tx *bolt.Tx) error { + err = rt.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket(boltDBBucket) nextID, _ := b.NextSequence() // nolint @@ -56,7 +56,7 @@ func (rt *boltDBRoutingTable) AddRule(rule Rule) (routeID RouteID, err error) { // SetRule sets RoutingRule for a given RouteID. func (rt *boltDBRoutingTable) SetRule(routeID RouteID, rule Rule) error { - return rt.db.Update(func(tx *bolt.Tx) error { + return rt.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket(boltDBBucket) return b.Put(binaryID(routeID), []byte(rule)) @@ -66,7 +66,7 @@ func (rt *boltDBRoutingTable) SetRule(routeID RouteID, rule Rule) error { // Rule returns RoutingRule with a given RouteID. func (rt *boltDBRoutingTable) Rule(routeID RouteID) (Rule, error) { var rule Rule - err := rt.db.View(func(tx *bolt.Tx) error { // nolint: unparam + err := rt.db.View(func(tx *bbolt.Tx) error { // nolint: unparam b := tx.Bucket(boltDBBucket) rule = b.Get(binaryID(routeID)) return nil @@ -77,7 +77,7 @@ func (rt *boltDBRoutingTable) Rule(routeID RouteID) (Rule, error) { // RangeRules iterates over all rules and yields values to the rangeFunc until `next` is false. func (rt *boltDBRoutingTable) RangeRules(rangeFunc RangeFunc) error { - return rt.db.View(func(tx *bolt.Tx) error { // nolint: unparam + return rt.db.View(func(tx *bbolt.Tx) error { // nolint: unparam b := tx.Bucket(boltDBBucket) b.ForEach(func(k, v []byte) error { // nolint if !rangeFunc(RouteID(binary.BigEndian.Uint32(k)), v) { @@ -93,7 +93,7 @@ func (rt *boltDBRoutingTable) RangeRules(rangeFunc RangeFunc) error { // Rules returns RoutingRules for a given RouteIDs. func (rt *boltDBRoutingTable) Rules(routeIDs ...RouteID) (rules []Rule, err error) { rules = []Rule{} - err = rt.db.View(func(tx *bolt.Tx) error { // nolint: unparam + err = rt.db.View(func(tx *bbolt.Tx) error { // nolint: unparam b := tx.Bucket(boltDBBucket) for _, routeID := range routeIDs { @@ -110,7 +110,7 @@ func (rt *boltDBRoutingTable) Rules(routeIDs ...RouteID) (rules []Rule, err erro // DeleteRules removes RoutingRules with a given a RouteIDs. func (rt *boltDBRoutingTable) DeleteRules(routeIDs ...RouteID) error { - return rt.db.Update(func(tx *bolt.Tx) error { + return rt.db.Update(func(tx *bbolt.Tx) error { b := tx.Bucket(boltDBBucket) var dErr error @@ -126,7 +126,7 @@ func (rt *boltDBRoutingTable) DeleteRules(routeIDs ...RouteID) error { // Count returns the number of routing rules stored. func (rt *boltDBRoutingTable) Count() (count int) { - err := rt.db.View(func(tx *bolt.Tx) error { // nolint: unparam + err := rt.db.View(func(tx *bbolt.Tx) error { // nolint: unparam b := tx.Bucket(boltDBBucket) stats := b.Stats() diff --git a/users.db b/users.db new file mode 100644 index 0000000000..27182d35c5 Binary files /dev/null and b/users.db differ diff --git a/vendor/github.com/gorilla/securecookie/.travis.yml b/vendor/github.com/gorilla/securecookie/.travis.yml new file mode 100644 index 0000000000..6f440f1e42 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/.travis.yml @@ -0,0 +1,19 @@ +language: go +sudo: false + +matrix: + include: + - go: 1.3 + - go: 1.4 + - go: 1.5 + - go: 1.6 + - go: 1.7 + - go: tip + allow_failures: + - go: tip + +script: + - go get -t -v ./... + - diff -u <(echo -n) <(gofmt -d .) + - go vet $(go list ./... | grep -v /vendor/) + - go test -v -race ./... diff --git a/vendor/github.com/gorilla/securecookie/LICENSE b/vendor/github.com/gorilla/securecookie/LICENSE new file mode 100644 index 0000000000..0e5fb87280 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/LICENSE @@ -0,0 +1,27 @@ +Copyright (c) 2012 Rodrigo Moraes. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/gorilla/securecookie/README.md b/vendor/github.com/gorilla/securecookie/README.md new file mode 100644 index 0000000000..aa7bd1a5b2 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/README.md @@ -0,0 +1,80 @@ +securecookie +============ +[![GoDoc](https://godoc.org/github.com/gorilla/securecookie?status.svg)](https://godoc.org/github.com/gorilla/securecookie) [![Build Status](https://travis-ci.org/gorilla/securecookie.png?branch=master)](https://travis-ci.org/gorilla/securecookie) +[![Sourcegraph](https://sourcegraph.com/github.com/gorilla/securecookie/-/badge.svg)](https://sourcegraph.com/github.com/gorilla/securecookie?badge) + + +securecookie encodes and decodes authenticated and optionally encrypted +cookie values. + +Secure cookies can't be forged, because their values are validated using HMAC. +When encrypted, the content is also inaccessible to malicious eyes. It is still +recommended that sensitive data not be stored in cookies, and that HTTPS be used +to prevent cookie [replay attacks](https://en.wikipedia.org/wiki/Replay_attack). + +## Examples + +To use it, first create a new SecureCookie instance: + +```go +// Hash keys should be at least 32 bytes long +var hashKey = []byte("very-secret") +// Block keys should be 16 bytes (AES-128) or 32 bytes (AES-256) long. +// Shorter keys may weaken the encryption used. +var blockKey = []byte("a-lot-secret") +var s = securecookie.New(hashKey, blockKey) +``` + +The hashKey is required, used to authenticate the cookie value using HMAC. +It is recommended to use a key with 32 or 64 bytes. + +The blockKey is optional, used to encrypt the cookie value -- set it to nil +to not use encryption. If set, the length must correspond to the block size +of the encryption algorithm. For AES, used by default, valid lengths are +16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. + +Strong keys can be created using the convenience function GenerateRandomKey(). + +Once a SecureCookie instance is set, use it to encode a cookie value: + +```go +func SetCookieHandler(w http.ResponseWriter, r *http.Request) { + value := map[string]string{ + "foo": "bar", + } + if encoded, err := s.Encode("cookie-name", value); err == nil { + cookie := &http.Cookie{ + Name: "cookie-name", + Value: encoded, + Path: "/", + Secure: true, + HttpOnly: true, + } + http.SetCookie(w, cookie) + } +} +``` + +Later, use the same SecureCookie instance to decode and validate a cookie +value: + +```go +func ReadCookieHandler(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("cookie-name"); err == nil { + value := make(map[string]string) + if err = s2.Decode("cookie-name", cookie.Value, &value); err == nil { + fmt.Fprintf(w, "The value of foo is %q", value["foo"]) + } + } +} +``` + +We stored a map[string]string, but secure cookies can hold any value that +can be encoded using `encoding/gob`. To store custom types, they must be +registered first using gob.Register(). For basic types this is not needed; +it works out of the box. An optional JSON encoder that uses `encoding/json` is +available for types compatible with JSON. + +## License + +BSD licensed. See the LICENSE file for details. diff --git a/vendor/github.com/gorilla/securecookie/doc.go b/vendor/github.com/gorilla/securecookie/doc.go new file mode 100644 index 0000000000..ae89408d9d --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/doc.go @@ -0,0 +1,61 @@ +// Copyright 2012 The Gorilla Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +/* +Package securecookie encodes and decodes authenticated and optionally +encrypted cookie values. + +Secure cookies can't be forged, because their values are validated using HMAC. +When encrypted, the content is also inaccessible to malicious eyes. + +To use it, first create a new SecureCookie instance: + + var hashKey = []byte("very-secret") + var blockKey = []byte("a-lot-secret") + var s = securecookie.New(hashKey, blockKey) + +The hashKey is required, used to authenticate the cookie value using HMAC. +It is recommended to use a key with 32 or 64 bytes. + +The blockKey is optional, used to encrypt the cookie value -- set it to nil +to not use encryption. If set, the length must correspond to the block size +of the encryption algorithm. For AES, used by default, valid lengths are +16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. + +Strong keys can be created using the convenience function GenerateRandomKey(). + +Once a SecureCookie instance is set, use it to encode a cookie value: + + func SetCookieHandler(w http.ResponseWriter, r *http.Request) { + value := map[string]string{ + "foo": "bar", + } + if encoded, err := s.Encode("cookie-name", value); err == nil { + cookie := &http.Cookie{ + Name: "cookie-name", + Value: encoded, + Path: "/", + } + http.SetCookie(w, cookie) + } + } + +Later, use the same SecureCookie instance to decode and validate a cookie +value: + + func ReadCookieHandler(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("cookie-name"); err == nil { + value := make(map[string]string) + if err = s2.Decode("cookie-name", cookie.Value, &value); err == nil { + fmt.Fprintf(w, "The value of foo is %q", value["foo"]) + } + } + } + +We stored a map[string]string, but secure cookies can hold any value that +can be encoded using encoding/gob. To store custom types, they must be +registered first using gob.Register(). For basic types this is not needed; +it works out of the box. +*/ +package securecookie diff --git a/vendor/github.com/gorilla/securecookie/fuzz.go b/vendor/github.com/gorilla/securecookie/fuzz.go new file mode 100644 index 0000000000..e4d0534e41 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/fuzz.go @@ -0,0 +1,25 @@ +// +build gofuzz + +package securecookie + +var hashKey = []byte("very-secret12345") +var blockKey = []byte("a-lot-secret1234") +var s = New(hashKey, blockKey) + +type Cookie struct { + B bool + I int + S string +} + +func Fuzz(data []byte) int { + datas := string(data) + var c Cookie + if err := s.Decode("fuzz", datas, &c); err != nil { + return 0 + } + if _, err := s.Encode("fuzz", c); err != nil { + panic(err) + } + return 1 +} diff --git a/vendor/github.com/gorilla/securecookie/securecookie.go b/vendor/github.com/gorilla/securecookie/securecookie.go new file mode 100644 index 0000000000..cd4e0976d6 --- /dev/null +++ b/vendor/github.com/gorilla/securecookie/securecookie.go @@ -0,0 +1,646 @@ +// Copyright 2012 The Gorilla Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package securecookie + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/gob" + "encoding/json" + "fmt" + "hash" + "io" + "strconv" + "strings" + "time" +) + +// Error is the interface of all errors returned by functions in this library. +type Error interface { + error + + // IsUsage returns true for errors indicating the client code probably + // uses this library incorrectly. For example, the client may have + // failed to provide a valid hash key, or may have failed to configure + // the Serializer adequately for encoding value. + IsUsage() bool + + // IsDecode returns true for errors indicating that a cookie could not + // be decoded and validated. Since cookies are usually untrusted + // user-provided input, errors of this type should be expected. + // Usually, the proper action is simply to reject the request. + IsDecode() bool + + // IsInternal returns true for unexpected errors occurring in the + // securecookie implementation. + IsInternal() bool + + // Cause, if it returns a non-nil value, indicates that this error was + // propagated from some underlying library. If this method returns nil, + // this error was raised directly by this library. + // + // Cause is provided principally for debugging/logging purposes; it is + // rare that application logic should perform meaningfully different + // logic based on Cause. See, for example, the caveats described on + // (MultiError).Cause(). + Cause() error +} + +// errorType is a bitmask giving the error type(s) of an cookieError value. +type errorType int + +const ( + usageError = errorType(1 << iota) + decodeError + internalError +) + +type cookieError struct { + typ errorType + msg string + cause error +} + +func (e cookieError) IsUsage() bool { return (e.typ & usageError) != 0 } +func (e cookieError) IsDecode() bool { return (e.typ & decodeError) != 0 } +func (e cookieError) IsInternal() bool { return (e.typ & internalError) != 0 } + +func (e cookieError) Cause() error { return e.cause } + +func (e cookieError) Error() string { + parts := []string{"securecookie: "} + if e.msg == "" { + parts = append(parts, "error") + } else { + parts = append(parts, e.msg) + } + if c := e.Cause(); c != nil { + parts = append(parts, " - caused by: ", c.Error()) + } + return strings.Join(parts, "") +} + +var ( + errGeneratingIV = cookieError{typ: internalError, msg: "failed to generate random iv"} + + errNoCodecs = cookieError{typ: usageError, msg: "no codecs provided"} + errHashKeyNotSet = cookieError{typ: usageError, msg: "hash key is not set"} + errBlockKeyNotSet = cookieError{typ: usageError, msg: "block key is not set"} + errEncodedValueTooLong = cookieError{typ: usageError, msg: "the value is too long"} + + errValueToDecodeTooLong = cookieError{typ: decodeError, msg: "the value is too long"} + errTimestampInvalid = cookieError{typ: decodeError, msg: "invalid timestamp"} + errTimestampTooNew = cookieError{typ: decodeError, msg: "timestamp is too new"} + errTimestampExpired = cookieError{typ: decodeError, msg: "expired timestamp"} + errDecryptionFailed = cookieError{typ: decodeError, msg: "the value could not be decrypted"} + errValueNotByte = cookieError{typ: decodeError, msg: "value not a []byte."} + errValueNotBytePtr = cookieError{typ: decodeError, msg: "value not a pointer to []byte."} + + // ErrMacInvalid indicates that cookie decoding failed because the HMAC + // could not be extracted and verified. Direct use of this error + // variable is deprecated; it is public only for legacy compatibility, + // and may be privatized in the future, as it is rarely useful to + // distinguish between this error and other Error implementations. + ErrMacInvalid = cookieError{typ: decodeError, msg: "the value is not valid"} +) + +// Codec defines an interface to encode and decode cookie values. +type Codec interface { + Encode(name string, value interface{}) (string, error) + Decode(name, value string, dst interface{}) error +} + +// New returns a new SecureCookie. +// +// hashKey is required, used to authenticate values using HMAC. Create it using +// GenerateRandomKey(). It is recommended to use a key with 32 or 64 bytes. +// +// blockKey is optional, used to encrypt values. Create it using +// GenerateRandomKey(). The key length must correspond to the block size +// of the encryption algorithm. For AES, used by default, valid lengths are +// 16, 24, or 32 bytes to select AES-128, AES-192, or AES-256. +// The default encoder used for cookie serialization is encoding/gob. +// +// Note that keys created using GenerateRandomKey() are not automatically +// persisted. New keys will be created when the application is restarted, and +// previously issued cookies will not be able to be decoded. +func New(hashKey, blockKey []byte) *SecureCookie { + s := &SecureCookie{ + hashKey: hashKey, + blockKey: blockKey, + hashFunc: sha256.New, + maxAge: 86400 * 30, + maxLength: 4096, + sz: GobEncoder{}, + } + if hashKey == nil { + s.err = errHashKeyNotSet + } + if blockKey != nil { + s.BlockFunc(aes.NewCipher) + } + return s +} + +// SecureCookie encodes and decodes authenticated and optionally encrypted +// cookie values. +type SecureCookie struct { + hashKey []byte + hashFunc func() hash.Hash + blockKey []byte + block cipher.Block + maxLength int + maxAge int64 + minAge int64 + err error + sz Serializer + // For testing purposes, the function that returns the current timestamp. + // If not set, it will use time.Now().UTC().Unix(). + timeFunc func() int64 +} + +// Serializer provides an interface for providing custom serializers for cookie +// values. +type Serializer interface { + Serialize(src interface{}) ([]byte, error) + Deserialize(src []byte, dst interface{}) error +} + +// GobEncoder encodes cookie values using encoding/gob. This is the simplest +// encoder and can handle complex types via gob.Register. +type GobEncoder struct{} + +// JSONEncoder encodes cookie values using encoding/json. Users who wish to +// encode complex types need to satisfy the json.Marshaller and +// json.Unmarshaller interfaces. +type JSONEncoder struct{} + +// NopEncoder does not encode cookie values, and instead simply accepts a []byte +// (as an interface{}) and returns a []byte. This is particularly useful when +// you encoding an object upstream and do not wish to re-encode it. +type NopEncoder struct{} + +// MaxLength restricts the maximum length, in bytes, for the cookie value. +// +// Default is 4096, which is the maximum value accepted by Internet Explorer. +func (s *SecureCookie) MaxLength(value int) *SecureCookie { + s.maxLength = value + return s +} + +// MaxAge restricts the maximum age, in seconds, for the cookie value. +// +// Default is 86400 * 30. Set it to 0 for no restriction. +func (s *SecureCookie) MaxAge(value int) *SecureCookie { + s.maxAge = int64(value) + return s +} + +// MinAge restricts the minimum age, in seconds, for the cookie value. +// +// Default is 0 (no restriction). +func (s *SecureCookie) MinAge(value int) *SecureCookie { + s.minAge = int64(value) + return s +} + +// HashFunc sets the hash function used to create HMAC. +// +// Default is crypto/sha256.New. +func (s *SecureCookie) HashFunc(f func() hash.Hash) *SecureCookie { + s.hashFunc = f + return s +} + +// BlockFunc sets the encryption function used to create a cipher.Block. +// +// Default is crypto/aes.New. +func (s *SecureCookie) BlockFunc(f func([]byte) (cipher.Block, error)) *SecureCookie { + if s.blockKey == nil { + s.err = errBlockKeyNotSet + } else if block, err := f(s.blockKey); err == nil { + s.block = block + } else { + s.err = cookieError{cause: err, typ: usageError} + } + return s +} + +// Encoding sets the encoding/serialization method for cookies. +// +// Default is encoding/gob. To encode special structures using encoding/gob, +// they must be registered first using gob.Register(). +func (s *SecureCookie) SetSerializer(sz Serializer) *SecureCookie { + s.sz = sz + + return s +} + +// Encode encodes a cookie value. +// +// It serializes, optionally encrypts, signs with a message authentication code, +// and finally encodes the value. +// +// The name argument is the cookie name. It is stored with the encoded value. +// The value argument is the value to be encoded. It can be any value that can +// be encoded using the currently selected serializer; see SetSerializer(). +// +// It is the client's responsibility to ensure that value, when encoded using +// the current serialization/encryption settings on s and then base64-encoded, +// is shorter than the maximum permissible length. +func (s *SecureCookie) Encode(name string, value interface{}) (string, error) { + if s.err != nil { + return "", s.err + } + if s.hashKey == nil { + s.err = errHashKeyNotSet + return "", s.err + } + var err error + var b []byte + // 1. Serialize. + if b, err = s.sz.Serialize(value); err != nil { + return "", cookieError{cause: err, typ: usageError} + } + // 2. Encrypt (optional). + if s.block != nil { + if b, err = encrypt(s.block, b); err != nil { + return "", cookieError{cause: err, typ: usageError} + } + } + b = encode(b) + // 3. Create MAC for "name|date|value". Extra pipe to be used later. + b = []byte(fmt.Sprintf("%s|%d|%s|", name, s.timestamp(), b)) + mac := createMac(hmac.New(s.hashFunc, s.hashKey), b[:len(b)-1]) + // Append mac, remove name. + b = append(b, mac...)[len(name)+1:] + // 4. Encode to base64. + b = encode(b) + // 5. Check length. + if s.maxLength != 0 && len(b) > s.maxLength { + return "", errEncodedValueTooLong + } + // Done. + return string(b), nil +} + +// Decode decodes a cookie value. +// +// It decodes, verifies a message authentication code, optionally decrypts and +// finally deserializes the value. +// +// The name argument is the cookie name. It must be the same name used when +// it was stored. The value argument is the encoded cookie value. The dst +// argument is where the cookie will be decoded. It must be a pointer. +func (s *SecureCookie) Decode(name, value string, dst interface{}) error { + if s.err != nil { + return s.err + } + if s.hashKey == nil { + s.err = errHashKeyNotSet + return s.err + } + // 1. Check length. + if s.maxLength != 0 && len(value) > s.maxLength { + return errValueToDecodeTooLong + } + // 2. Decode from base64. + b, err := decode([]byte(value)) + if err != nil { + return err + } + // 3. Verify MAC. Value is "date|value|mac". + parts := bytes.SplitN(b, []byte("|"), 3) + if len(parts) != 3 { + return ErrMacInvalid + } + h := hmac.New(s.hashFunc, s.hashKey) + b = append([]byte(name+"|"), b[:len(b)-len(parts[2])-1]...) + if err = verifyMac(h, b, parts[2]); err != nil { + return err + } + // 4. Verify date ranges. + var t1 int64 + if t1, err = strconv.ParseInt(string(parts[0]), 10, 64); err != nil { + return errTimestampInvalid + } + t2 := s.timestamp() + if s.minAge != 0 && t1 > t2-s.minAge { + return errTimestampTooNew + } + if s.maxAge != 0 && t1 < t2-s.maxAge { + return errTimestampExpired + } + // 5. Decrypt (optional). + b, err = decode(parts[1]) + if err != nil { + return err + } + if s.block != nil { + if b, err = decrypt(s.block, b); err != nil { + return err + } + } + // 6. Deserialize. + if err = s.sz.Deserialize(b, dst); err != nil { + return cookieError{cause: err, typ: decodeError} + } + // Done. + return nil +} + +// timestamp returns the current timestamp, in seconds. +// +// For testing purposes, the function that generates the timestamp can be +// overridden. If not set, it will return time.Now().UTC().Unix(). +func (s *SecureCookie) timestamp() int64 { + if s.timeFunc == nil { + return time.Now().UTC().Unix() + } + return s.timeFunc() +} + +// Authentication ------------------------------------------------------------- + +// createMac creates a message authentication code (MAC). +func createMac(h hash.Hash, value []byte) []byte { + h.Write(value) + return h.Sum(nil) +} + +// verifyMac verifies that a message authentication code (MAC) is valid. +func verifyMac(h hash.Hash, value []byte, mac []byte) error { + mac2 := createMac(h, value) + // Check that both MACs are of equal length, as subtle.ConstantTimeCompare + // does not do this prior to Go 1.4. + if len(mac) == len(mac2) && subtle.ConstantTimeCompare(mac, mac2) == 1 { + return nil + } + return ErrMacInvalid +} + +// Encryption ----------------------------------------------------------------- + +// encrypt encrypts a value using the given block in counter mode. +// +// A random initialization vector (http://goo.gl/zF67k) with the length of the +// block size is prepended to the resulting ciphertext. +func encrypt(block cipher.Block, value []byte) ([]byte, error) { + iv := GenerateRandomKey(block.BlockSize()) + if iv == nil { + return nil, errGeneratingIV + } + // Encrypt it. + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(value, value) + // Return iv + ciphertext. + return append(iv, value...), nil +} + +// decrypt decrypts a value using the given block in counter mode. +// +// The value to be decrypted must be prepended by a initialization vector +// (http://goo.gl/zF67k) with the length of the block size. +func decrypt(block cipher.Block, value []byte) ([]byte, error) { + size := block.BlockSize() + if len(value) > size { + // Extract iv. + iv := value[:size] + // Extract ciphertext. + value = value[size:] + // Decrypt it. + stream := cipher.NewCTR(block, iv) + stream.XORKeyStream(value, value) + return value, nil + } + return nil, errDecryptionFailed +} + +// Serialization -------------------------------------------------------------- + +// Serialize encodes a value using gob. +func (e GobEncoder) Serialize(src interface{}) ([]byte, error) { + buf := new(bytes.Buffer) + enc := gob.NewEncoder(buf) + if err := enc.Encode(src); err != nil { + return nil, cookieError{cause: err, typ: usageError} + } + return buf.Bytes(), nil +} + +// Deserialize decodes a value using gob. +func (e GobEncoder) Deserialize(src []byte, dst interface{}) error { + dec := gob.NewDecoder(bytes.NewBuffer(src)) + if err := dec.Decode(dst); err != nil { + return cookieError{cause: err, typ: decodeError} + } + return nil +} + +// Serialize encodes a value using encoding/json. +func (e JSONEncoder) Serialize(src interface{}) ([]byte, error) { + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + if err := enc.Encode(src); err != nil { + return nil, cookieError{cause: err, typ: usageError} + } + return buf.Bytes(), nil +} + +// Deserialize decodes a value using encoding/json. +func (e JSONEncoder) Deserialize(src []byte, dst interface{}) error { + dec := json.NewDecoder(bytes.NewReader(src)) + if err := dec.Decode(dst); err != nil { + return cookieError{cause: err, typ: decodeError} + } + return nil +} + +// Serialize passes a []byte through as-is. +func (e NopEncoder) Serialize(src interface{}) ([]byte, error) { + if b, ok := src.([]byte); ok { + return b, nil + } + + return nil, errValueNotByte +} + +// Deserialize passes a []byte through as-is. +func (e NopEncoder) Deserialize(src []byte, dst interface{}) error { + if dat, ok := dst.(*[]byte); ok { + *dat = src + return nil + } + return errValueNotBytePtr +} + +// Encoding ------------------------------------------------------------------- + +// encode encodes a value using base64. +func encode(value []byte) []byte { + encoded := make([]byte, base64.URLEncoding.EncodedLen(len(value))) + base64.URLEncoding.Encode(encoded, value) + return encoded +} + +// decode decodes a cookie using base64. +func decode(value []byte) ([]byte, error) { + decoded := make([]byte, base64.URLEncoding.DecodedLen(len(value))) + b, err := base64.URLEncoding.Decode(decoded, value) + if err != nil { + return nil, cookieError{cause: err, typ: decodeError, msg: "base64 decode failed"} + } + return decoded[:b], nil +} + +// Helpers -------------------------------------------------------------------- + +// GenerateRandomKey creates a random key with the given length in bytes. +// On failure, returns nil. +// +// Callers should explicitly check for the possibility of a nil return, treat +// it as a failure of the system random number generator, and not continue. +func GenerateRandomKey(length int) []byte { + k := make([]byte, length) + if _, err := io.ReadFull(rand.Reader, k); err != nil { + return nil + } + return k +} + +// CodecsFromPairs returns a slice of SecureCookie instances. +// +// It is a convenience function to create a list of codecs for key rotation. Note +// that the generated Codecs will have the default options applied: callers +// should iterate over each Codec and type-assert the underlying *SecureCookie to +// change these. +// +// Example: +// +// codecs := securecookie.CodecsFromPairs( +// []byte("new-hash-key"), +// []byte("new-block-key"), +// []byte("old-hash-key"), +// []byte("old-block-key"), +// ) +// +// // Modify each instance. +// for _, s := range codecs { +// if cookie, ok := s.(*securecookie.SecureCookie); ok { +// cookie.MaxAge(86400 * 7) +// cookie.SetSerializer(securecookie.JSONEncoder{}) +// cookie.HashFunc(sha512.New512_256) +// } +// } +// +func CodecsFromPairs(keyPairs ...[]byte) []Codec { + codecs := make([]Codec, len(keyPairs)/2+len(keyPairs)%2) + for i := 0; i < len(keyPairs); i += 2 { + var blockKey []byte + if i+1 < len(keyPairs) { + blockKey = keyPairs[i+1] + } + codecs[i/2] = New(keyPairs[i], blockKey) + } + return codecs +} + +// EncodeMulti encodes a cookie value using a group of codecs. +// +// The codecs are tried in order. Multiple codecs are accepted to allow +// key rotation. +// +// On error, may return a MultiError. +func EncodeMulti(name string, value interface{}, codecs ...Codec) (string, error) { + if len(codecs) == 0 { + return "", errNoCodecs + } + + var errors MultiError + for _, codec := range codecs { + encoded, err := codec.Encode(name, value) + if err == nil { + return encoded, nil + } + errors = append(errors, err) + } + return "", errors +} + +// DecodeMulti decodes a cookie value using a group of codecs. +// +// The codecs are tried in order. Multiple codecs are accepted to allow +// key rotation. +// +// On error, may return a MultiError. +func DecodeMulti(name string, value string, dst interface{}, codecs ...Codec) error { + if len(codecs) == 0 { + return errNoCodecs + } + + var errors MultiError + for _, codec := range codecs { + err := codec.Decode(name, value, dst) + if err == nil { + return nil + } + errors = append(errors, err) + } + return errors +} + +// MultiError groups multiple errors. +type MultiError []error + +func (m MultiError) IsUsage() bool { return m.any(func(e Error) bool { return e.IsUsage() }) } +func (m MultiError) IsDecode() bool { return m.any(func(e Error) bool { return e.IsDecode() }) } +func (m MultiError) IsInternal() bool { return m.any(func(e Error) bool { return e.IsInternal() }) } + +// Cause returns nil for MultiError; there is no unique underlying cause in the +// general case. +// +// Note: we could conceivably return a non-nil Cause only when there is exactly +// one child error with a Cause. However, it would be brittle for client code +// to rely on the arity of causes inside a MultiError, so we have opted not to +// provide this functionality. Clients which really wish to access the Causes +// of the underlying errors are free to iterate through the errors themselves. +func (m MultiError) Cause() error { return nil } + +func (m MultiError) Error() string { + s, n := "", 0 + for _, e := range m { + if e != nil { + if n == 0 { + s = e.Error() + } + n++ + } + } + switch n { + case 0: + return "(0 errors)" + case 1: + return s + case 2: + return s + " (and 1 other error)" + } + return fmt.Sprintf("%s (and %d other errors)", s, n-1) +} + +// any returns true if any element of m is an Error for which pred returns true. +func (m MultiError) any(pred func(Error) bool) bool { + for _, e := range m { + if ourErr, ok := e.(Error); ok && pred(ourErr) { + return true + } + } + return false +} diff --git a/vendor/modules.txt b/vendor/modules.txt index 1ee825ed6a..9e2259b97b 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -9,6 +9,8 @@ github.com/go-chi/chi github.com/go-chi/chi/middleware # github.com/google/uuid v1.1.1 github.com/google/uuid +# github.com/gorilla/securecookie v1.1.1 +github.com/gorilla/securecookie # github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d github.com/hashicorp/yamux # github.com/inconshreveable/mousetrap v1.0.0