feat: edge gateway service

This commit is contained in:
Ilia Denisov
2026-04-02 19:18:42 +02:00
committed by GitHub
parent 8cde99936c
commit 436c97a38b
95 changed files with 20504 additions and 57 deletions
+209
View File
@@ -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
}
+275
View File
@@ -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
}