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:
2026-05-05 21:28:37 +05:00
parent 0e3a3b4b0e
commit 731b60863a
12 changed files with 571 additions and 11 deletions
+38 -4
View File
@@ -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.
}