feat: tunnel JWT auth
Tunnel method now requires Bearer JWT (HMAC-HS256) in gRPC metadata.
- internal/auth: pure-stdlib verifier + issuer + revoked-jti list
- internal/tunnel: per-target rate limiter + egress allowlist with
wildcard support + crypto/rand frame padding to {1KB, 4KB, 16KB}
- cmd/server reads /etc/stats-gateway/{jwt.key,tunnel.yaml,revoked.txt}
via --config-dir flag
- cmd/issue-token Go binary wraps auth.Issue
- configs/tunnel.yaml example with projectshitpost wildcard
Tests: 13 PASS across 5 packages.
This commit is contained in:
@@ -2,23 +2,30 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||
"github.com/bergabruh/stats-gateway/internal/auth"
|
||||
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||
"github.com/bergabruh/stats-gateway/internal/tunnel"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
// Service implements statsv1.SpeedStatusServer with public RecentResults
|
||||
// and Aggregate methods. Tunnel returns Unimplemented until Task 11 wires
|
||||
// auth + handler.
|
||||
// Service implements statsv1.SpeedStatusServer.
|
||||
type Service struct {
|
||||
statsv1.UnimplementedSpeedStatusServer
|
||||
store *storage.Store
|
||||
store *storage.Store
|
||||
verifier *auth.Verifier
|
||||
tunnel *tunnel.Handler
|
||||
}
|
||||
|
||||
func New(st *storage.Store) *Service { return &Service{store: st} }
|
||||
// New creates a Service with the given storage, JWT verifier, and tunnel handler.
|
||||
func New(st *storage.Store, v *auth.Verifier, t *tunnel.Handler) *Service {
|
||||
return &Service{store: st, verifier: v, tunnel: t}
|
||||
}
|
||||
|
||||
func (s *Service) RecentResults(f *statsv1.Filter, stream statsv1.SpeedStatus_RecentResultsServer) error {
|
||||
limit := int(f.Limit)
|
||||
@@ -87,3 +94,23 @@ func (s *Service) Aggregate(ctx context.Context, r *statsv1.Range) (*statsv1.His
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// Tunnel authenticates the caller via Bearer JWT then delegates to the tunnel handler.
|
||||
func (s *Service) Tunnel(stream statsv1.SpeedStatus_TunnelServer) error {
|
||||
md, ok := metadata.FromIncomingContext(stream.Context())
|
||||
if !ok {
|
||||
return status.Error(codes.Unauthenticated, "no metadata")
|
||||
}
|
||||
authH := md.Get("authorization")
|
||||
if len(authH) == 0 {
|
||||
return status.Error(codes.Unauthenticated, "no auth")
|
||||
}
|
||||
tok := strings.TrimPrefix(authH[0], "Bearer ")
|
||||
if tok == authH[0] {
|
||||
return status.Error(codes.Unauthenticated, "expected Bearer")
|
||||
}
|
||||
if _, err := s.verifier.Verify(tok); err != nil {
|
||||
return status.Errorf(codes.Unauthenticated, "auth: %v", err)
|
||||
}
|
||||
return s.tunnel.Serve(stream)
|
||||
}
|
||||
|
||||
@@ -8,19 +8,27 @@ import (
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
|
||||
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||
"github.com/bergabruh/stats-gateway/internal/auth"
|
||||
"github.com/bergabruh/stats-gateway/internal/probes"
|
||||
"github.com/bergabruh/stats-gateway/internal/storage"
|
||||
"github.com/bergabruh/stats-gateway/internal/tunnel"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, st *storage.Store) (statsv1.SpeedStatusClient, func()) {
|
||||
t.Helper()
|
||||
verifier := auth.NewVerifier([]byte("test-secret"), nil)
|
||||
handler := tunnel.NewHandler(tunnel.EgressConfig{Allowed: []string{"127.0.0.1:0"}, RateMbps: 50})
|
||||
|
||||
lis := bufconn.Listen(1024 * 1024)
|
||||
srv := grpc.NewServer()
|
||||
statsv1.RegisterSpeedStatusServer(srv, New(st))
|
||||
statsv1.RegisterSpeedStatusServer(srv, New(st, verifier, handler))
|
||||
go srv.Serve(lis)
|
||||
|
||||
conn, err := grpc.NewClient("passthrough:///bufnet",
|
||||
@@ -104,7 +112,7 @@ func TestAggregate_BucketCounts(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnel_Unimplemented(t *testing.T) {
|
||||
func TestTunnel_RejectsMissingAuth(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := storage.Open(filepath.Join(dir, "test.db"))
|
||||
defer st.Close()
|
||||
@@ -118,7 +126,33 @@ func TestTunnel_Unimplemented(t *testing.T) {
|
||||
stream.Send(&statsv1.Frame{Kind: statsv1.Frame_HELLO, Target: "1.2.3.4:80"})
|
||||
_, err = stream.Recv()
|
||||
if err == nil {
|
||||
t.Fatal("expected Unimplemented error")
|
||||
t.Fatal("expected unauthenticated")
|
||||
}
|
||||
st_, ok := status.FromError(err)
|
||||
if !ok || st_.Code() != codes.Unauthenticated {
|
||||
t.Errorf("expected Unauthenticated, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTunnel_RejectsBadToken(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, _ := storage.Open(filepath.Join(dir, "test.db"))
|
||||
defer st.Close()
|
||||
client, cleanup := newTestServer(t, st)
|
||||
defer cleanup()
|
||||
|
||||
ctx := metadata.AppendToOutgoingContext(context.Background(), "authorization", "Bearer not.a.real.token")
|
||||
stream, err := client.Tunnel(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stream.Send(&statsv1.Frame{Kind: statsv1.Frame_HELLO, Target: "1.2.3.4:80"})
|
||||
_, err = stream.Recv()
|
||||
if err == nil {
|
||||
t.Fatal("expected auth fail")
|
||||
}
|
||||
st_, ok := status.FromError(err)
|
||||
if !ok || st_.Code() != codes.Unauthenticated {
|
||||
t.Errorf("expected Unauthenticated, got %v", err)
|
||||
}
|
||||
// Just check it's an error; the exact code/message will be checked indirectly when Task 11 wires real impl.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user