diff --git a/audit/multi_auditor.go b/audit/multi_auditor.go index bd0789b..28a8238 100644 --- a/audit/multi_auditor.go +++ b/audit/multi_auditor.go @@ -30,7 +30,7 @@ func (m *MultiAuditor) AuditRequest(req Request) { // provided configuration. It always includes a LogAuditor for stderr logging, // and conditionally adds a SocketAuditor if audit logs are enabled and the // workspace agent's log proxy socket exists. -func SetupAuditor(ctx context.Context, logger *slog.Logger, disableAuditLogs bool, logProxySocketPath string, sessionID uuid.UUID) (Auditor, error) { +func SetupAuditor(ctx context.Context, logger *slog.Logger, disableAuditLogs bool, logProxySocketPath string, sessionID uuid.UUID, confinedProcessName string) (Auditor, error) { stderrAuditor := NewLogAuditor(logger) auditors := []Auditor{stderrAuditor} @@ -50,7 +50,7 @@ func SetupAuditor(ctx context.Context, logger *slog.Logger, disableAuditLogs boo } agentWillProxy := !os.IsNotExist(err) if agentWillProxy { - socketAuditor := NewSocketAuditor(logger, logProxySocketPath, sessionID) + socketAuditor := NewSocketAuditor(logger, logProxySocketPath, sessionID, confinedProcessName) go socketAuditor.Loop(ctx) auditors = append(auditors, socketAuditor) } else { diff --git a/audit/multi_auditor_test.go b/audit/multi_auditor_test.go index 2293ff8..6a434b5 100644 --- a/audit/multi_auditor_test.go +++ b/audit/multi_auditor_test.go @@ -27,7 +27,7 @@ func TestSetupAuditor_DisabledAuditLogs(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := context.Background() - auditor, err := SetupAuditor(ctx, logger, true, "", uuid.New()) + auditor, err := SetupAuditor(ctx, logger, true, "", uuid.New(), "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -52,7 +52,7 @@ func TestSetupAuditor_EmptySocketPath(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := context.Background() - _, err := SetupAuditor(ctx, logger, false, "", uuid.New()) + _, err := SetupAuditor(ctx, logger, false, "", uuid.New(), "") if err == nil { t.Fatal("expected error for empty socket path, got nil") } @@ -64,7 +64,7 @@ func TestSetupAuditor_SocketDoesNotExist(t *testing.T) { logger := slog.New(slog.NewTextHandler(io.Discard, nil)) ctx := context.Background() - auditor, err := SetupAuditor(ctx, logger, false, "/nonexistent/socket/path", uuid.New()) + auditor, err := SetupAuditor(ctx, logger, false, "/nonexistent/socket/path", uuid.New(), "") if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -102,7 +102,7 @@ func TestSetupAuditor_SocketExists(t *testing.T) { t.Fatalf("failed to close temp file: %v", err) } - auditor, err := SetupAuditor(ctx, logger, false, socketPath, uuid.New()) + auditor, err := SetupAuditor(ctx, logger, false, socketPath, uuid.New(), "claude") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/audit/socket_auditor.go b/audit/socket_auditor.go index 5ab7f82..0d1dc61 100644 --- a/audit/socket_auditor.go +++ b/audit/socket_auditor.go @@ -29,13 +29,14 @@ const ( // as a FIFO i.e., logs are sent in the order they are received and dropped // if the queue is full. type SocketAuditor struct { - dial func() (net.Conn, error) - logger *slog.Logger - logCh chan *agentproto.BoundaryLog - batchSize int - batchTimerDuration time.Duration - socketPath string - sessionID uuid.UUID + dial func() (net.Conn, error) + logger *slog.Logger + logCh chan *agentproto.BoundaryLog + batchSize int + batchTimerDuration time.Duration + socketPath string + sessionID uuid.UUID + confinedProcessName string droppedChannelFull atomic.Int64 droppedBatchFull atomic.Int64 @@ -45,9 +46,8 @@ type SocketAuditor struct { } // NewSocketAuditor creates a new SocketAuditor that sends logs to the agent's -// boundary log proxy socket after SocketAuditor.Loop is called. The socket path -// is read from EnvAuditSocketPath, falling back to defaultAuditSocketPath. -func NewSocketAuditor(logger *slog.Logger, socketPath string, sessionID uuid.UUID) *SocketAuditor { +// boundary log proxy socket at socketPath after SocketAuditor.Loop is called. +func NewSocketAuditor(logger *slog.Logger, socketPath string, sessionID uuid.UUID, confinedProcessName string) *SocketAuditor { // This channel buffer size intends to allow enough buffering for bursty // AI agent network requests while a batch is being sent to the workspace // agent. @@ -57,12 +57,13 @@ func NewSocketAuditor(logger *slog.Logger, socketPath string, sessionID uuid.UUI dial: func() (net.Conn, error) { return net.Dial("unix", socketPath) }, - logger: logger, - logCh: make(chan *agentproto.BoundaryLog, logChBufSize), - batchSize: defaultBatchSize, - batchTimerDuration: defaultBatchTimerDuration, - socketPath: socketPath, - sessionID: sessionID, + logger: logger, + logCh: make(chan *agentproto.BoundaryLog, logChBufSize), + batchSize: defaultBatchSize, + batchTimerDuration: defaultBatchTimerDuration, + socketPath: socketPath, + sessionID: sessionID, + confinedProcessName: confinedProcessName, } } @@ -104,7 +105,7 @@ type flushErr struct { func (e *flushErr) Error() string { return e.err.Error() } // flush sends the current batch of logs to the given connection. -func flush(conn net.Conn, sessionID uuid.UUID, logs []*agentproto.BoundaryLog) *flushErr { +func flush(conn net.Conn, sessionID uuid.UUID, confinedProcessName string, logs []*agentproto.BoundaryLog) *flushErr { if len(logs) == 0 { return nil } @@ -112,8 +113,9 @@ func flush(conn net.Conn, sessionID uuid.UUID, logs []*agentproto.BoundaryLog) * msg := &codec.BoundaryMessage{ Msg: &codec.BoundaryMessage_Logs{ Logs: &agentproto.ReportBoundaryLogsRequest{ - Logs: logs, - SessionId: sessionID.String(), + Logs: logs, + SessionId: sessionID.String(), + ConfinedProcessName: confinedProcessName, }, }, } @@ -195,7 +197,7 @@ func (s *SocketAuditor) Loop(ctx context.Context) { return } - if err := flush(conn, s.sessionID, batch); err != nil { + if err := flush(conn, s.sessionID, s.confinedProcessName, batch); err != nil { if err.permanent { // Data error: discard batch to avoid infinite retries. s.logger.Warn("dropping batch due to data error on flush attempt", diff --git a/audit/socket_auditor_test.go b/audit/socket_auditor_test.go index 09e359a..09e6de3 100644 --- a/audit/socket_auditor_test.go +++ b/audit/socket_auditor_test.go @@ -464,12 +464,12 @@ func TestSocketAuditor_Loop_ShutdownFlushIncludesDrops(t *testing.T) { func TestFlush_EmptyBatch(t *testing.T) { t.Parallel() - err := flush(nil, uuid.Nil, nil) + err := flush(nil, uuid.Nil, "", nil) if err != nil { t.Errorf("expected nil error for empty batch, got %v", err) } - err = flush(nil, uuid.Nil, []*agentproto.BoundaryLog{}) + err = flush(nil, uuid.Nil, "", []*agentproto.BoundaryLog{}) if err != nil { t.Errorf("expected nil error for empty slice, got %v", err) } @@ -516,6 +516,9 @@ func TestSocketAuditor_Loop_FlushIncludesSessionID(t *testing.T) { if req.SessionId != expectedSessionID { t.Errorf("expected SessionId=%s, got %q", expectedSessionID, req.SessionId) } + if req.ConfinedProcessName != "claude" { + t.Errorf("expected ConfinedProcessName=%q, got %q", "claude", req.ConfinedProcessName) + } if len(req.Logs) != auditor.batchSize { t.Errorf("expected %d logs, got %d", auditor.batchSize, len(req.Logs)) } @@ -572,11 +575,12 @@ func setupTestAuditor(t *testing.T) (*SocketAuditor, net.Conn) { dial: func() (net.Conn, error) { return clientConn, nil }, - logger: logger, - logCh: make(chan *agentproto.BoundaryLog, 2*defaultBatchSize), - batchSize: defaultBatchSize, - batchTimerDuration: defaultBatchTimerDuration, - sessionID: uuid.MustParse("00000000-0000-4000-8000-000000000001"), + logger: logger, + logCh: make(chan *agentproto.BoundaryLog, 2*defaultBatchSize), + batchSize: defaultBatchSize, + batchTimerDuration: defaultBatchTimerDuration, + sessionID: uuid.MustParse("00000000-0000-4000-8000-000000000001"), + confinedProcessName: "claude", } return auditor, serverConn diff --git a/config/config.go b/config/config.go index 9de181b..3e745b6 100644 --- a/config/config.go +++ b/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "path/filepath" "strings" "github.com/coder/serpent" @@ -100,6 +101,22 @@ type AppConfig struct { // all audit events produced by this boundary invocation into a // single session. Set by Run, not by configuration. SessionID uuid.UUID + + // ConfinedProcessName is the base name of the process boundary is + // confining (e.g. "claude", "codex"), derived from TargetCMD. It is + // reported alongside audit logs so that sessions can be attributed to + // the process that generated them. + ConfinedProcessName string +} + +// confinedProcessName returns a human-readable name for the process being +// confined, derived from the first element of the target command. It returns +// an empty string when no command is present. +func confinedProcessName(targetCMD []string) string { + if len(targetCMD) == 0 { + return "" + } + return filepath.Base(targetCMD[0]) } func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string, environ []string) (AppConfig, error) { @@ -124,20 +141,21 @@ func NewAppConfigFromCliConfig(cfg CliConfig, targetCMD []string, environ []stri } return AppConfig{ - AllowRules: allAllowStrings, - LogLevel: cfg.LogLevel.Value(), - LogDir: cfg.LogDir.Value(), - ProxyPort: cfg.ProxyPort.Value(), - PprofEnabled: cfg.PprofEnabled.Value(), - PprofPort: cfg.PprofPort.Value(), - JailType: jailType, - UseRealDNS: cfg.UseRealDNS.Value(), - NoUserNamespace: cfg.NoUserNamespace.Value(), - TargetCMD: targetCMD, - UserInfo: userInfo, - DisableAuditLogs: cfg.DisableAuditLogs.Value(), - LogProxySocketPath: cfg.LogProxySocketPath.Value(), - SessionCorrelation: sc, + AllowRules: allAllowStrings, + LogLevel: cfg.LogLevel.Value(), + LogDir: cfg.LogDir.Value(), + ProxyPort: cfg.ProxyPort.Value(), + PprofEnabled: cfg.PprofEnabled.Value(), + PprofPort: cfg.PprofPort.Value(), + JailType: jailType, + UseRealDNS: cfg.UseRealDNS.Value(), + NoUserNamespace: cfg.NoUserNamespace.Value(), + TargetCMD: targetCMD, + UserInfo: userInfo, + DisableAuditLogs: cfg.DisableAuditLogs.Value(), + LogProxySocketPath: cfg.LogProxySocketPath.Value(), + SessionCorrelation: sc, + ConfinedProcessName: confinedProcessName(targetCMD), }, nil } @@ -152,8 +170,8 @@ func buildSessionCorrelation(cfg CliConfig, environ []string) (SessionCorrelatio targets := append(cfg.InjectSessionIDTargets.Value(), cfg.InjectSessionIDTarget.Value()...) if len(targets) == 0 && cfg.SessionCorrelationEnabled.Value() { - if t := DefaultInjectTargetFromEnv(environ); t != "" { - targets = []string{t} + if derived := DefaultInjectTargetsFromEnv(environ); len(derived) > 0 { + targets = derived } } diff --git a/config/session_correlation.go b/config/session_correlation.go index 67a7b27..0a43cbf 100644 --- a/config/session_correlation.go +++ b/config/session_correlation.go @@ -19,8 +19,16 @@ const ( // exactly this header name. SequenceNumberHeaderName = "X-Coder-Agent-Firewall-Sequence-Number" - // DefaultAIBridgePath is the path glob used when auto-deriving an inject - // target from CODER_AGENT_URL. + // DefaultAIGatewayPath is the current AI Gateway route prefix glob used + // when auto-deriving an inject target from CODER_AGENT_URL. + DefaultAIGatewayPath = "/api/v2/ai-gateway/*" + + // DefaultAIBridgePath is the backward-compatible aibridge alias route + // prefix glob used when auto-deriving an inject target from + // CODER_AGENT_URL. It is transitional: once all Coder deployments serve + // the gateway exclusively at DefaultAIGatewayPath, this alias and the + // extra inject target derived from it in DefaultInjectTargetsFromEnv + // should be removed. DefaultAIBridgePath = "/api/v2/aibridge/*" // CoderAgentURLEnv is the environment variable set by the Coder workspace @@ -46,15 +54,17 @@ type SessionCorrelationConfig struct { InjectTargets []string } -// DefaultInjectTargetFromEnv derives an inject target rule string from the -// CODER_AGENT_URL variable in the provided environment slice. It returns "" +// DefaultInjectTargetsFromEnv derives inject target rule strings from the +// CODER_AGENT_URL variable in the provided environment slice. It returns nil // if the variable is absent, empty, or not a valid URL with a host. The -// derived target uses DefaultAIBridgePath as the path glob so that all AI -// Bridge traffic on the control-plane host is matched. +// derived targets cover both the current AI Gateway route prefix +// (DefaultAIGatewayPath) and its backward-compatible aibridge alias +// (DefaultAIBridgePath) so that clients hitting either path on the +// control-plane host get correlation headers injected. // // The environ parameter is accepted rather than reading os.Environ directly so // that callers (and tests) can supply an arbitrary environment. -func DefaultInjectTargetFromEnv(environ []string) string { +func DefaultInjectTargetsFromEnv(environ []string) []string { var raw string for _, e := range environ { k, v, ok := strings.Cut(e, "=") @@ -64,15 +74,18 @@ func DefaultInjectTargetFromEnv(environ []string) string { } } if raw == "" { - return "" + return nil } u, err := url.Parse(raw) if err != nil || u.Host == "" { - return "" + return nil } - return fmt.Sprintf("domain=%s path=%s", u.Hostname(), DefaultAIBridgePath) + return []string{ + fmt.Sprintf("domain=%s path=%s", u.Hostname(), DefaultAIGatewayPath), + fmt.Sprintf("domain=%s path=%s", u.Hostname(), DefaultAIBridgePath), + } } // ValidateSessionCorrelation checks that the session correlation config diff --git a/config/session_correlation_test.go b/config/session_correlation_test.go index 588f9e6..b3a8d59 100644 --- a/config/session_correlation_test.go +++ b/config/session_correlation_test.go @@ -160,7 +160,10 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { }(), environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, wantEnabled: true, - wantTargets: []string{"domain=dev.coder.com path=" + DefaultAIBridgePath}, + wantTargets: []string{ + "domain=dev.coder.com path=" + DefaultAIGatewayPath, + "domain=dev.coder.com path=" + DefaultAIBridgePath, + }, }, { name: "enabled with no targets, CODER_AGENT_URL absent -> error", @@ -217,48 +220,57 @@ func TestNewAppConfigFromCliConfig_SessionCorrelation(t *testing.T) { } } -func TestDefaultInjectTargetFromEnv(t *testing.T) { +func TestDefaultInjectTargetsFromEnv(t *testing.T) { t.Parallel() tests := []struct { name string environ []string - want string + want []string }{ { name: "valid URL with trailing slash", environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, - want: "domain=dev.coder.com path=" + DefaultAIBridgePath, + want: []string{ + "domain=dev.coder.com path=" + DefaultAIGatewayPath, + "domain=dev.coder.com path=" + DefaultAIBridgePath, + }, }, { name: "valid URL without trailing slash", environ: []string{"CODER_AGENT_URL=https://dev.coder.com"}, - want: "domain=dev.coder.com path=" + DefaultAIBridgePath, + want: []string{ + "domain=dev.coder.com path=" + DefaultAIGatewayPath, + "domain=dev.coder.com path=" + DefaultAIBridgePath, + }, }, { name: "URL with port", environ: []string{"CODER_AGENT_URL=https://dev.coder.com:8443/"}, - want: "domain=dev.coder.com path=" + DefaultAIBridgePath, + want: []string{ + "domain=dev.coder.com path=" + DefaultAIGatewayPath, + "domain=dev.coder.com path=" + DefaultAIBridgePath, + }, }, { name: "unset variable", environ: []string{}, - want: "", + want: nil, }, { name: "empty value", environ: []string{"CODER_AGENT_URL="}, - want: "", + want: nil, }, { name: "no host in URL", environ: []string{"CODER_AGENT_URL=not-a-url"}, - want: "", + want: nil, }, { name: "other env vars present but not CODER_AGENT_URL", environ: []string{"CODER_URL=https://dev.coder.com/", "HOME=/home/user"}, - want: "", + want: nil, }, } @@ -266,9 +278,15 @@ func TestDefaultInjectTargetFromEnv(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - got := DefaultInjectTargetFromEnv(tc.environ) - if got != tc.want { - t.Errorf("got %q, want %q", got, tc.want) + got := DefaultInjectTargetsFromEnv(tc.environ) + if len(got) != len(tc.want) { + t.Fatalf("len: got %d %q, want %d %q", + len(got), got, len(tc.want), tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Errorf("[%d]: got %q, want %q", i, got[i], tc.want[i]) + } } }) } @@ -381,6 +399,7 @@ func TestBuildSessionCorrelation_AgentURLFallback(t *testing.T) { }, environ: []string{"CODER_AGENT_URL=https://dev.coder.com/"}, wantTargets: []string{ + "domain=dev.coder.com path=" + DefaultAIGatewayPath, "domain=dev.coder.com path=" + DefaultAIBridgePath, }, }, diff --git a/landjail/manager.go b/landjail/manager.go index 4167103..92b08ee 100644 --- a/landjail/manager.go +++ b/landjail/manager.go @@ -31,6 +31,12 @@ func NewLandJail( logger *slog.Logger, config config.AppConfig, ) (*LandJail, error) { + // Build the session-correlation inject engine (nil when disabled). + injectEngine, err := proxy.NewInjectEngine(config.SessionCorrelation, logger) + if err != nil { + return nil, fmt.Errorf("build inject engine: %w", err) + } + // Create proxy server proxyServer := proxy.NewProxyServer(proxy.Config{ HTTPPort: int(config.ProxyPort), @@ -40,6 +46,8 @@ func NewLandJail( TLSConfig: tlsConfig, PprofEnabled: config.PprofEnabled, PprofPort: int(config.PprofPort), + InjectEngine: injectEngine, + SessionID: config.SessionID.String(), }) return &LandJail{ diff --git a/landjail/parent.go b/landjail/parent.go index d399d3d..96e3b72 100644 --- a/landjail/parent.go +++ b/landjail/parent.go @@ -27,7 +27,7 @@ func RunParent(ctx context.Context, logger *slog.Logger, config config.AppConfig ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) // Create auditor - auditor, err := audit.SetupAuditor(ctx, logger, config.DisableAuditLogs, config.LogProxySocketPath, config.SessionID) + auditor, err := audit.SetupAuditor(ctx, logger, config.DisableAuditLogs, config.LogProxySocketPath, config.SessionID, config.ConfinedProcessName) if err != nil { return fmt.Errorf("failed to setup auditor: %v", err) } diff --git a/nsjail_manager/manager.go b/nsjail_manager/manager.go index 348620d..51169b6 100644 --- a/nsjail_manager/manager.go +++ b/nsjail_manager/manager.go @@ -34,6 +34,11 @@ func NewNSJailManager( logger *slog.Logger, config config.AppConfig, ) (*NSJailManager, error) { + injectEngine, err := proxy.NewInjectEngine(config.SessionCorrelation, logger) + if err != nil { + return nil, fmt.Errorf("build inject engine: %w", err) + } + // Create proxy server proxyServer := proxy.NewProxyServer(proxy.Config{ HTTPPort: int(config.ProxyPort), @@ -43,6 +48,8 @@ func NewNSJailManager( TLSConfig: tlsConfig, PprofEnabled: config.PprofEnabled, PprofPort: int(config.PprofPort), + InjectEngine: injectEngine, + SessionID: config.SessionID.String(), }) return &NSJailManager{ diff --git a/nsjail_manager/parent.go b/nsjail_manager/parent.go index e5d769b..90b3a72 100644 --- a/nsjail_manager/parent.go +++ b/nsjail_manager/parent.go @@ -28,7 +28,7 @@ func RunParent(ctx context.Context, logger *slog.Logger, config config.AppConfig ruleEngine := rulesengine.NewRuleEngine(allowRules, logger) // Create auditor - auditor, err := audit.SetupAuditor(ctx, logger, config.DisableAuditLogs, config.LogProxySocketPath, config.SessionID) + auditor, err := audit.SetupAuditor(ctx, logger, config.DisableAuditLogs, config.LogProxySocketPath, config.SessionID, config.ConfinedProcessName) if err != nil { return fmt.Errorf("failed to setup auditor: %v", err) } diff --git a/proxy/proxy.go b/proxy/proxy.go index 229cce0..d3ec4c6 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -51,12 +51,10 @@ type Config struct { TLSConfig *tls.Config PprofEnabled bool PprofPort int - // SessionCorrelation controls header injection for AI Bridge - // correlation. See config.SessionCorrelationConfig for details. - SessionCorrelation config.SessionCorrelationConfig // InjectEngine, if non-nil, is used to evaluate whether outgoing - // requests match configured inject targets. Built from - // SessionCorrelation.InjectTargets using rulesengine.ParseAllowSpecs. + // requests match configured inject targets for session correlation. + // Built from config.SessionCorrelationConfig.InjectTargets via + // NewInjectEngine using rulesengine.ParseAllowSpecs. InjectEngine *rulesengine.Engine // SessionID is the boundary session UUID injected as a header // on matching requests. @@ -83,6 +81,24 @@ func NewProxyServer(config Config) *Server { } } +// NewInjectEngine builds a rule engine for session-correlation inject +// targets from sc, or returns nil when correlation is disabled or has no +// targets. The returned engine uses the same matching semantics as --allow +// rules so that inject-target evaluation is identical to allow-rule +// evaluation. sc is expected to have already been validated via +// config.ValidateSessionCorrelation. +func NewInjectEngine(sc config.SessionCorrelationConfig, logger *slog.Logger) (*rulesengine.Engine, error) { + if !sc.Enabled || len(sc.InjectTargets) == 0 { + return nil, nil + } + rules, err := rulesengine.ParseAllowSpecs(sc.InjectTargets) + if err != nil { + return nil, fmt.Errorf("parse inject targets: %w", err) + } + eng := rulesengine.NewRuleEngine(rules, logger) + return &eng, nil +} + // Start starts the HTTP proxy server with TLS termination capability func (p *Server) Start() error { if p.isStarted() { diff --git a/proxy/proxy_framework_test.go b/proxy/proxy_framework_test.go index bfc69e1..9ccad67 100644 --- a/proxy/proxy_framework_test.go +++ b/proxy/proxy_framework_test.go @@ -190,15 +190,14 @@ func (pt *ProxyTest) Start() *ProxyTest { } pt.server = NewProxyServer(Config{ - HTTPPort: pt.port, - RuleEngine: ruleEngine, - Auditor: auditor, - Logger: logger, - TLSConfig: tlsConfig, - SessionCorrelation: pt.sessionCorrelation, - InjectEngine: injectEngine, - SessionID: pt.sessionID, - ForwardTransport: pt.forwardTransport, + HTTPPort: pt.port, + RuleEngine: ruleEngine, + Auditor: auditor, + Logger: logger, + TLSConfig: tlsConfig, + InjectEngine: injectEngine, + SessionID: pt.sessionID, + ForwardTransport: pt.forwardTransport, }) err = pt.server.Start()