Link Sharing Service (#2431)
Link sharing service. See `docs/design/link-sharing-service.md` for the design and `cmd/linksharing/README.md` for operational instructions.
This commit is contained in:
parent
af7ffb8072
commit
416fa80e85
1
cmd/linksharing/.gitignore
vendored
Normal file
1
cmd/linksharing/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
linksharing
|
57
cmd/linksharing/README.md
Normal file
57
cmd/linksharing/README.md
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
# Link Sharing Service
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
```
|
||||||
|
$ go install storj.io/storj/cmd/linksharing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuring
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Default development configuration has the link sharing service hosted on
|
||||||
|
`localhost:8080` serving plain HTTP.
|
||||||
|
|
||||||
|
```
|
||||||
|
$ linksharing setup --defaults dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production
|
||||||
|
|
||||||
|
To configure the link sharing service for production, run the `setup` command
|
||||||
|
using the `release` defaults. You must also provide the public URL for
|
||||||
|
the sharing service, which is used to construct URLs returned to
|
||||||
|
clients. Since there is currently no server affinity for requests, the URL
|
||||||
|
can point to a pool of servers:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ linksharing setup --defaults release --public-url <PUBLIC URL>
|
||||||
|
```
|
||||||
|
|
||||||
|
Default release configuration has the link sharing service hosted on `:8443`
|
||||||
|
serving HTTPS using a server certificate (`server.crt.pem`) and
|
||||||
|
key (`server.key.pem`) residing in the working directory where the linksharing
|
||||||
|
service is run.
|
||||||
|
|
||||||
|
You can modify the configuration file or use the `--cert-file` and `--key-file`
|
||||||
|
flags to configure an alternate location for the server keypair.
|
||||||
|
|
||||||
|
In order to run the link sharing service in release mode serving HTTP, you must
|
||||||
|
clear the certificate and key file configurables:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ linksharing setup --defaults release --public-url <PUBLIC URL> --cert-file="" --key-file="" --address=":8080"
|
||||||
|
```
|
||||||
|
|
||||||
|
**WARNING** HTTP is only recommended if you are doing TLS termination on the
|
||||||
|
same machine running the link sharing service as the link sharing service
|
||||||
|
serves unencrypted user data.
|
||||||
|
|
||||||
|
## Running
|
||||||
|
|
||||||
|
After configuration is complete, running the link sharing is as simple as:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ linksharing run
|
||||||
|
```
|
141
cmd/linksharing/main.go
Normal file
141
cmd/linksharing/main.go
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
// Copyright (C) 2019 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/zeebo/errs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"storj.io/storj/internal/fpath"
|
||||||
|
"storj.io/storj/lib/uplink"
|
||||||
|
"storj.io/storj/pkg/cfgstruct"
|
||||||
|
"storj.io/storj/pkg/httpserver"
|
||||||
|
"storj.io/storj/pkg/linksharing"
|
||||||
|
"storj.io/storj/pkg/process"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LinkSharing defines link sharing configuration
|
||||||
|
type LinkSharing struct {
|
||||||
|
Address string `user:"true" help:"public address to listen on" devDefault:"localhost:8080" releaseDefault:":8443"`
|
||||||
|
CertFile string `user:"true" help:"server certificate file" devDefault:"" releaseDefault:"server.crt.pem"`
|
||||||
|
KeyFile string `user:"true" help:"server key file" devDefault:"" releaseDefault:"server.key.pem"`
|
||||||
|
PublicURL string `user:"true" help:"public url for the server" devDefault:"http://localhost:8080" releaseDefault:""`
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
rootCmd = &cobra.Command{
|
||||||
|
Use: "link sharing service",
|
||||||
|
Short: "Link Sharing Service",
|
||||||
|
}
|
||||||
|
runCmd = &cobra.Command{
|
||||||
|
Use: "run",
|
||||||
|
Short: "Run the link sharing service",
|
||||||
|
RunE: cmdRun,
|
||||||
|
}
|
||||||
|
setupCmd = &cobra.Command{
|
||||||
|
Use: "setup",
|
||||||
|
Short: "Create config files",
|
||||||
|
RunE: cmdSetup,
|
||||||
|
Annotations: map[string]string{"type": "setup"},
|
||||||
|
}
|
||||||
|
|
||||||
|
runCfg LinkSharing
|
||||||
|
setupCfg LinkSharing
|
||||||
|
|
||||||
|
confDir string
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
defaultConfDir := fpath.ApplicationDir("storj", "linksharing")
|
||||||
|
cfgstruct.SetupFlag(zap.L(), rootCmd, &confDir, "config-dir", defaultConfDir, "main directory for link sharing configuration")
|
||||||
|
defaults := cfgstruct.DefaultsFlag(rootCmd)
|
||||||
|
rootCmd.AddCommand(runCmd)
|
||||||
|
rootCmd.AddCommand(setupCmd)
|
||||||
|
process.Bind(runCmd, &runCfg, defaults, cfgstruct.ConfDir(confDir))
|
||||||
|
process.Bind(setupCmd, &setupCfg, defaults, cfgstruct.ConfDir(confDir), cfgstruct.SetupMode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdRun(cmd *cobra.Command, args []string) (err error) {
|
||||||
|
ctx := process.Ctx(cmd)
|
||||||
|
log := zap.L()
|
||||||
|
|
||||||
|
uplink, err := uplink.NewUplink(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tlsConfig, err := configureTLS(runCfg.CertFile, runCfg.KeyFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
handler, err := linksharing.NewHandler(linksharing.HandlerConfig{
|
||||||
|
Log: log,
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: runCfg.PublicURL,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
server, err := httpserver.New(log, httpserver.Config{
|
||||||
|
Name: "Link Sharing",
|
||||||
|
Address: runCfg.Address,
|
||||||
|
Handler: handler,
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
ShutdownTimeout: -1,
|
||||||
|
})
|
||||||
|
|
||||||
|
return server.Run(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cmdSetup(cmd *cobra.Command, args []string) (err error) {
|
||||||
|
setupDir, err := filepath.Abs(confDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, _ := fpath.IsValidSetupDir(setupDir)
|
||||||
|
if !valid {
|
||||||
|
return fmt.Errorf("link sharing configuration already exists (%v)", setupDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.MkdirAll(setupDir, 0700)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return process.SaveConfigWithAllDefaults(cmd.Flags(), filepath.Join(setupDir, "config.yaml"), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func configureTLS(certFile, keyFile string) (*tls.Config, error) {
|
||||||
|
switch {
|
||||||
|
case certFile != "" && keyFile != "":
|
||||||
|
case certFile == "" && keyFile == "":
|
||||||
|
return nil, nil
|
||||||
|
case certFile != "" && keyFile == "":
|
||||||
|
return nil, errs.New("key file must be provided with cert file")
|
||||||
|
case certFile == "" && keyFile != "":
|
||||||
|
return nil, errs.New("cert file must be provided with key file")
|
||||||
|
}
|
||||||
|
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.New("unable to load server keypair: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
process.Exec(rootCmd)
|
||||||
|
}
|
@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/zeebo/errs"
|
"github.com/zeebo/errs"
|
||||||
|
|
||||||
"storj.io/storj/internal/fpath"
|
"storj.io/storj/internal/fpath"
|
||||||
|
"storj.io/storj/lib/uplink"
|
||||||
libuplink "storj.io/storj/lib/uplink"
|
libuplink "storj.io/storj/lib/uplink"
|
||||||
"storj.io/storj/pkg/macaroon"
|
"storj.io/storj/pkg/macaroon"
|
||||||
"storj.io/storj/pkg/process"
|
"storj.io/storj/pkg/process"
|
||||||
@ -151,7 +152,19 @@ func shareMain(cmd *cobra.Command, args []string) (err error) {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scope := &uplink.Scope{
|
||||||
|
SatelliteAddr: cfg.Client.SatelliteAddr,
|
||||||
|
APIKey: key,
|
||||||
|
EncryptionAccess: access,
|
||||||
|
}
|
||||||
|
|
||||||
|
scopeData, err := scope.Serialize()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("api key:", key.Serialize())
|
fmt.Println("api key:", key.Serialize())
|
||||||
fmt.Println("enc ctx:", accessData)
|
fmt.Println("enc ctx:", accessData)
|
||||||
|
fmt.Println("scope :", scopeData)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
132
pkg/httpserver/server.go
Normal file
132
pkg/httpserver/server.go
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
// Copyright (C) 2019 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/zeebo/errs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
|
||||||
|
"storj.io/storj/internal/errs2"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultShutdownTimeout is the default ShutdownTimeout (see Config)
|
||||||
|
DefaultShutdownTimeout = time.Second * 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config holds the HTTP server configuration
|
||||||
|
type Config struct {
|
||||||
|
// Name is the name of the server. It is only used for logging. It can
|
||||||
|
// be empty.
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Address is the address to bind the server to. It must be set.
|
||||||
|
Address string
|
||||||
|
|
||||||
|
// Handler is the HTTP handler to be served. It must be set.
|
||||||
|
Handler http.Handler
|
||||||
|
|
||||||
|
// TLSConfig is the TLS configuration for the server. It is optional.
|
||||||
|
TLSConfig *tls.Config
|
||||||
|
|
||||||
|
// ShutdownTimeout controls how long to wait for requests to finish before
|
||||||
|
// returning from Run() after the context is canceled. It defaults to
|
||||||
|
// 10 seconds if unset. If set to a negative value, the server will be
|
||||||
|
// closed immediately.
|
||||||
|
ShutdownTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the HTTP server
|
||||||
|
type Server struct {
|
||||||
|
log *zap.Logger
|
||||||
|
name string
|
||||||
|
listener net.Listener
|
||||||
|
server *http.Server
|
||||||
|
shutdownTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new URL Service Server.
|
||||||
|
func New(log *zap.Logger, config Config) (*Server, error) {
|
||||||
|
switch {
|
||||||
|
case config.Address == "":
|
||||||
|
return nil, errs.New("server address is required")
|
||||||
|
case config.Handler == nil:
|
||||||
|
return nil, errs.New("server handler is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", config.Address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.New("unable to listen on %s: %v", config.Address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
server := &http.Server{
|
||||||
|
Handler: config.Handler,
|
||||||
|
TLSConfig: config.TLSConfig,
|
||||||
|
ErrorLog: zap.NewStdLog(log),
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.ShutdownTimeout == 0 {
|
||||||
|
config.ShutdownTimeout = DefaultShutdownTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Name != "" {
|
||||||
|
log = log.With(zap.String("server", config.Name))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Server{
|
||||||
|
log: log,
|
||||||
|
name: config.Name,
|
||||||
|
listener: listener,
|
||||||
|
server: server,
|
||||||
|
shutdownTimeout: config.ShutdownTimeout,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run runs the server until it's either closed or it errors.
|
||||||
|
func (server *Server) Run(ctx context.Context) (err error) {
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
var group errgroup.Group
|
||||||
|
|
||||||
|
group.Go(func() error {
|
||||||
|
<-ctx.Done()
|
||||||
|
server.log.Info("Server shutting down")
|
||||||
|
return shutdownWithTimeout(server.server, server.shutdownTimeout)
|
||||||
|
})
|
||||||
|
group.Go(func() (err error) {
|
||||||
|
defer cancel()
|
||||||
|
server.log.With(zap.String("addr", server.Addr())).Sugar().Info("Server started")
|
||||||
|
if server.server.TLSConfig == nil {
|
||||||
|
err = server.server.Serve(server.listener)
|
||||||
|
} else {
|
||||||
|
err = server.server.ServeTLS(server.listener, "", "")
|
||||||
|
}
|
||||||
|
if err == http.ErrServerClosed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
server.log.With(zap.Error(err)).Error("Server closed unexpectedly")
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
return group.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the public address.
|
||||||
|
func (server *Server) Addr() string {
|
||||||
|
return server.listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func shutdownWithTimeout(server *http.Server, timeout time.Duration) error {
|
||||||
|
if timeout < 0 {
|
||||||
|
return server.Close()
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
return errs2.IgnoreCanceled(server.Shutdown(ctx))
|
||||||
|
}
|
180
pkg/httpserver/server_test.go
Normal file
180
pkg/httpserver/server_test.go
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
// Copyright (C) 2019 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
package httpserver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"io/ioutil"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"storj.io/storj/internal/testcontext"
|
||||||
|
"storj.io/storj/pkg/pkcrypto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
testKey = mustSignerFromPEM(`-----BEGIN PRIVATE KEY-----
|
||||||
|
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgT8yIof+3qG3wQzXf
|
||||||
|
eAOcuTgWmgqXRnHVwKJl2g1pCb2hRANCAARWxVAPyT1BRs2hqiDuHlPXr1kVDXuw
|
||||||
|
7/a1USmgsVWiZ0W3JopcTbTMhvMZk+2MKqtWcc3gHF4vRDnHTeQl4lsx
|
||||||
|
-----END PRIVATE KEY-----
|
||||||
|
`)
|
||||||
|
testCert = mustCreateLocalhostCert()
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestServer(t *testing.T) {
|
||||||
|
address := "localhost:0"
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fmt.Fprint(w, "OK")
|
||||||
|
})
|
||||||
|
tlsConfig := &tls.Config{
|
||||||
|
Certificates: []tls.Certificate{
|
||||||
|
{
|
||||||
|
Certificate: [][]byte{testCert.Raw},
|
||||||
|
PrivateKey: testKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
testCases := []serverTestCase{
|
||||||
|
{
|
||||||
|
Name: "missing address",
|
||||||
|
Handler: handler,
|
||||||
|
NewErr: "server address is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "bad address",
|
||||||
|
Address: "this is no good",
|
||||||
|
Handler: handler,
|
||||||
|
NewErr: "unable to listen on this is no good: listen tcp: address this is no good: missing port in address",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "missing handler",
|
||||||
|
Address: address,
|
||||||
|
NewErr: "server handler is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "success via HTTP",
|
||||||
|
Address: address,
|
||||||
|
Handler: handler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "success via HTTPS",
|
||||||
|
Address: address,
|
||||||
|
Handler: handler,
|
||||||
|
TLSConfig: tlsConfig,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
testCase := testCase
|
||||||
|
t.Run(testCase.Name, func(t *testing.T) {
|
||||||
|
ctx := testcontext.NewWithTimeout(t, time.Minute)
|
||||||
|
defer ctx.Cleanup()
|
||||||
|
|
||||||
|
s, ok := testCase.NewServer(t)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
runCtx, cancel := context.WithCancel(ctx)
|
||||||
|
ctx.Go(func() error {
|
||||||
|
return s.Run(runCtx)
|
||||||
|
})
|
||||||
|
|
||||||
|
testCase.DoGet(t, s)
|
||||||
|
cancel()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type serverTestCase struct {
|
||||||
|
Name string
|
||||||
|
Address string
|
||||||
|
Handler http.Handler
|
||||||
|
TLSConfig *tls.Config
|
||||||
|
NewErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testCase *serverTestCase) NewServer(tb testing.TB) (*Server, bool) {
|
||||||
|
s, err := New(zaptest.NewLogger(tb), Config{
|
||||||
|
Name: "test",
|
||||||
|
Address: testCase.Address,
|
||||||
|
Handler: testCase.Handler,
|
||||||
|
TLSConfig: testCase.TLSConfig,
|
||||||
|
})
|
||||||
|
if testCase.NewErr != "" {
|
||||||
|
require.EqualError(tb, err, testCase.NewErr)
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
require.NoError(tb, err)
|
||||||
|
return s, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (testCase *serverTestCase) DoGet(tb testing.TB, s *Server) {
|
||||||
|
scheme := "http"
|
||||||
|
client := &http.Client{}
|
||||||
|
if testCase.TLSConfig != nil {
|
||||||
|
scheme = "https"
|
||||||
|
client.Transport = &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
RootCAs: certPoolFromCert(testCert),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := client.Get(fmt.Sprintf("%s://%s", scheme, s.Addr()))
|
||||||
|
require.NoError(tb, err)
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
assert.Equal(tb, resp.StatusCode, http.StatusOK)
|
||||||
|
|
||||||
|
body, err := ioutil.ReadAll(resp.Body)
|
||||||
|
assert.NoError(tb, err)
|
||||||
|
assert.Equal(tb, "OK", string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustSignerFromPEM(keyBytes string) crypto.Signer {
|
||||||
|
key, err := pkcrypto.PrivateKeyFromPEM([]byte(keyBytes))
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return key.(crypto.Signer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustCreateLocalhostCert() *x509.Certificate {
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: big.NewInt(0),
|
||||||
|
NotAfter: time.Now().Add(time.Hour),
|
||||||
|
DNSNames: []string{"localhost"},
|
||||||
|
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||||
|
}
|
||||||
|
certDER, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, testKey.Public(), testKey)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
cert, err := x509.ParseCertificate(certDER)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return cert
|
||||||
|
}
|
||||||
|
|
||||||
|
func certPoolFromCert(cert *x509.Certificate) *x509.CertPool {
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(cert)
|
||||||
|
return pool
|
||||||
|
}
|
225
pkg/linksharing/handler.go
Normal file
225
pkg/linksharing/handler.go
Normal file
@ -0,0 +1,225 @@
|
|||||||
|
// Copyright (C) 2019 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
package linksharing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/zeebo/errs"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gopkg.in/spacemonkeygo/monkit.v2"
|
||||||
|
|
||||||
|
"storj.io/storj/lib/uplink"
|
||||||
|
"storj.io/storj/pkg/ranger"
|
||||||
|
"storj.io/storj/pkg/storj"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mon = monkit.Package()
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandlerConfig specifies the handler configuration
|
||||||
|
type HandlerConfig struct {
|
||||||
|
// Log is a logger used for logging
|
||||||
|
Log *zap.Logger
|
||||||
|
|
||||||
|
// Uplink is the uplink used to talk to the storage network
|
||||||
|
Uplink *uplink.Uplink
|
||||||
|
|
||||||
|
// URLBase is the base URL of the link sharing handler. It is used
|
||||||
|
// to construct URLs returned to clients. It should be a fully formed URL.
|
||||||
|
URLBase string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler implements the link sharing HTTP handler
|
||||||
|
type Handler struct {
|
||||||
|
log *zap.Logger
|
||||||
|
uplink *uplink.Uplink
|
||||||
|
urlBase *url.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a new link sharing HTTP handler
|
||||||
|
func NewHandler(config HandlerConfig) (*Handler, error) {
|
||||||
|
if config.Log == nil {
|
||||||
|
config.Log = zap.L()
|
||||||
|
}
|
||||||
|
|
||||||
|
if config.Uplink == nil {
|
||||||
|
return nil, errs.New("uplink is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
urlBase, err := parseURLBase(config.URLBase)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handler{
|
||||||
|
log: config.Log,
|
||||||
|
uplink: config.Uplink,
|
||||||
|
urlBase: urlBase,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP handles link sharing requests
|
||||||
|
func (handler *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// serveHTTP handles the request in full. the error that is returned can
|
||||||
|
// be ignored since it was only added to facilitate monitoring.
|
||||||
|
_ = handler.serveHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) serveHTTP(w http.ResponseWriter, r *http.Request) (err error) {
|
||||||
|
ctx := r.Context()
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
|
||||||
|
locationOnly := false
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodHead:
|
||||||
|
locationOnly = true
|
||||||
|
case http.MethodGet:
|
||||||
|
default:
|
||||||
|
err = errs.New("method not allowed")
|
||||||
|
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
scope, bucket, unencPath, err := parseRequestPath(r.URL.Path)
|
||||||
|
if err != nil {
|
||||||
|
err = fmt.Errorf("invalid request: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := handler.uplink.OpenProject(ctx, scope.SatelliteAddr, scope.APIKey)
|
||||||
|
if err != nil {
|
||||||
|
handler.handleUplinkErr(w, "open project", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := p.Close(); err != nil {
|
||||||
|
handler.log.With(zap.Error(err)).Warn("unable to close project")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
b, err := p.OpenBucket(ctx, bucket, scope.EncryptionAccess)
|
||||||
|
if err != nil {
|
||||||
|
handler.handleUplinkErr(w, "open bucket", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := b.Close(); err != nil {
|
||||||
|
handler.log.With(zap.Error(err)).Warn("unable to close bucket")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
o, err := b.OpenObject(ctx, unencPath)
|
||||||
|
if err != nil {
|
||||||
|
handler.handleUplinkErr(w, "open object", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := o.Close(); err != nil {
|
||||||
|
handler.log.With(zap.Error(err)).Warn("unable to close object")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if locationOnly {
|
||||||
|
location := makeLocation(handler.urlBase, r.URL.Path)
|
||||||
|
http.Redirect(w, r, location, http.StatusFound)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ranger.ServeContent(ctx, w, r, unencPath, o.Meta.Modified, newObjectRanger(o))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) handleUplinkErr(w http.ResponseWriter, action string, err error) {
|
||||||
|
switch {
|
||||||
|
case storj.ErrBucketNotFound.Has(err):
|
||||||
|
http.Error(w, "bucket not found", http.StatusNotFound)
|
||||||
|
case storj.ErrObjectNotFound.Has(err):
|
||||||
|
http.Error(w, "object not found", http.StatusNotFound)
|
||||||
|
default:
|
||||||
|
handler.log.Error("unable to handle request", zap.String("action", action), zap.Error(err))
|
||||||
|
http.Error(w, "unable to handle request", http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRequestPath(p string) (*uplink.Scope, string, string, error) {
|
||||||
|
// Drop the leading slash, if necessary
|
||||||
|
p = strings.TrimPrefix(p, "/")
|
||||||
|
|
||||||
|
// Split the request path
|
||||||
|
segments := strings.SplitN(p, "/", 3)
|
||||||
|
switch len(segments) {
|
||||||
|
case 1:
|
||||||
|
if segments[0] == "" {
|
||||||
|
return nil, "", "", errs.New("missing scope")
|
||||||
|
}
|
||||||
|
return nil, "", "", errs.New("missing bucket")
|
||||||
|
case 2:
|
||||||
|
return nil, "", "", errs.New("missing bucket path")
|
||||||
|
}
|
||||||
|
scopeb58 := segments[0]
|
||||||
|
bucket := segments[1]
|
||||||
|
unencPath := segments[2]
|
||||||
|
|
||||||
|
scope, err := uplink.ParseScope(scopeb58)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", err
|
||||||
|
}
|
||||||
|
return scope, bucket, unencPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type objectRanger struct {
|
||||||
|
o *uplink.Object
|
||||||
|
}
|
||||||
|
|
||||||
|
func newObjectRanger(o *uplink.Object) ranger.Ranger {
|
||||||
|
return &objectRanger{
|
||||||
|
o: o,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ranger *objectRanger) Size() int64 {
|
||||||
|
return ranger.o.Meta.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ranger *objectRanger) Range(ctx context.Context, offset, length int64) (_ io.ReadCloser, err error) {
|
||||||
|
defer mon.Task()(&ctx)(&err)
|
||||||
|
return ranger.o.DownloadRange(ctx, offset, length)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseURLBase(s string) (*url.URL, error) {
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errs.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case u.Scheme != "http" && u.Scheme != "https":
|
||||||
|
return nil, errs.New("URL base must be http:// or https://")
|
||||||
|
case u.Host == "":
|
||||||
|
return nil, errs.New("URL base must contain host")
|
||||||
|
case u.User != nil:
|
||||||
|
return nil, errs.New("URL base must not contain user info")
|
||||||
|
case u.RawQuery != "":
|
||||||
|
return nil, errs.New("URL base must not contain query values")
|
||||||
|
case u.Fragment != "":
|
||||||
|
return nil, errs.New("URL base must not contain a fragment")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeLocation(base *url.URL, reqPath string) string {
|
||||||
|
location := *base
|
||||||
|
location.Path = path.Join(location.Path, reqPath)
|
||||||
|
return location.String()
|
||||||
|
}
|
284
pkg/linksharing/handler_test.go
Normal file
284
pkg/linksharing/handler_test.go
Normal file
@ -0,0 +1,284 @@
|
|||||||
|
// Copyright (C) 2019 Storj Labs, Inc.
|
||||||
|
// See LICENSE for copying information.
|
||||||
|
|
||||||
|
package linksharing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/zap/zaptest"
|
||||||
|
|
||||||
|
"storj.io/storj/internal/testcontext"
|
||||||
|
"storj.io/storj/internal/testplanet"
|
||||||
|
"storj.io/storj/lib/uplink"
|
||||||
|
"storj.io/storj/pkg/storj"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewHandler(t *testing.T) {
|
||||||
|
ctx := testcontext.New(t)
|
||||||
|
defer ctx.Cleanup()
|
||||||
|
|
||||||
|
uplink := newUplink(ctx, t)
|
||||||
|
defer ctx.Check(uplink.Close)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
config HandlerConfig
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing uplink",
|
||||||
|
config: HandlerConfig{
|
||||||
|
URLBase: "http://localhost",
|
||||||
|
},
|
||||||
|
err: "uplink is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base must be http or https",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "gopher://chunks",
|
||||||
|
},
|
||||||
|
err: "URL base must be http:// or https://",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base must contain host",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://",
|
||||||
|
},
|
||||||
|
err: "URL base must contain host",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base can have a port",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://host:99",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base can have a path",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://host/gopher",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base must not contain user info",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://joe@host",
|
||||||
|
},
|
||||||
|
err: "URL base must not contain user info",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base must not contain query values",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://host/?gopher=chunks",
|
||||||
|
},
|
||||||
|
err: "URL base must not contain query values",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "URL base must not contain a fragment",
|
||||||
|
config: HandlerConfig{
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://host/#gopher-chunks",
|
||||||
|
},
|
||||||
|
err: "URL base must not contain a fragment",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
testCase := testCase
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
|
||||||
|
handler, err := NewHandler(testCase.config)
|
||||||
|
if testCase.err != "" {
|
||||||
|
require.EqualError(t, err, testCase.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandlerRequests(t *testing.T) {
|
||||||
|
testplanet.Run(t, testplanet.Config{
|
||||||
|
SatelliteCount: 2,
|
||||||
|
StorageNodeCount: 1,
|
||||||
|
UplinkCount: 1,
|
||||||
|
}, testHandlerRequests)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testHandlerRequests(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
|
||||||
|
err := planet.Uplinks[0].Upload(ctx, planet.Satellites[0], "testbucket", "test/foo", []byte("FOO"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
apiKey, err := uplink.ParseAPIKey(planet.Uplinks[0].APIKey[planet.Satellites[0].ID()])
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
scope, err := (&uplink.Scope{
|
||||||
|
SatelliteAddr: planet.Satellites[0].Addr(),
|
||||||
|
APIKey: apiKey,
|
||||||
|
EncryptionAccess: uplink.NewEncryptionAccessWithDefaultKey(storj.Key{}),
|
||||||
|
}).Serialize()
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
status int
|
||||||
|
header http.Header
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "invalid method",
|
||||||
|
method: "PUT",
|
||||||
|
status: http.StatusMethodNotAllowed,
|
||||||
|
body: "method not allowed\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET missing scope",
|
||||||
|
method: "GET",
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: missing scope\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET malformed scope",
|
||||||
|
method: "GET",
|
||||||
|
path: path.Join("BADSCOPE", "testbucket", "test/foo"),
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: invalid scope format\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET missing bucket",
|
||||||
|
method: "GET",
|
||||||
|
path: scope,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: missing bucket\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET bucket not found",
|
||||||
|
method: "GET",
|
||||||
|
path: path.Join(scope, "someotherbucket", "test/foo"),
|
||||||
|
status: http.StatusNotFound,
|
||||||
|
body: "bucket not found\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET missing bucket path",
|
||||||
|
method: "GET",
|
||||||
|
path: path.Join(scope, "testbucket"),
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: missing bucket path\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET object not found",
|
||||||
|
method: "GET",
|
||||||
|
path: path.Join(scope, "testbucket", "test/bar"),
|
||||||
|
status: http.StatusNotFound,
|
||||||
|
body: "object not found\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "GET success",
|
||||||
|
method: "GET",
|
||||||
|
path: path.Join(scope, "testbucket", "test/foo"),
|
||||||
|
status: http.StatusOK,
|
||||||
|
body: "FOO",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD missing scope",
|
||||||
|
method: "HEAD",
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: missing scope\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD malformed scope",
|
||||||
|
method: "HEAD",
|
||||||
|
path: path.Join("BADSCOPE", "testbucket", "test/foo"),
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: invalid scope format\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD missing bucket",
|
||||||
|
method: "HEAD",
|
||||||
|
path: scope,
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: missing bucket\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD bucket not found",
|
||||||
|
method: "HEAD",
|
||||||
|
path: path.Join(scope, "someotherbucket", "test/foo"),
|
||||||
|
status: http.StatusNotFound,
|
||||||
|
body: "bucket not found\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD missing bucket path",
|
||||||
|
method: "HEAD",
|
||||||
|
path: path.Join(scope, "testbucket"),
|
||||||
|
status: http.StatusBadRequest,
|
||||||
|
body: "invalid request: missing bucket path\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD object not found",
|
||||||
|
method: "HEAD",
|
||||||
|
path: path.Join(scope, "testbucket", "test/bar"),
|
||||||
|
status: http.StatusNotFound,
|
||||||
|
body: "object not found\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "HEAD success",
|
||||||
|
method: "HEAD",
|
||||||
|
path: path.Join(scope, "testbucket", "test/foo"),
|
||||||
|
status: http.StatusFound,
|
||||||
|
header: http.Header{
|
||||||
|
"Location": []string{"http://localhost/" + path.Join(scope, "testbucket", "test/foo")},
|
||||||
|
},
|
||||||
|
body: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, testCase := range testCases {
|
||||||
|
testCase := testCase
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
uplink := newUplink(ctx, t)
|
||||||
|
defer ctx.Check(uplink.Close)
|
||||||
|
|
||||||
|
handler, err := NewHandler(HandlerConfig{
|
||||||
|
Log: zaptest.NewLogger(t),
|
||||||
|
Uplink: uplink,
|
||||||
|
URLBase: "http://localhost",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
url := "http://localhost/" + testCase.path
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r, err := http.NewRequest(testCase.method, url, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
handler.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
assert.Equal(t, testCase.status, w.Code, "status code does not match")
|
||||||
|
for h, v := range testCase.header {
|
||||||
|
assert.Equal(t, v, w.Header()[h], "%q header does not match", h)
|
||||||
|
}
|
||||||
|
assert.Equal(t, testCase.body, w.Body.String(), "body does not match")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUplink(ctx context.Context, tb testing.TB) *uplink.Uplink {
|
||||||
|
cfg := new(uplink.Config)
|
||||||
|
cfg.Volatile.TLS.SkipPeerCAWhitelist = true
|
||||||
|
up, err := uplink.NewUplink(ctx, cfg)
|
||||||
|
require.NoError(tb, err)
|
||||||
|
return up
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user