feat: edge gateway service
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"galaxy/gateway/internal/adminapi"
|
||||
"galaxy/gateway/internal/app"
|
||||
"galaxy/gateway/internal/authn"
|
||||
"galaxy/gateway/internal/config"
|
||||
"galaxy/gateway/internal/downstream"
|
||||
"galaxy/gateway/internal/events"
|
||||
"galaxy/gateway/internal/grpcapi"
|
||||
"galaxy/gateway/internal/logging"
|
||||
"galaxy/gateway/internal/push"
|
||||
"galaxy/gateway/internal/replay"
|
||||
"galaxy/gateway/internal/restapi"
|
||||
"galaxy/gateway/internal/session"
|
||||
"galaxy/gateway/internal/telemetry"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// main loads the gateway configuration, runs the process lifecycle, and exits
|
||||
// with a non-zero status when startup or runtime fails.
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := run(ctx); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context) (err error) {
|
||||
cfg, err := config.LoadFromEnv()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger, err := logging.New(cfg.Logging)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build gateway logger: %w", err)
|
||||
}
|
||||
|
||||
telemetryRuntime, err := telemetry.New(ctx, logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build gateway telemetry: %w", err)
|
||||
}
|
||||
|
||||
grpcDeps, components, cleanup, err := newAuthenticatedGRPCDependencies(ctx, cfg, logger, telemetryRuntime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
||||
defer cancel()
|
||||
|
||||
err = errors.Join(
|
||||
err,
|
||||
cleanup(),
|
||||
telemetryRuntime.Shutdown(shutdownCtx),
|
||||
logging.Sync(logger),
|
||||
)
|
||||
}()
|
||||
|
||||
restServer := restapi.NewServer(cfg.PublicHTTP, restapi.ServerDependencies{
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
})
|
||||
grpcServer := grpcapi.NewServer(cfg.AuthenticatedGRPC, grpcDeps)
|
||||
|
||||
applicationComponents := []app.Component{
|
||||
restServer,
|
||||
grpcServer,
|
||||
}
|
||||
if adminServer := adminapi.NewServer(cfg.AdminHTTP, telemetryRuntime.Handler(), logger); adminServer.Enabled() {
|
||||
applicationComponents = append(applicationComponents, adminServer)
|
||||
}
|
||||
applicationComponents = append(applicationComponents, components...)
|
||||
|
||||
logger.Info("gateway application starting",
|
||||
zap.String("public_http_addr", cfg.PublicHTTP.Addr),
|
||||
zap.String("authenticated_grpc_addr", cfg.AuthenticatedGRPC.Addr),
|
||||
zap.String("admin_http_addr", cfg.AdminHTTP.Addr),
|
||||
)
|
||||
|
||||
application := app.New(cfg, applicationComponents...)
|
||||
|
||||
err = application.Run(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func newAuthenticatedGRPCDependencies(ctx context.Context, cfg config.Config, logger *zap.Logger, telemetryRuntime *telemetry.Runtime) (grpcapi.ServerDependencies, []app.Component, func() error, error) {
|
||||
responseSigner, err := authn.LoadEd25519ResponseSignerFromPEMFile(cfg.ResponseSigner.PrivateKeyPEMPath)
|
||||
if err != nil {
|
||||
return grpcapi.ServerDependencies{}, nil, nil, fmt.Errorf("build authenticated grpc dependencies: load response signer: %w", err)
|
||||
}
|
||||
|
||||
fallbackSessionCache, err := session.NewRedisCache(cfg.SessionCacheRedis)
|
||||
if err != nil {
|
||||
return grpcapi.ServerDependencies{}, nil, nil, fmt.Errorf("build authenticated grpc dependencies: %w", err)
|
||||
}
|
||||
|
||||
replayStore, err := replay.NewRedisStore(cfg.SessionCacheRedis, cfg.ReplayRedis)
|
||||
if err != nil {
|
||||
closeErr := fallbackSessionCache.Close()
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
localSessionCache := session.NewMemoryCache()
|
||||
sessionCache, err := session.NewReadThroughCache(localSessionCache, fallbackSessionCache)
|
||||
if err != nil {
|
||||
closeErr := errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
)
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
pushHub := push.NewHubWithObserver(0, telemetry.NewPushObserver(telemetryRuntime))
|
||||
sessionSubscriber, err := events.NewRedisSessionSubscriberWithObservability(cfg.SessionCacheRedis, cfg.SessionEventsRedis, localSessionCache, pushHub, logger, telemetryRuntime)
|
||||
if err != nil {
|
||||
closeErr := errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
)
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
clientEventSubscriber, err := events.NewRedisClientEventSubscriberWithObservability(cfg.SessionCacheRedis, cfg.ClientEventsRedis, pushHub, logger, telemetryRuntime)
|
||||
if err != nil {
|
||||
closeErr := errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
sessionSubscriber.Close(),
|
||||
)
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
cleanup := func() error {
|
||||
return errors.Join(
|
||||
fallbackSessionCache.Close(),
|
||||
replayStore.Close(),
|
||||
sessionSubscriber.Close(),
|
||||
clientEventSubscriber.Close(),
|
||||
)
|
||||
}
|
||||
|
||||
if err := fallbackSessionCache.Ping(ctx); err != nil {
|
||||
closeErr := cleanup()
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
if err := replayStore.Ping(ctx); err != nil {
|
||||
closeErr := cleanup()
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
if err := sessionSubscriber.Ping(ctx); err != nil {
|
||||
closeErr := cleanup()
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
if err := clientEventSubscriber.Ping(ctx); err != nil {
|
||||
closeErr := cleanup()
|
||||
return grpcapi.ServerDependencies{}, nil, nil, errors.Join(
|
||||
fmt.Errorf("build authenticated grpc dependencies: %w", err),
|
||||
closeErr,
|
||||
)
|
||||
}
|
||||
|
||||
return grpcapi.ServerDependencies{
|
||||
Service: grpcapi.NewFanOutPushStreamService(pushHub, responseSigner, nil, logger),
|
||||
Router: downstream.NewStaticRouter(nil),
|
||||
ResponseSigner: responseSigner,
|
||||
SessionCache: sessionCache,
|
||||
ReplayStore: replayStore,
|
||||
Logger: logger,
|
||||
Telemetry: telemetryRuntime,
|
||||
PushHub: pushHub,
|
||||
}, []app.Component{sessionSubscriber, clientEventSubscriber}, cleanup, nil
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/gateway/internal/config"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestNewAuthenticatedGRPCDependencies(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
server := miniredis.RunT(t)
|
||||
responseSignerPEMPath := writeTestResponseSignerPEMFile(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg config.Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
Addr: server.Addr(),
|
||||
KeyPrefix: "gateway:session:",
|
||||
LookupTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
KeyPrefix: "gateway:replay:",
|
||||
ReserveTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ResponseSigner: config.ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: responseSignerPEMPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid redis config",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
LookupTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
KeyPrefix: "gateway:replay:",
|
||||
ReserveTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ResponseSigner: config.ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: responseSignerPEMPath,
|
||||
},
|
||||
},
|
||||
wantErr: "redis addr must not be empty",
|
||||
},
|
||||
{
|
||||
name: "startup ping failure",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
Addr: unusedTCPAddr(t),
|
||||
KeyPrefix: "gateway:session:",
|
||||
LookupTimeout: 100 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
KeyPrefix: "gateway:replay:",
|
||||
ReserveTimeout: 100 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ResponseSigner: config.ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: responseSignerPEMPath,
|
||||
},
|
||||
},
|
||||
wantErr: "ping redis session cache",
|
||||
},
|
||||
{
|
||||
name: "invalid replay config",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
Addr: server.Addr(),
|
||||
KeyPrefix: "gateway:session:",
|
||||
LookupTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
ReserveTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ResponseSigner: config.ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: responseSignerPEMPath,
|
||||
},
|
||||
},
|
||||
wantErr: "replay key prefix must not be empty",
|
||||
},
|
||||
{
|
||||
name: "invalid client event config",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
Addr: server.Addr(),
|
||||
KeyPrefix: "gateway:session:",
|
||||
LookupTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
KeyPrefix: "gateway:replay:",
|
||||
ReserveTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ResponseSigner: config.ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: responseSignerPEMPath,
|
||||
},
|
||||
},
|
||||
wantErr: "client event subscriber: stream must not be empty",
|
||||
},
|
||||
{
|
||||
name: "missing response signer path",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
Addr: server.Addr(),
|
||||
KeyPrefix: "gateway:session:",
|
||||
LookupTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
KeyPrefix: "gateway:replay:",
|
||||
ReserveTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
},
|
||||
wantErr: "load response signer",
|
||||
},
|
||||
{
|
||||
name: "invalid response signer pem",
|
||||
cfg: config.Config{
|
||||
SessionCacheRedis: config.SessionCacheRedisConfig{
|
||||
Addr: server.Addr(),
|
||||
KeyPrefix: "gateway:session:",
|
||||
LookupTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
ReplayRedis: config.ReplayRedisConfig{
|
||||
KeyPrefix: "gateway:replay:",
|
||||
ReserveTimeout: 250 * time.Millisecond,
|
||||
},
|
||||
SessionEventsRedis: config.SessionEventsRedisConfig{
|
||||
Stream: "gateway:session_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ClientEventsRedis: config.ClientEventsRedisConfig{
|
||||
Stream: "gateway:client_events",
|
||||
ReadBlockTimeout: time.Second,
|
||||
},
|
||||
ResponseSigner: config.ResponseSignerConfig{
|
||||
PrivateKeyPEMPath: writeInvalidPEMFile(t),
|
||||
},
|
||||
},
|
||||
wantErr: "response signer private key",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
deps, components, cleanup, err := newAuthenticatedGRPCDependencies(context.Background(), tt.cfg, zap.NewNop(), nil)
|
||||
if tt.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, deps.SessionCache)
|
||||
require.NotNil(t, deps.ReplayStore)
|
||||
require.NotNil(t, deps.ResponseSigner)
|
||||
require.NotNil(t, deps.Router)
|
||||
require.NotNil(t, deps.Service)
|
||||
require.Len(t, components, 2)
|
||||
require.NotNil(t, cleanup)
|
||||
assert.NoError(t, cleanup())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func unusedTCPAddr(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
addr := listener.Addr().String()
|
||||
require.NoError(t, listener.Close())
|
||||
|
||||
return addr
|
||||
}
|
||||
|
||||
func writeTestResponseSignerPEMFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
seed := sha256.Sum256([]byte("gateway-main-test-response-signer"))
|
||||
privateKey := ed25519.NewKeyFromSeed(seed[:])
|
||||
|
||||
encodedPrivateKey, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
path := filepath.Join(t.TempDir(), "response-signer.pem")
|
||||
err = os.WriteFile(path, pem.EncodeToMemory(&pem.Block{
|
||||
Type: "PRIVATE KEY",
|
||||
Bytes: encodedPrivateKey,
|
||||
}), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func writeInvalidPEMFile(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "invalid-response-signer.pem")
|
||||
err := os.WriteFile(path, []byte("not a valid pem"), 0o600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user