Files
OpenShell/sdk/go/openshell/v1/ssh_client.go
Roland HussandDrew Newberry 8dc55e21ee feat(sdk/go): complete Go SDK with domain clients, auth, and hardening (#2702)
* feat(sdk/go): complete Go SDK with domain clients, auth, and hardening

Add the Go SDK implementation covering all gateway RPC services with
domain-typed clients, comprehensive OIDC authentication flows, fake
test doubles, and proto converters.

Domain clients: Sandbox, Provider, Exec, File, TCP, SSH, Policy,
Profile, Health, Service, Config, Workspace, Inference, Refresh.
Each client validates inputs, resolves sandboxes by name, and converts
between domain types and proto at the boundary.

Auth: OIDC authorization code (PKCE), device code (RFC 8628), and
client credentials (RFC 6749 Section 4.4) flows with gateway config
auto-resolution. Token refresh with singleflight deduplication and
exponential backoff. Edge tunnel proxy for gRPC-over-WebSocket.

Gateway: On-disk gateway discovery with user/system directory
precedence, lazy token loading, and auth mode mapping.

Testing: In-memory fake client with deep-copy isolation, watch
broadcasting with filtering, and workspace-scoped object stores.
Bufconn-based gRPC tests for all domain clients.

Ref: #2044

Signed-off-by: Roland Huß <rhuss@redhat.com>

* feat(sdk/go): add gateway client options for logger, timeout, and retry

Add WithLogger, WithTimeout, and WithRetryPolicy options to the gateway
package's NewClient function. These forward the corresponding Config
fields (Logger, Timeout, RetryPolicy) through to the underlying SDK
client, giving callers full control over observability, connection
timeouts, and retry behavior when constructing clients from on-disk
gateway configurations.

Also adds RetryPolicy type to types package and extends Config with the
three new fields.

* fix(mise): sync sccache lock entry

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Roland Huß <rhuss@redhat.com>
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
Co-authored-by: Drew Newberry <anewberry@nvidia.com>
2026-08-13 19:15:21 +00:00

162 lines
3.7 KiB
Go

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"fmt"
"io"
"sync"
"time"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
)
const sshCleanupTimeout = 5 * time.Second
type sshClient struct {
client pb.OpenShellClient
sandboxes SandboxInterface
}
func newSSHClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *sshClient {
return &sshClient{
client: pb.NewOpenShellClient(conn),
sandboxes: sandboxes,
}
}
func (s *sshClient) CreateSession(ctx context.Context, _, sandboxID string) (*SSHSession, error) {
resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{
SandboxId: sandboxID,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return converter.SSHSessionFromProto(resp), nil
}
func (s *sshClient) RevokeSession(ctx context.Context, _, token string) (bool, error) {
resp, err := s.client.RevokeSshSession(ctx, &pb.RevokeSshSessionRequest{
Token: token,
})
if err != nil {
return false, converter.FromGRPCError(err)
}
return resp.GetRevoked(), nil
}
func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) {
if sandboxName == "" {
return nil, &StatusError{
Code: ErrorInvalidArgument,
Message: "sandbox name must not be empty",
}
}
if port == 0 || port > 65535 {
return nil, &StatusError{
Code: ErrorInvalidArgument,
Message: fmt.Sprintf("port must be in range 1-65535, got %d", port),
}
}
var cfg tunnelConfig
for _, o := range opts {
o(&cfg)
}
sandbox, err := s.sandboxes.Get(ctx, workspace, sandboxName)
if err != nil {
return nil, err
}
session, err := s.CreateSession(ctx, workspace, sandbox.ID)
if err != nil {
return nil, err
}
revokeSession := true
defer func() {
if revokeSession {
s.revokeSessionForCleanup(workspace, session.Token)
}
}()
streamCtx, cancel := context.WithCancel(ctx)
stream, err := s.client.ForwardTcp(streamCtx)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
initFrame := &pb.TcpForwardFrame{
Payload: &pb.TcpForwardFrame_Init{
Init: &pb.TcpForwardInit{
SandboxId: sandbox.ID,
ServiceId: cfg.serviceID,
AuthorizationToken: session.Token,
Target: &pb.TcpForwardInit_Ssh{
Ssh: &pb.SshRelayTarget{},
},
},
},
}
if err := stream.Send(initFrame); err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
conn := &tcpForwardConn{
stream: stream,
streamCtx: streamCtx,
cancel: cancel,
dataCh: make(chan []byte, 64),
done: make(chan struct{}),
}
go conn.readLoop()
revokeSession = false
t := &sshTunnel{
tcpForwardConn: conn,
revokeFunc: func() {
s.revokeSessionForCleanup(workspace, session.Token)
},
}
// Auto-revoke the SSH session when the parent context is cancelled.
// The done channel closes after readLoop exits (stream fully drained),
// so Close() won't race with an active stream.
go func() {
<-conn.done
_ = t.Close()
}()
return t, nil
}
func (s *sshClient) revokeSessionForCleanup(workspace, token string) {
ctx, cancel := context.WithTimeout(context.Background(), sshCleanupTimeout)
defer cancel()
_, _ = s.RevokeSession(ctx, workspace, token)
}
type sshTunnel struct {
*tcpForwardConn
revokeFunc func()
closeOnce sync.Once
closeErr error
}
func (t *sshTunnel) Close() error {
t.closeOnce.Do(func() {
t.closeErr = t.tcpForwardConn.Close()
t.revokeFunc()
})
return t.closeErr
}