@@ -86,6 +86,13 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.
8686 if err != nil {
8787 return nil , err
8888 }
89+
90+ // snapshot holds the original control-plane request target so a routed
91+ // attempt can be transparently re-issued to the control plane if the VM
92+ // turns out to be unreachable/gone. It stays nil for requests that are
93+ // not actually rewritten to the VM.
94+ var snapshot * originalRequestTarget
95+
8996 sessionID , subresource , suffix , ok := parseDirectVMPath (req .URL .Path )
9097 if ok {
9198 if _ , ok := allowed [subresource ]; ok {
@@ -95,6 +102,11 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.
95102 if err != nil {
96103 return nil , err
97104 }
105+
106+ // Capture the original target before mutating the request so
107+ // we can restore it for a control-plane fallback.
108+ snapshot = snapshotRequestTarget (req )
109+
98110 req .Header .Del ("Authorization" )
99111 if route .JWT != "" {
100112 q := req .URL .Query ()
@@ -114,13 +126,157 @@ func DirectVMRoutingMiddleware(cache *RouteCache, subresources []string) option.
114126 }
115127
116128 res , err := next (req )
129+
130+ // Control-plane fallback: only for requests that were actually routed to
131+ // the VM. If the direct-VM attempt failed as "VM unreachable / session
132+ // gone" and the method is idempotent (GET/HEAD), re-issue the ORIGINAL
133+ // request to the control plane exactly once. We never fall back for
134+ // non-idempotent methods (could double-execute side effects), and we
135+ // never loop: at most one fallback attempt.
136+ if snapshot != nil && shouldFallBackToControlPlane (req .Method , res , err ) {
137+ // Drain and close any partial VM response body before retrying so we
138+ // don't leak the connection.
139+ if res != nil && res .Body != nil {
140+ _ , _ = io .Copy (io .Discard , res .Body )
141+ _ = res .Body .Close ()
142+ }
143+
144+ // Restore the original control-plane target: URL/Host/Authorization
145+ // and drop the VM jwt query param. A body-less GET/HEAD is safe to
146+ // resend.
147+ snapshot .restore (req )
148+
149+ res , err = next (req )
150+ }
151+
117152 if err != nil {
118153 return res , err
119154 }
155+ // Cache sniff/evict always runs on the FINAL response (VM or fallback).
120156 return finalizeResponse (res , cache , lifecycle )
121157 }
122158}
123159
160+ // originalRequestTarget captures the parts of a request that the direct-VM
161+ // rewrite mutates, so the request can be reconstructed for a control-plane
162+ // fallback.
163+ type originalRequestTarget struct {
164+ scheme string
165+ host string // URL.Host
166+ path string
167+ rawPath string
168+ rawQuery string
169+ reqHost string // Request.Host
170+ authorization []string
171+ hadAuthHeader bool
172+ }
173+
174+ func snapshotRequestTarget (req * http.Request ) * originalRequestTarget {
175+ snap := & originalRequestTarget {
176+ scheme : req .URL .Scheme ,
177+ host : req .URL .Host ,
178+ path : req .URL .Path ,
179+ rawPath : req .URL .RawPath ,
180+ rawQuery : req .URL .RawQuery ,
181+ reqHost : req .Host ,
182+ }
183+ if values , ok := req .Header ["Authorization" ]; ok {
184+ snap .hadAuthHeader = true
185+ snap .authorization = append ([]string (nil ), values ... )
186+ }
187+ return snap
188+ }
189+
190+ // restore rewrites req back to the original control-plane target: it restores
191+ // the URL/Host and Authorization header and drops the jwt query param that the
192+ // VM rewrite added.
193+ func (s * originalRequestTarget ) restore (req * http.Request ) {
194+ req .URL .Scheme = s .scheme
195+ req .URL .Host = s .host
196+ req .URL .Path = s .path
197+ req .URL .RawPath = s .rawPath
198+ // Restoring the original RawQuery inherently drops the jwt query param, which
199+ // only existed in the VM-rewritten request.
200+ req .URL .RawQuery = s .rawQuery
201+ req .Host = s .reqHost
202+
203+ if s .hadAuthHeader {
204+ req .Header ["Authorization" ] = append ([]string (nil ), s .authorization ... )
205+ } else {
206+ req .Header .Del ("Authorization" )
207+ }
208+ }
209+
210+ // shouldFallBackToControlPlane reports whether a routed direct-VM attempt should
211+ // be retried against the control plane. It triggers only for idempotent methods
212+ // (GET/HEAD) when the VM attempt failed as "VM unreachable / session gone":
213+ // - a transport/connection error, OR
214+ // - HTTP 502/503/504, OR
215+ // - the clean gone signal: 404 with header X-Kernel-Upstream: gone and JSON
216+ // body {"code":"browser_gone",...}.
217+ //
218+ // It does NOT trigger on a normal success or an ordinary 4xx from a live VM
219+ // (e.g. 400/401/403, or a 404 that is not the gone marker).
220+ func shouldFallBackToControlPlane (method string , res * http.Response , err error ) bool {
221+ switch strings .ToUpper (method ) {
222+ case http .MethodGet , http .MethodHead :
223+ default :
224+ return false
225+ }
226+
227+ // Transport/network/connection error reaching the VM.
228+ if err != nil {
229+ return true
230+ }
231+ if res == nil {
232+ return false
233+ }
234+
235+ switch res .StatusCode {
236+ case http .StatusBadGateway , http .StatusServiceUnavailable , http .StatusGatewayTimeout :
237+ // 502/503/504 -- today's dead-VM signal ("Upstream not available").
238+ return true
239+ case http .StatusNotFound :
240+ // Future clean gone signal: 404 + X-Kernel-Upstream: gone + browser_gone.
241+ return isBrowserGoneResponse (res )
242+ default :
243+ return false
244+ }
245+ }
246+
247+ // isBrowserGoneResponse detects the clean "browser gone" marker: an
248+ // X-Kernel-Upstream: gone response header plus a JSON body whose code field is
249+ // "browser_gone". It buffers and restores the response body so the check is
250+ // non-destructive for callers that still read it.
251+ func isBrowserGoneResponse (res * http.Response ) bool {
252+ if res == nil {
253+ return false
254+ }
255+ if ! strings .EqualFold (strings .TrimSpace (res .Header .Get ("X-Kernel-Upstream" )), "gone" ) {
256+ return false
257+ }
258+ if res .Body == nil || ! isJSONResponse (res .Header ) {
259+ return false
260+ }
261+
262+ body , err := io .ReadAll (res .Body )
263+ _ = res .Body .Close ()
264+ // Restore the body so the response remains readable regardless of outcome.
265+ res .Body = io .NopCloser (bytes .NewReader (body ))
266+ res .ContentLength = int64 (len (body ))
267+ if err != nil {
268+ return false
269+ }
270+
271+ var payload struct {
272+ Code string `json:"code"`
273+ }
274+ if err := json .Unmarshal (body , & payload ); err != nil {
275+ return false
276+ }
277+ return payload .Code == "browser_gone"
278+ }
279+
124280func parseCacheLifecycle (req * http.Request ) (cacheLifecycle , error ) {
125281 if req == nil || req .URL == nil {
126282 return cacheLifecycle {}, nil
0 commit comments