Skip to content

Commit

Permalink
refactor: http broadcasting secrets + broadcasting docs
Browse files Browse the repository at this point in the history
  • Loading branch information
palkan committed Mar 6, 2024
1 parent a7aa9b4 commit 607740e
Show file tree
Hide file tree
Showing 11 changed files with 386 additions and 65 deletions.
60 changes: 45 additions & 15 deletions broadcast/http.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import (
"strconv"

"github.com/anycable/anycable-go/server"
"github.com/anycable/anycable-go/utils"
"github.com/joomcode/errorx"
)

const (
defaultHTTPPort = 8090
defaultHTTPPath = "/_broadcast"
defaultHTTPPath = "/_broadcast"
broadcastKeyPhrase = "broadcast-cable"
)

// HTTPConfig contains HTTP pubsub adapter configuration
Expand All @@ -24,20 +26,26 @@ type HTTPConfig struct {
Path string
// Secret token to authorize requests
Secret string
// SecretBase is a secret used to generate a token if none provided
SecretBase string
}

// NewHTTPConfig builds a new config for HTTP pub/sub
func NewHTTPConfig() HTTPConfig {
return HTTPConfig{
Port: defaultHTTPPort,
Path: defaultHTTPPath,
}
}

func (c *HTTPConfig) IsSecured() bool {
return c.Secret != "" || c.SecretBase != ""
}

// HTTPBroadcaster represents HTTP broadcaster
type HTTPBroadcaster struct {
port int
path string
conf *HTTPConfig
authHeader string
server *server.HTTPServer
node Handler
Expand All @@ -48,18 +56,12 @@ var _ Broadcaster = (*HTTPBroadcaster)(nil)

// NewHTTPBroadcaster builds a new HTTPSubscriber struct
func NewHTTPBroadcaster(node Handler, config *HTTPConfig, l *slog.Logger) *HTTPBroadcaster {
authHeader := ""

if config.Secret != "" {
authHeader = fmt.Sprintf("Bearer %s", config.Secret)
}

return &HTTPBroadcaster{
node: node,
log: l.With("context", "broadcast").With("provider", "http"),
port: config.Port,
path: config.Path,
authHeader: authHeader,
node: node,
log: l.With("context", "broadcast").With("provider", "http"),
port: config.Port,
path: config.Path,
conf: config,
}
}

Expand All @@ -75,10 +77,38 @@ func (s *HTTPBroadcaster) Start(done chan (error)) error {
return err
}

authHeader := ""

if s.conf.Secret == "" && s.conf.SecretBase != "" {
secret, err := utils.NewMessageVerifier(s.conf.SecretBase).Sign([]byte(broadcastKeyPhrase))

if err != nil {
err = errorx.Decorate(err, "failed to auto-generate authentication key for HTTP broadcaster")
return err
}

s.log.Info("auto-generated authorization secret from the application secret")
s.conf.Secret = string(secret)
}

if s.conf.Secret != "" {
authHeader = fmt.Sprintf("Bearer %s", s.conf.Secret)
}

s.authHeader = authHeader

s.server = server
s.server.SetupHandler(s.path, http.HandlerFunc(s.Handler))

s.log.Info(fmt.Sprintf("Accept broadcast requests at %s%s", s.server.Address(), s.path))
var verifiedVia string

if s.authHeader != "" {
verifiedVia = "authorization required"
} else {
verifiedVia = "no authorization"
}

s.log.Info(fmt.Sprintf("Accept broadcast requests at %s%s (%s)", s.server.Address(), s.path, verifiedVia))

go func() {
if err := s.server.StartAndAnnounce("broadcasting HTTP server"); err != nil {
Expand Down
38 changes: 22 additions & 16 deletions broadcast/http_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package broadcast

import (
"context"
"encoding/json"
"log/slog"
"net/http"
Expand All @@ -10,15 +11,28 @@ import (

"github.com/anycable/anycable-go/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestHttpHandler(t *testing.T) {
handler := &mocks.Handler{}
config := HTTPConfig{}
secretConfig := HTTPConfig{Secret: "secret"}
config := NewHTTPConfig()

secretConfig := NewHTTPConfig()
secretConfig.SecretBase = "qwerty"
broadcastKey := "42923a28b760e667fc92f7c6123bb07a282822b329dd2ef48e7aee7830d98485"

broadcaster := NewHTTPBroadcaster(handler, &config, slog.Default())
protectedBroadcaster := NewHTTPBroadcaster(handler, &secretConfig, slog.Default())

done := make(chan (error))

require.NoError(t, broadcaster.Start(done))
defer broadcaster.Shutdown(context.Background())

require.NoError(t, protectedBroadcaster.Start(done))
defer protectedBroadcaster.Shutdown(context.Background())

payload, err := json.Marshal(map[string]string{"stream": "any_test", "data": "123_test"})
if err != nil {
t.Fatal(err)
Expand All @@ -31,9 +45,7 @@ func TestHttpHandler(t *testing.T) {

t.Run("Handles broadcasts", func(t *testing.T) {
req, err := http.NewRequest("POST", "/", strings.NewReader(string(payload)))
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)

rr := httptest.NewRecorder()
handler := http.HandlerFunc(broadcaster.Handler)
Expand All @@ -44,9 +56,7 @@ func TestHttpHandler(t *testing.T) {

t.Run("Rejects non-POST requests", func(t *testing.T) {
req, err := http.NewRequest("GET", "/", strings.NewReader(string(payload)))
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)

rr := httptest.NewRecorder()
handler := http.HandlerFunc(broadcaster.Handler)
Expand All @@ -57,9 +67,7 @@ func TestHttpHandler(t *testing.T) {

t.Run("Rejects when authorization header is missing", func(t *testing.T) {
req, err := http.NewRequest("POST", "/", strings.NewReader(string(payload)))
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)

rr := httptest.NewRecorder()
handler := http.HandlerFunc(protectedBroadcaster.Handler)
Expand All @@ -68,13 +76,11 @@ func TestHttpHandler(t *testing.T) {
assert.Equal(t, http.StatusUnauthorized, rr.Code)
})

t.Run("Rejects when authorization header is valid", func(t *testing.T) {
t.Run("Accepts when authorization header is valid", func(t *testing.T) {
req, err := http.NewRequest("POST", "/", strings.NewReader(string(payload)))
req.Header.Set("Authorization", "Bearer secret")
req.Header.Set("Authorization", "Bearer "+broadcastKey)

if err != nil {
t.Fatal(err)
}
require.NoError(t, err)

rr := httptest.NewRecorder()
handler := http.HandlerFunc(protectedBroadcaster.Handler)
Expand Down
79 changes: 59 additions & 20 deletions cli/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,20 +259,63 @@ It has no effect anymore, use public streams instead.`)
c.RPC.Implementation = "none"
}

configureSecrets(
c.Secret,
&c.Streams.Secret,
&c.JWT.Secret,
&c.HTTPBroadcast.Secret,
&c.RPC.Secret,
)
// Legacy HTTP authentication stuff
if c.HTTPBroadcast.Secret == "" {
c.HTTPBroadcast.Secret = c.BroadcastKey
}

// Fallback secrets
if c.Secret != "" {
if c.Streams.Secret == "" {
c.Streams.Secret = c.Secret
}

if c.JWT.Secret == "" {
c.JWT.Secret = c.Secret
}

if c.HTTPBroadcast.Secret == "" {
c.HTTPBroadcast.SecretBase = c.Secret
}

if c.RPC.Secret == "" {
c.RPC.SecretBase = c.Secret
}
}

// Nullify none secrets
if c.Streams.Secret == "none" {
c.Streams.Secret = ""
}

if c.JWT.Secret == "none" {
c.JWT.Secret = ""
}

if c.RPC.Secret == "none" {
c.RPC.Secret = ""
}

if c.HTTPBroadcast.Secret == "none" {
c.HTTPBroadcast.Secret = ""
}

// Configure default HTTP port
if c.HTTPBroadcast.Port == 0 {
if c.HTTPBroadcast.IsSecured() {
c.HTTPBroadcast.Port = c.Port
} else {
c.HTTPBroadcast.Port = 8090
}
}

// Configure public mode and other insecure features
if isPublic {
c.SkipAuth = true
c.Streams.Public = true
// Ensure broadcasting is also public
c.HTTPBroadcast.Secret = ""
c.HTTPBroadcast.SecretBase = ""
}

return &c, nil, false
Expand Down Expand Up @@ -344,6 +387,13 @@ func serverCLIFlags(c *config.Config, path *string, isPublic *bool) []cli.Flag {
Destination: &c.Secret,
},

&cli.StringFlag{
Name: "broadcast_key",
Usage: "An authentication key for broadcast requests",
Value: c.BroadcastKey,
Destination: &c.BroadcastKey,
},

&cli.BoolFlag{
Name: "public",
Usage: "[DANGER ZONE] Run server in the public mode allowing all connections and stream subscriptions",
Expand Down Expand Up @@ -542,8 +592,9 @@ func httpBroadcastCLIFlags(c *config.Config) []cli.Flag {

&cli.StringFlag{
Name: "http_broadcast_secret",
Usage: "HTTP pub/sub authorization secret",
Usage: "[Deprecated] HTTP pub/sub authorization secret",
Destination: &c.HTTPBroadcast.Secret,
Hidden: true,
},
})
}
Expand Down Expand Up @@ -1218,15 +1269,3 @@ func parseTags(str string) map[string]string {

return res
}

func configureSecrets(source string, targets ...*string) {
for _, t := range targets {
if (*t) == "" {
(*t) = source
}

if (*t) == "none" {
(*t) = ""
}
}
}
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
type Config struct {
ID string
Secret string
BroadcastKey string
SkipAuth bool
App node.Config
RPC rpc.Config
Expand Down
Loading

0 comments on commit 607740e

Please sign in to comment.