Files
ziti/tests/connect_v2_test.go
Paul Lorenz 67eba590e2 Implement connect-v2. Fixes #3884
Implements the router-side Connect-V2 sessionless dial path. Dials are
authorized locally via the RouterDataModel instead of a controller-issued
service session token; circuit creation flows through the existing
`CreateCircuitV3` controller endpoint (#3721). Builds on the sdk-golang
v2 migration.

- Adds `processConnectV2` on `edgeClientConn`: resolves the service by id
  or name via the RouterDataModel, checks dial access, and dispatches to
  the controller via `sendCreateCircuitV3Msg`. Supports both
  `xgEdgeForwarder` (SDK xgress) and `nonXgConnectHandler` flow-control
  modes, selected by the SDK's `UseXgressToSdkHeader`.
- Makes `CircuitId` optional in `DecodeCreateCircuitV3Request`. The V2
  router path does not pre-assign a circuit ID; the controller generates
  it as V1/V2 already do. Without this the decoder rejected the empty
  header and every V2 dial hung until timeout. Adds a regression test.
- Splits `checkAccess` to close a posture-check bypass on the V2 path.
  The old single `checkAccess` short-circuited to nil for non-OIDC
  sessions (V1 ran posture at the controller during `CreateSession`); V2
  has no such step, so posture would have been skipped. `checkAccess` now
  always runs the RDM `HasAccess` (policy + posture) check;
  `checkAccessIfOidc` keeps the OIDC-only gate for the V1 and bind paths.
- Sends the V2 `state_connected` on the default (data) sender rather than
  the control sender. On multi-underlay channels the two senders are
  independently ordered, so an early terminator payload on the data
  sender could beat `state_connected` to the SDK and be dropped (channel/v5
  has no message-priority API).
- Updates `xgEdgeForwarder.lastRx` on every forward path, including the
  fast `timeout == 0` `TrySend` branch used for normal payload dispatch.
  The old code only updated it on the `timeout > 0` path, so active V2
  circuits looked idle and could be unrouted prematurely.
- Adds `state.ConnState.ServiceId`, populated by the connect handlers from
  the service session token (V1) or the request header (V2). The
  non-xgress V2 path previously left this empty, so `handleDialAccessLost`
  could not identify and close V2 non-xgress circuits when dial access was
  revoked.
- Skips conns with no `ServiceSessionToken` in `RemoveLegacyServiceSession`;
  a sessionless V2 conn's token is nil and the cleanup loop previously
  dereferenced it unconditionally, which would panic the router.
- Advertises Connect-V2 via the `RouterCapabilityConnectV2` bit in the
  listener hello so SDKs can detect V2 support.
- Wires `ContentTypeConnectV2` and `ContentTypeXgControl` handlers in
  `Acceptor.BindChannel`, and adds `handleXgControl` for SDK-side xgress
  control messages, preserving `ControlUserVal` so trace-route responses
  correlate back to the initiator's `SendForReply` waiter.
- Adds `RouterDataModel.serviceNameIndex` for O(1) name->id lookup in the
  V2 dial path, maintained with rename safety at the `HandleServiceEvent`
  mutation points.
- Adds `tests/connect_v2_test.go` covering end-to-end V2 dataflow and the
  V1 fallback (`ForceConnectV1`), asserting the dial path via the SDK
  `DialEvent`.

- Propagates a V2 initiator's graceful half-close to legacy hosts via
  `edgeXgressConn.FlowFromFabricToXgressClosed`, which emits an edge FIN
  when the fabric->app half of the circuit closes. The SDK signals
  half-close to its router xgress peer with the native xgress EOF flag;
  without translating that to an edge FIN, a legacy host reading to EOF
  stalled until teardown.
- Records the dialing identity id as the circuit `ClientId` for
  sessionless V2 dials, since there is no dial session to key on; updates
  `Test_OidcEvents` to match.
- Adds `tests/connect_v2_teardown_test.go` covering client- and
  host-initiated close propagation on both the V2 and forced-V1 paths.
- Polls for the asynchronous conn close in the SDK posture-check tests
  (`awaitClientConnClosed`): revocation tears the circuit down out of
  band, so checking `IsClosed` immediately after the first read error was
  racy.
- Temporarily pins sdk-golang/v2 to the openziti/sdk-golang#959 commit,
  which carries the matching xgress conn-close-on-teardown fix the V2
  posture tests depend on; to be repointed at the next sdk-golang
  pre-release before merge.

For openziti/sdk-golang#936.
2026-06-24 16:40:40 -04:00

171 lines
5.1 KiB
Go

//go:build dataflow
/*
Copyright NetFoundry Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package tests
import (
"math"
"testing"
"time"
"github.com/openziti/sdk-golang/v2/ziti"
"github.com/openziti/sdk-golang/v2/ziti/edge"
"github.com/openziti/ziti/v2/common/eid"
"github.com/openziti/ziti/v2/controller/xt_smartrouting"
)
// Test_ConnectV2_Dataflow exercises the sessionless ConnectV2 dial path
// end-to-end. The SDK defaults to V2 whenever the router advertises the
// capability and `ForceConnectV1` is not set. The dial protocol is asserted
// explicitly via the DialEvent so a capability/auth negotiation regression
// fails directly rather than only as a hang or data failure.
func Test_ConnectV2_Dataflow(t *testing.T) {
ctx := NewTestContext(t)
defer ctx.Teardown()
ctx.StartServer()
ctx.RequireAdminManagementApiLogin()
service := ctx.AdminManagementSession.RequireNewServiceAccessibleToAll(xt_smartrouting.Name)
ctx.CreateEnrollAndStartEdgeRouter()
_, hostContext := ctx.AdminManagementSession.RequireCreateSdkContext()
defer hostContext.Close()
listener, err := hostContext.Listen(service.Name)
ctx.Req.NoError(err)
defer listener.Close()
testServer := newTestServer(listener, func(conn *testServerConn) error {
for {
name, eof := conn.ReadString(math.MaxUint16*4, time.Minute)
if eof {
return conn.server.close()
}
if name == "quit" {
conn.WriteString("ok", time.Second)
return conn.server.close()
}
conn.WriteString("hello, "+name, time.Second)
}
})
testServer.start()
_, clientContext := ctx.AdminManagementSession.RequireCreateSdkContext()
defer clientContext.Close()
var dialEvt ziti.DialEvent
dialEvtSet := false
removeListener := clientContext.Events().AddDialListener(func(_ ziti.Context, evt ziti.DialEvent) {
if evt.ServiceName == service.Name {
dialEvt = evt
dialEvtSet = true
}
})
defer removeListener()
dialOptions := &ziti.DialOptions{
ConnectTimeout: 5 * time.Second,
}
conn := ctx.WrapConn(clientContext.DialWithOptions(service.Name, dialOptions))
defer conn.Close()
ctx.Req.True(dialEvtSet, "expected a dial event for service %s", service.Name)
ctx.Req.Equal(edge.DialProtocolConnectV2, dialEvt.Protocol, "expected the dial to take the ConnectV2 path")
name := eid.New()
conn.WriteString(name, time.Second)
conn.ReadExpected("hello, "+name, time.Second)
conn.WriteString("quit", time.Second)
conn.ReadExpected("ok", time.Second)
testServer.waitForDone(ctx, 5*time.Second)
}
// Test_ConnectV1_Fallback_Dataflow confirms that the V1 fallback path still
// works after the connect-v2 changes — important because the SDK still uses
// V1 against routers that don't advertise V2, and the ForceConnectV1 escape
// hatch is a documented supported option.
func Test_ConnectV1_Fallback_Dataflow(t *testing.T) {
ctx := NewTestContext(t)
defer ctx.Teardown()
ctx.StartServer()
ctx.RequireAdminManagementApiLogin()
service := ctx.AdminManagementSession.RequireNewServiceAccessibleToAll(xt_smartrouting.Name)
ctx.CreateEnrollAndStartEdgeRouter()
_, hostContext := ctx.AdminManagementSession.RequireCreateSdkContext()
defer hostContext.Close()
listener, err := hostContext.Listen(service.Name)
ctx.Req.NoError(err)
defer listener.Close()
testServer := newTestServer(listener, func(conn *testServerConn) error {
for {
name, eof := conn.ReadString(math.MaxUint16*4, time.Minute)
if eof {
return conn.server.close()
}
if name == "quit" {
conn.WriteString("ok", time.Second)
return conn.server.close()
}
conn.WriteString("hello, "+name, time.Second)
}
})
testServer.start()
_, clientContext := ctx.AdminManagementSession.RequireCreateSdkContext()
defer clientContext.Close()
var dialEvt ziti.DialEvent
dialEvtSet := false
removeListener := clientContext.Events().AddDialListener(func(_ ziti.Context, evt ziti.DialEvent) {
if evt.ServiceName == service.Name {
dialEvt = evt
dialEvtSet = true
}
})
defer removeListener()
forceV1 := true
dialOptions := &ziti.DialOptions{
ConnectTimeout: 5 * time.Second,
ForceConnectV1: &forceV1,
}
conn := ctx.WrapConn(clientContext.DialWithOptions(service.Name, dialOptions))
defer conn.Close()
ctx.Req.True(dialEvtSet, "expected a dial event for service %s", service.Name)
ctx.Req.Equal(edge.DialProtocolConnectV1, dialEvt.Protocol, "expected the dial to take the ConnectV1 fallback path")
ctx.Req.True(dialEvt.Forced, "expected the V1 dial to be flagged as forced via ForceConnectV1")
name := eid.New()
conn.WriteString(name, time.Second)
conn.ReadExpected("hello, "+name, time.Second)
conn.WriteString("quit", time.Second)
conn.ReadExpected("ok", time.Second)
testServer.waitForDone(ctx, 5*time.Second)
}