Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,21 @@ var rootCmd = &cobra.Command{
if err != nil {
return fmt.Errorf("failed to generate certificate: %w", err)
}
newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls)
var signingKey *ecdsa.PublicKey
if len(c.SigningPublicKey) > 0 {
block, _ := pem.Decode(c.SigningPublicKey)
if block == nil {
logger.Warn("received signing public key but PEM decode failed")
} else if pub, err := x509.ParsePKIXPublicKey(block.Bytes); err != nil {
logger.Warn("received signing public key but PKIX parse failed: %v", err)
} else if ecKey, ok := pub.(*ecdsa.PublicKey); !ok {
logger.Warn("received signing public key but unexpected type: %T", pub)
} else {
signingKey = ecKey
logger.Info("using upstream signing key for proxy verification")
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
newServer, err := stack.StartServer(ctx, logger, tlsConfig, urls, agent, signingKey)
if err != nil {
return fmt.Errorf("failed to start server: %w", err)
}
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/agentuity/gravity
go 1.26.1

require (
github.com/agentuity/go-common v1.0.165
github.com/agentuity/go-common v1.0.181
github.com/spf13/cobra v1.10.1
gvisor.dev/gvisor v0.0.0-20240423190808-9d7a357edefe
)
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/agentuity/go-common v1.0.165 h1:hxaGFRg05/ir4OqoIpCQHhgGhs9VKeR564EsLhEN8Gc=
github.com/agentuity/go-common v1.0.165/go.mod h1:uW1IsiE9ydoK6HRwr8jgEE8wVXSXoFzhm/AJ8Q4xlos=
github.com/agentuity/go-common v1.0.181 h1:+mJSQhZdPj++ZxSyIwM3BtG7GcCmLAahWlfRcfaI2Lc=
github.com/agentuity/go-common v1.0.181/go.mod h1:YuiBVsz9WZ5K1vW2cjHvjUnuA65t7YZTHj4Nq11b0UQ=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cockroachdb/errors v1.12.0 h1:d7oCs6vuIMUQRVbi6jWWWEJZahLCfJpnJSVobd1/sUo=
Expand Down
112 changes: 87 additions & 25 deletions internal/stack/stack.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package stack

import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/tls"
Expand All @@ -17,6 +18,7 @@ import (
"sync"
"time"

agcrypto "github.com/agentuity/go-common/crypto"
"github.com/agentuity/go-common/gravity"
"github.com/agentuity/go-common/gravity/proto"
"github.com/agentuity/go-common/gravity/provider"
Expand Down Expand Up @@ -131,7 +133,7 @@ func GenerateCertificate(_ context.Context, logger _logger.Logger, bundle string
return tlsConfig, nil
}

func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Config, urls UrlsMetadata) (*http.Server, error) {
func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Config, urls UrlsMetadata, agent AgentMetadata, signingKey *ecdsa.PublicKey) (*http.Server, error) {

// Set up reverse proxy to the agent server
agentURL := fmt.Sprintf("http://127.0.0.1:%d", urls.LocalPort)
Expand All @@ -140,31 +142,36 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf
return nil, fmt.Errorf("failed to parse agent URL: %w", err)
}

proxy := httputil.NewSingleHostReverseProxy(upstreamURL)
// Override the Director to restore the original public hostname from
// X-Forwarded-Host. Without this, the Host header may contain internal
// routing names (e.g., "*.agentuity-us.live.internal") that leak through
// to the local dev server. Vite and other dev servers check the Host
// header against their allowedHosts list and reject unrecognized hostnames.
defaultDirector := proxy.Director
proxy.Director = func(req *http.Request) {
defaultDirector(req)
if fwdHost := req.Header.Get("X-Forwarded-Host"); fwdHost != "" {
req.Host = fwdHost
}
}
proxy.FlushInterval = -1
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
// Suppress expected context cancellation errors (client disconnect, WebSocket close)
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) {
return
}
logger.Error("proxy error: %v", err)
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
proxy := &httputil.ReverseProxy{
// Use Rewrite instead of the deprecated Director. Restore the original
// public hostname from X-Forwarded-Host so that the Host header sent
// to the local dev server matches the public URL. Vite and other dev
// servers check the Host header against their allowedHosts list and
// reject unrecognized hostnames.
Rewrite: func(r *httputil.ProxyRequest) {
r.SetURL(upstreamURL)
if fwdHost := r.In.Header.Get("X-Forwarded-Host"); fwdHost != "" {
r.Out.Host = fwdHost
}
},
FlushInterval: -1,
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
// Suppress expected context cancellation errors (client disconnect, WebSocket close)
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(r.Context().Err(), context.Canceled) {
return
}
logger.Error("proxy error: %v", err)
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
},
ModifyResponse: func(resp *http.Response) error {
logger.Trace("response %s: %d", resp.Request.URL.Path, resp.StatusCode)
return nil
},
}
proxy.ModifyResponse = func(resp *http.Response) error {
logger.Trace("response %s: %d", resp.Request.URL.Path, resp.StatusCode)
return nil

// Log once if signing key is missing (avoid flooding logs on every request).
if signingKey == nil && agent.PrivateKey != nil {
logger.Warn("no upstream signing key available, skipping signature verification")
}

server := &http.Server{
Expand All @@ -186,6 +193,7 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf
return
default:
}

proxy.ServeHTTP(w, r)
}),
}
Expand Down Expand Up @@ -218,6 +226,60 @@ func StartServer(ctx context.Context, logger _logger.Logger, tlsConfig *tls.Conf
return server, serverErr
}

// verifyUpstreamSignature checks that the incoming request was signed by the
// Agentuity ion proxy. It reads the body so that HTTP trailers become
// available, verifies the signature, and restores the body and Content-Length
// for downstream proxying.
func verifyUpstreamSignature(logger _logger.Logger, publicKey *ecdsa.PublicKey, r *http.Request) error {
alg := r.Header.Get(agcrypto.HeaderSignatureAlg)
keyID := r.Header.Get(agcrypto.HeaderSignatureKeyID)
timestamp := r.Header.Get(agcrypto.HeaderSignatureTimestamp)
nonce := r.Header.Get(agcrypto.HeaderSignatureNonce)

logger.Debug("upstream signature: alg=%s keyid=%s timestamp=%s nonce=%s via=%s",
alg, keyID, timestamp, nonce, r.Header.Get("Via"))

if alg == "" {
return fmt.Errorf("no signature headers present")
}

// Read the full body so HTTP trailers become available. Save the
// original Content-Length so we can restore it after verification —
// the reverse proxy needs it to avoid chunked encoding to localhost.
origContentLength := r.ContentLength
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}

// The ion proxy sends the Signature as an HTTP trailer (for streaming
// requests) or as a header (for WebSocket requests).
sig := r.Trailer.Get(agcrypto.HeaderSignature)
source := "trailer"
if sig == "" {
sig = r.Header.Get(agcrypto.HeaderSignature)
source = "header"
if sig == "" {
source = "missing"
}
}
logger.Debug("upstream signature value: source=%s present=%v", source, sig != "")

// Verify the cryptographic signature against the upstream signing key.
verifyErr := agcrypto.VerifyHTTPRequest(publicKey, r, body, nil)
if verifyErr != nil {
logger.Debug("upstream signature verification failed: %v", verifyErr)
} else {
logger.Debug("upstream signature verification succeeded")
}

// Restore the body and Content-Length so the reverse proxy can forward it.
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = origContentLength
Comment on lines +246 to +278
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd internal/stack && sed -n '240,285p' stack.go

Repository: agentuity/gravity

Length of output: 1568


🏁 Script executed:

# Let's also check the full function context
cd internal/stack && wc -l stack.go

Repository: agentuity/gravity

Length of output: 73


🏁 Script executed:

# Find the function name and broader context
cd internal/stack && grep -n "verifyUpstreamSignature\|func.*signature" stack.go | head -20

Repository: agentuity/gravity

Length of output: 251


🏁 Script executed:

# Check if there are tests or related usage
rg -n "verifyUpstreamSignature|origContentLength" internal/

Repository: agentuity/gravity

Length of output: 430


🏁 Script executed:

# Find where verifyUpstreamSignature is called
rg -n "verifyUpstreamSignature" internal/

Repository: agentuity/gravity

Length of output: 299


🏁 Script executed:

# Check for HTTP/2 references in the codebase
rg -n "HTTP/2|h2|:authority|pseudo-header" internal/stack/ | head -20

Repository: agentuity/gravity

Length of output: 136


🏁 Script executed:

# Look at the function signature and its callers more broadly
cd internal/stack && sed -n '229,285p' stack.go

Repository: agentuity/gravity

Length of output: 2232


🏁 Script executed:

# Check if TransferEncoding is manipulated anywhere in this file
rg -n "TransferEncoding" internal/stack/

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Look for calls to verifyUpstreamSignature
rg -n "verifyUpstreamSignature\(" internal/

Repository: agentuity/gravity

Length of output: 193


🏁 Script executed:

# Check the broader stack.go file to see request handling flow
cd internal/stack && sed -n '1,50p' stack.go

Repository: agentuity/gravity

Length of output: 1039


🏁 Script executed:

# Search for how requests flow through this code
rg -n "func.*Request\|http\.Request" internal/stack/stack.go | head -10

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Search all files for calls to verifyUpstreamSignature
rg "verifyUpstreamSignature" --type go

Repository: agentuity/gravity

Length of output: 291


🏁 Script executed:

# Look for HTTP handler or middleware that might use this
rg -n "ServeHTTP\|Handler\|Middleware" internal/stack/stack.go | head -20

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Check if there's any proxy configuration or request handling
rg -n "ReverseProxy\|Director\|Transport" internal/stack/stack.go | head -20

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Search all go files in the repo for calls
rg "verifyUpstreamSignature\(" . --type go

Repository: agentuity/gravity

Length of output: 191


🏁 Script executed:

# Check if the function is dead code or commented out
rg -B5 -A5 "verifyUpstreamSignature\(" internal/stack/stack.go

Repository: agentuity/gravity

Length of output: 643


🏁 Script executed:

# Look for HTTP/2 streaming or trailer handling
rg -n "Trailer\|trailer" internal/stack/stack.go

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Check the context after the function - what calls it or how it integrates
cd internal/stack && sed -n '278,350p' stack.go

Repository: agentuity/gravity

Length of output: 2080


🏁 Script executed:

# Search more broadly - maybe it's commented out or in a different pattern
rg "verifyUpstream" . --type go

Repository: agentuity/gravity

Length of output: 295


🏁 Script executed:

# Check if there are any TODOs or comments about enabling this
rg -n "TODO\|FIXME\|disabled\|commented" internal/stack/stack.go | head -20

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Look at the imports to understand the context better
cd internal/stack && head -40 stack.go

Repository: agentuity/gravity

Length of output: 858


🏁 Script executed:

# Check if there are other signature verification patterns
rg -n "VerifyHTTP\|signature" internal/stack/stack.go

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Search for any references or comments about this being disabled/unused
rg -B10 "func verifyUpstreamSignature" internal/stack/stack.go

Repository: agentuity/gravity

Length of output: 464


🏁 Script executed:

# Check if there's a git history that might explain the function
git log --oneline -n 50 -- internal/stack/stack.go 2>/dev/null | head -20

Repository: agentuity/gravity

Length of output: 114


🏁 Script executed:

# Look for any test files that might reference this function
find . -name "*test*.go" -type f | xargs grep -l "verifyUpstreamSignature" 2>/dev/null

Repository: agentuity/gravity

Length of output: 45


🏁 Script executed:

# Check the full file for context on where this function sits
cd internal/stack && wc -l stack.go && grep -n "^func " stack.go

Repository: agentuity/gravity

Length of output: 792


🏁 Script executed:

# Check the git commit that disabled signature verification
git show a9e526c --stat 2>/dev/null | head -30

Repository: agentuity/gravity

Length of output: 1438


🏁 Script executed:

# Look for any calls to verifyUpstreamSignature in the actual code flow
rg -n "verifyUpstream" . --type go -A 2 -B 2

Repository: agentuity/gravity

Length of output: 798


🏁 Script executed:

# Check StartServer function to see if it calls verifyUpstreamSignature
cd internal/stack && sed -n '136,232p' stack.go

Repository: agentuity/gravity

Length of output: 3681


🏁 Script executed:

# Check if there's documentation or comments about HTTP/2 handling
rg -n "HTTP/2\|streaming\|chunked\|TransferEncoding" . --type go

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Look at the agcrypto module to understand VerifyHTTPRequest
rg -n "VerifyHTTPRequest" . --type go

Repository: agentuity/gravity

Length of output: 156


🏁 Script executed:

# Check Go's net/http documentation behavior - let's verify ContentLength in HTTP/2
python3 << 'EOF'
# HTTP/2 spec: Content-Length header is optional
# In Go's net/http package, when a request body is read via io.ReadAll
# and the request came from HTTP/2, ContentLength may be -1 (unknown)
# This is well-documented in Go's http.Request struct comments

# Let's verify our understanding about the fix
print("HTTP/2 ContentLength behavior:")
print("- HTTP/2 doesn't require Content-Length header")
print("- Go's http.Request sets ContentLength = -1 when reading streamed/chunked bodies")
print("- After ReadAll(), we know the exact length, so setting ContentLength = len(body) is correct")
print("- Clearing TransferEncoding prevents chunked encoding downstream")
EOF

Repository: agentuity/gravity

Length of output: 377


🏁 Script executed:

# Check if there are any other places in the codebase that handle Content-Length restoration
rg -n "ContentLength\|Transfer-Encoding" . --type go -A 2 -B 2

Repository: agentuity/gravity

Length of output: 43


🏁 Script executed:

# Let's verify the HTTP module behavior and understand if our analysis is sound
# Check the exact behavior of http.Request.ContentLength
python3 << 'EOF'
# Based on Go's documentation:
# - http.Request.ContentLength is set to -1 for requests without Content-Length header
# - This happens with chunked encoding, streaming, or HTTP/2
# - After io.ReadAll(r.Body), we know the exact length
# - Setting ContentLength to the actual length is the correct fix
# - TransferEncoding should be cleared to prevent proxy from re-chunking

facts = [
    "http.Request has ContentLength int64 field",
    "ContentLength = -1 means 'unknown length' (chunked/streamed)",
    "http.Request.TransferEncoding []string contains encoding names",
    "After ReadAll(), body size is known, should set ContentLength = len(body)",
    "TransferEncoding should be cleared to prevent downstream chunking"
]

for fact in facts:
    print(f"✓ {fact}")
EOF

Repository: agentuity/gravity

Length of output: 377


Fix the Content-Length restoration before re-enabling signature verification.

This function is currently disabled (commit a9e526c), but when signature verification is re-enabled, the Content-Length restoration logic needs correction. For HTTP/2 requests, origContentLength is often -1. Restoring that value after ReadAll() reintroduces an unknown-length body, which defeats the normalization and allows the reverse proxy to fall back to chunked transfer encoding. Additionally, if the signature came via HTTP trailer, that must be stripped from the forwarded request.

💡 Required fix for re-enablement
-	// Read the full body so HTTP trailers become available. Save the
-	// original Content-Length so we can restore it after verification —
-	// the reverse proxy needs it to avoid chunked encoding to localhost.
-	origContentLength := r.ContentLength
+	// Read the full body so HTTP trailers become available.
 	body, err := io.ReadAll(r.Body)
 	if err != nil {
 		return fmt.Errorf("read body: %w", err)
 	}
@@
 	// Restore the body and Content-Length so the reverse proxy can forward it.
 	r.Body = io.NopCloser(bytes.NewReader(body))
-	r.ContentLength = origContentLength
+	r.ContentLength = int64(len(body))
+	r.TransferEncoding = nil
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Read the full body so HTTP trailers become available. Save the
// original Content-Length so we can restore it after verification —
// the reverse proxy needs it to avoid chunked encoding to localhost.
origContentLength := r.ContentLength
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
// The ion proxy sends the Signature as an HTTP trailer (for streaming
// requests) or as a header (for WebSocket requests).
sig := r.Trailer.Get(agcrypto.HeaderSignature)
source := "trailer"
if sig == "" {
sig = r.Header.Get(agcrypto.HeaderSignature)
source = "header"
if sig == "" {
source = "missing"
}
}
logger.Debug("upstream signature value: source=%s present=%v", source, sig != "")
// Verify the cryptographic signature against the upstream signing key.
verifyErr := agcrypto.VerifyHTTPRequest(publicKey, r, body, nil)
if verifyErr != nil {
logger.Debug("upstream signature verification failed: %v", verifyErr)
} else {
logger.Debug("upstream signature verification succeeded")
}
// Restore the body and Content-Length so the reverse proxy can forward it.
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = origContentLength
// Read the full body so HTTP trailers become available.
body, err := io.ReadAll(r.Body)
if err != nil {
return fmt.Errorf("read body: %w", err)
}
// The ion proxy sends the Signature as an HTTP trailer (for streaming
// requests) or as a header (for WebSocket requests).
sig := r.Trailer.Get(agcrypto.HeaderSignature)
source := "trailer"
if sig == "" {
sig = r.Header.Get(agcrypto.HeaderSignature)
source = "header"
if sig == "" {
source = "missing"
}
}
logger.Debug("upstream signature value: source=%s present=%v", source, sig != "")
// Verify the cryptographic signature against the upstream signing key.
verifyErr := agcrypto.VerifyHTTPRequest(publicKey, r, body, nil)
if verifyErr != nil {
logger.Debug("upstream signature verification failed: %v", verifyErr)
} else {
logger.Debug("upstream signature verification succeeded")
}
// Restore the body and Content-Length so the reverse proxy can forward it.
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
r.TransferEncoding = nil
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@internal/stack/stack.go` around lines 246 - 278, The Content-Length
restoration and trailer/header cleanup around signature verification are
incorrect: if origContentLength is -1 (common for HTTP/2) you must set
r.ContentLength to the normalized length int64(len(body)) instead of restoring
-1, and you must remove the signature from the request before forwarding (strip
agcrypto.HeaderSignature from r.Trailer when the signature source is "trailer"
and from r.Header when source is "header"). Locate the block using
origContentLength, body, sig, source, agcrypto.HeaderSignature and
VerifyHTTPRequest and change the post-read logic to (1) delete the signature
from the appropriate place based on source, (2) restore r.Body to
io.NopCloser(bytes.NewReader(body)), and (3) set r.ContentLength =
origContentLength if origContentLength >= 0 else r.ContentLength =
int64(len(body)).


return verifyErr
}

func CreateNetworkStack(logger _logger.Logger, urls UrlsMetadata) (*stack.Stack, *channel.Endpoint, error) {

s := stack.New(stack.Options{
Expand Down