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:
@@ -0,0 +1,154 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Claims holds the JWT payload fields used by this service.
|
||||
type Claims struct {
|
||||
Subject string `json:"sub"`
|
||||
Scope string `json:"scope"`
|
||||
IssuedAt int64 `json:"iat"`
|
||||
Expires int64 `json:"exp"`
|
||||
JTI string `json:"jti"`
|
||||
}
|
||||
|
||||
// Verifier validates HMAC-HS256 tokens against a secret and optional revocation list.
|
||||
type Verifier struct {
|
||||
secret []byte
|
||||
revoked map[string]bool
|
||||
}
|
||||
|
||||
// NewVerifier creates a Verifier. If revoked is nil an empty map is used.
|
||||
func NewVerifier(secret []byte, revoked map[string]bool) *Verifier {
|
||||
if revoked == nil {
|
||||
revoked = map[string]bool{}
|
||||
}
|
||||
return &Verifier{secret: secret, revoked: revoked}
|
||||
}
|
||||
|
||||
// Verify checks signature, expiry, scope=="tunnel", and revocation list.
|
||||
// Returns parsed Claims on success.
|
||||
func (v *Verifier) Verify(token string) (*Claims, error) {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, errors.New("malformed token")
|
||||
}
|
||||
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
expectedSig, err := base64URLDecode(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode sig: %w", err)
|
||||
}
|
||||
mac := hmac.New(sha256.New, v.secret)
|
||||
mac.Write([]byte(signingInput))
|
||||
if !hmac.Equal(mac.Sum(nil), expectedSig) {
|
||||
return nil, errors.New("bad signature")
|
||||
}
|
||||
|
||||
headerJSON, err := base64URLDecode(parts[0])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode header: %w", err)
|
||||
}
|
||||
var header struct {
|
||||
Alg string `json:"alg"`
|
||||
Typ string `json:"typ"`
|
||||
}
|
||||
if err := json.Unmarshal(headerJSON, &header); err != nil {
|
||||
return nil, fmt.Errorf("header: %w", err)
|
||||
}
|
||||
if header.Alg != "HS256" {
|
||||
return nil, fmt.Errorf("alg %q not allowed", header.Alg)
|
||||
}
|
||||
|
||||
payload, err := base64URLDecode(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
var c Claims
|
||||
if err := json.Unmarshal(payload, &c); err != nil {
|
||||
return nil, fmt.Errorf("payload: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
if c.Expires < now {
|
||||
return nil, errors.New("token expired")
|
||||
}
|
||||
if c.Scope != "tunnel" {
|
||||
return nil, fmt.Errorf("scope %q not allowed", c.Scope)
|
||||
}
|
||||
if v.revoked[c.JTI] {
|
||||
return nil, errors.New("token revoked")
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// Issue creates a new HS256 token. ttl <= 0 produces an already-expired token (useful for tests).
|
||||
func Issue(secret []byte, subject, scope string, ttl time.Duration) (string, error) {
|
||||
jtiBytes := make([]byte, 8)
|
||||
if _, err := rand.Read(jtiBytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return IssueWithJTI(secret, subject, scope, ttl, fmt.Sprintf("%x", jtiBytes))
|
||||
}
|
||||
|
||||
// IssueWithJTI creates a token with a caller-supplied jti (useful for revocation tests).
|
||||
func IssueWithJTI(secret []byte, subject, scope string, ttl time.Duration, jti string) (string, error) {
|
||||
now := time.Now().Unix()
|
||||
c := Claims{
|
||||
Subject: subject,
|
||||
Scope: scope,
|
||||
IssuedAt: now,
|
||||
Expires: now + int64(ttl.Seconds()),
|
||||
JTI: jti,
|
||||
}
|
||||
header := `{"alg":"HS256","typ":"JWT"}`
|
||||
payload, err := json.Marshal(c)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
h := base64URLEncode([]byte(header))
|
||||
p := base64URLEncode(payload)
|
||||
signingInput := h + "." + p
|
||||
mac := hmac.New(sha256.New, secret)
|
||||
mac.Write([]byte(signingInput))
|
||||
sig := base64URLEncode(mac.Sum(nil))
|
||||
return signingInput + "." + sig, nil
|
||||
}
|
||||
|
||||
// LoadRevokedFile reads one jti per line from path. Lines starting with '#'
|
||||
// or empty are ignored. Missing file → empty map (no error).
|
||||
func LoadRevokedFile(path string) (map[string]bool, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
out := map[string]bool{}
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
out[line] = true
|
||||
}
|
||||
return out, sc.Err()
|
||||
}
|
||||
|
||||
func base64URLEncode(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
func base64URLDecode(s string) ([]byte, error) { return base64.RawURLEncoding.DecodeString(s) }
|
||||
@@ -0,0 +1,75 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestVerify_ValidToken(t *testing.T) {
|
||||
v := NewVerifier([]byte("test-secret"), nil)
|
||||
tok, _ := Issue([]byte("test-secret"), "alice", "tunnel", time.Hour)
|
||||
claims, err := v.Verify(tok)
|
||||
if err != nil {
|
||||
t.Fatalf("expected ok, got %v", err)
|
||||
}
|
||||
if claims.Subject != "alice" || claims.Scope != "tunnel" {
|
||||
t.Errorf("claims wrong: %+v", claims)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WrongSecret(t *testing.T) {
|
||||
v := NewVerifier([]byte("server-secret"), nil)
|
||||
tok, _ := Issue([]byte("attacker-secret"), "eve", "tunnel", time.Hour)
|
||||
if _, err := v.Verify(tok); err == nil {
|
||||
t.Fatal("expected verification fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_Expired(t *testing.T) {
|
||||
v := NewVerifier([]byte("test-secret"), nil)
|
||||
tok, _ := Issue([]byte("test-secret"), "alice", "tunnel", -time.Minute)
|
||||
if _, err := v.Verify(tok); err == nil {
|
||||
t.Fatal("expected expired")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_WrongScope(t *testing.T) {
|
||||
v := NewVerifier([]byte("test-secret"), nil)
|
||||
tok, _ := Issue([]byte("test-secret"), "alice", "read-only", time.Hour)
|
||||
if _, err := v.Verify(tok); err == nil {
|
||||
t.Fatal("expected scope mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_RevokedJTI(t *testing.T) {
|
||||
revoked := map[string]bool{"abc123": true}
|
||||
v := NewVerifier([]byte("test-secret"), revoked)
|
||||
tok, _ := IssueWithJTI([]byte("test-secret"), "alice", "tunnel", time.Hour, "abc123")
|
||||
if _, err := v.Verify(tok); err == nil {
|
||||
t.Fatal("expected revoked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRevokedFile_Missing(t *testing.T) {
|
||||
m, err := LoadRevokedFile(filepath.Join(t.TempDir(), "nonexistent"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Errorf("expected empty, got %v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRevokedFile_ParsesEntries(t *testing.T) {
|
||||
tmp := filepath.Join(t.TempDir(), "r.txt")
|
||||
os.WriteFile(tmp, []byte("# comment\nabc\n\nxyz\n"), 0644)
|
||||
m, err := LoadRevokedFile(tmp)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !m["abc"] || !m["xyz"] || len(m) != 2 {
|
||||
t.Errorf("got %v", m)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package tunnel
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
statsv1 "github.com/bergabruh/stats-gateway/gen/stats/v1"
|
||||
)
|
||||
|
||||
// EgressConfig defines which targets are allowed and the per-target rate limit.
|
||||
type EgressConfig struct {
|
||||
Allowed []string `yaml:"allowed"`
|
||||
RateMbps float64 `yaml:"rate_mbps"`
|
||||
}
|
||||
|
||||
// Allow returns true if target ("host:port") matches any entry in Allowed.
|
||||
// Wildcards: "*.suffix:port" matches "anything.suffix:port" but NOT bare "suffix:port".
|
||||
func (e *EgressConfig) Allow(target string) bool {
|
||||
for _, p := range e.Allowed {
|
||||
if p == target {
|
||||
return true
|
||||
}
|
||||
if strings.HasPrefix(p, "*.") {
|
||||
suffix := p[1:] // e.g. ".projectshitpost.fun:443"
|
||||
if strings.HasSuffix(target, suffix) && len(target) > len(suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Handler manages per-target rate limiters and dispatches tunnel sessions.
|
||||
type Handler struct {
|
||||
cfg EgressConfig
|
||||
mu sync.Mutex
|
||||
limiters map[string]*rate.Limiter
|
||||
}
|
||||
|
||||
// NewHandler creates a Handler with the given egress configuration.
|
||||
func NewHandler(cfg EgressConfig) *Handler {
|
||||
return &Handler{cfg: cfg, limiters: map[string]*rate.Limiter{}}
|
||||
}
|
||||
|
||||
func (h *Handler) limiter(target string) *rate.Limiter {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if l, ok := h.limiters[target]; ok {
|
||||
return l
|
||||
}
|
||||
bps := h.cfg.RateMbps * 1024 * 1024 / 8
|
||||
if bps <= 0 {
|
||||
bps = 50 * 1024 * 1024 / 8 // default 50 Mbps
|
||||
}
|
||||
l := rate.NewLimiter(rate.Limit(bps), int(bps))
|
||||
h.limiters[target] = l
|
||||
return l
|
||||
}
|
||||
|
||||
// Serve runs the bidirectional tunnel after auth has already been validated.
|
||||
// First frame must be HELLO with target "host:port" within Allow().
|
||||
func (h *Handler) Serve(stream statsv1.SpeedStatus_TunnelServer) error {
|
||||
first, err := stream.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("recv hello: %w", err)
|
||||
}
|
||||
if first.Kind != statsv1.Frame_HELLO {
|
||||
return errors.New("expected HELLO")
|
||||
}
|
||||
if !h.cfg.Allow(first.Target) {
|
||||
return fmt.Errorf("egress denied: %s", first.Target)
|
||||
}
|
||||
|
||||
conn, err := net.DialTimeout("tcp", first.Target, 10*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", first.Target, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
lim := h.limiter(first.Target)
|
||||
errCh := make(chan error, 2)
|
||||
|
||||
// stream → conn
|
||||
go func() {
|
||||
for {
|
||||
f, err := stream.Recv()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if f.Kind == statsv1.Frame_CLOSE {
|
||||
errCh <- nil
|
||||
return
|
||||
}
|
||||
if len(f.Payload) > 0 {
|
||||
if err := lim.WaitN(stream.Context(), len(f.Payload)); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
if _, err := conn.Write(f.Payload); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// conn → stream
|
||||
go func() {
|
||||
buf := make([]byte, 16*1024)
|
||||
for {
|
||||
n, err := conn.Read(buf)
|
||||
if n > 0 {
|
||||
if err := lim.WaitN(stream.Context(), n); err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if sErr := stream.Send(&statsv1.Frame{
|
||||
Kind: statsv1.Frame_DATA,
|
||||
Payload: append([]byte{}, buf[:n]...),
|
||||
Padding: paddingForSize(n),
|
||||
}); sErr != nil {
|
||||
errCh <- sErr
|
||||
return
|
||||
}
|
||||
}
|
||||
if err == io.EOF {
|
||||
errCh <- nil
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
// paddingForSize rounds up to the nearest bucket of {1024, 4096, 16384} bytes
|
||||
// and fills the remainder with crypto/rand bytes. This blurs the packet-length
|
||||
// distribution for DPI/ML classifiers.
|
||||
func paddingForSize(n int) []byte {
|
||||
target := 16384
|
||||
switch {
|
||||
case n <= 1024:
|
||||
target = 1024
|
||||
case n <= 4096:
|
||||
target = 4096
|
||||
}
|
||||
pad := target - n
|
||||
if pad <= 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]byte, pad)
|
||||
_, _ = rand.Read(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package tunnel
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllowEgress(t *testing.T) {
|
||||
cfg := EgressConfig{Allowed: []string{"192.0.2.10:8443", "*.projectshitpost.fun:443"}}
|
||||
cases := []struct {
|
||||
target string
|
||||
want bool
|
||||
}{
|
||||
{"192.0.2.10:8443", true},
|
||||
{"foo.projectshitpost.fun:443", true},
|
||||
{"projectshitpost.fun:443", false}, // wildcard "*.x" doesn't match bare "x"
|
||||
{"8.8.8.8:53", false},
|
||||
{"192.0.2.10:22", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := cfg.Allow(tc.target); got != tc.want {
|
||||
t.Errorf("Allow(%q) = %v, want %v", tc.target, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user