diff --git a/agent/agent.go b/agent/agent.go index a971c0e7987b6..74cf305c9434a 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -456,7 +456,7 @@ func (t *trySingleflight) Do(key string, fn func()) { fn() } -func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient26) error { tickerDone := make(chan struct{}) collectDone := make(chan struct{}) ctx, cancel := context.WithCancel(ctx) @@ -672,7 +672,7 @@ func (a *agent) reportMetadata(ctx context.Context, aAPI proto.DRPCAgentClient24 // reportLifecycle reports the current lifecycle state once. All state // changes are reported in order. -func (a *agent) reportLifecycle(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) reportLifecycle(ctx context.Context, aAPI proto.DRPCAgentClient26) error { for { select { case <-a.lifecycleUpdate: @@ -752,7 +752,7 @@ func (a *agent) setLifecycle(state codersdk.WorkspaceAgentLifecycle) { } // reportConnectionsLoop reports connections to the agent for auditing. -func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) reportConnectionsLoop(ctx context.Context, aAPI proto.DRPCAgentClient26) error { for { select { case <-a.reportConnectionsUpdate: @@ -872,7 +872,7 @@ func (a *agent) reportConnection(id uuid.UUID, connectionType proto.Connection_T // fetchServiceBannerLoop fetches the service banner on an interval. It will // not be fetched immediately; the expectation is that it is primed elsewhere // (and must be done before the session actually starts). -func (a *agent) fetchServiceBannerLoop(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) fetchServiceBannerLoop(ctx context.Context, aAPI proto.DRPCAgentClient26) error { ticker := time.NewTicker(a.announcementBannersRefreshInterval) defer ticker.Stop() for { @@ -925,7 +925,7 @@ func (a *agent) run() (retErr error) { connMan := newAPIConnRoutineManager(a.gracefulCtx, a.hardCtx, a.logger, aAPI, tAPI) connMan.startAgentAPI("init notification banners", gracefulShutdownBehaviorStop, - func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { bannersProto, err := aAPI.GetAnnouncementBanners(ctx, &proto.GetAnnouncementBannersRequest{}) if err != nil { return xerrors.Errorf("fetch service banner: %w", err) @@ -942,7 +942,7 @@ func (a *agent) run() (retErr error) { // sending logs gets gracefulShutdownBehaviorRemain because we want to send logs generated by // shutdown scripts. connMan.startAgentAPI("send logs", gracefulShutdownBehaviorRemain, - func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { err := a.logSender.SendLoop(ctx, aAPI) if xerrors.Is(err, agentsdk.ErrLogLimitExceeded) { // we don't want this error to tear down the API connection and propagate to the @@ -961,7 +961,7 @@ func (a *agent) run() (retErr error) { connMan.startAgentAPI("report metadata", gracefulShutdownBehaviorStop, a.reportMetadata) // resources monitor can cease as soon as we start gracefully shutting down. - connMan.startAgentAPI("resources monitor", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + connMan.startAgentAPI("resources monitor", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { logger := a.logger.Named("resources_monitor") clk := quartz.NewReal() config, err := aAPI.GetResourcesMonitoringConfiguration(ctx, &proto.GetResourcesMonitoringConfigurationRequest{}) @@ -1008,7 +1008,7 @@ func (a *agent) run() (retErr error) { connMan.startAgentAPI("handle manifest", gracefulShutdownBehaviorStop, a.handleManifest(manifestOK)) connMan.startAgentAPI("app health reporter", gracefulShutdownBehaviorStop, - func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { if err := manifestOK.wait(ctx); err != nil { return xerrors.Errorf("no manifest: %w", err) } @@ -1041,7 +1041,7 @@ func (a *agent) run() (retErr error) { connMan.startAgentAPI("fetch service banner loop", gracefulShutdownBehaviorStop, a.fetchServiceBannerLoop) - connMan.startAgentAPI("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { + connMan.startAgentAPI("stats report loop", gracefulShutdownBehaviorStop, func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { if err := networkOK.wait(ctx); err != nil { return xerrors.Errorf("no network: %w", err) } @@ -1056,8 +1056,8 @@ func (a *agent) run() (retErr error) { } // handleManifest returns a function that fetches and processes the manifest -func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { - return func(ctx context.Context, aAPI proto.DRPCAgentClient24) error { +func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { + return func(ctx context.Context, aAPI proto.DRPCAgentClient26) error { var ( sentResult = false err error @@ -1187,8 +1187,8 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context, // createOrUpdateNetwork waits for the manifest to be set using manifestOK, then creates or updates // the tailnet using the information in the manifest -func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, proto.DRPCAgentClient24) error { - return func(ctx context.Context, _ proto.DRPCAgentClient24) (retErr error) { +func (a *agent) createOrUpdateNetwork(manifestOK, networkOK *checkpoint) func(context.Context, proto.DRPCAgentClient26) error { + return func(ctx context.Context, _ proto.DRPCAgentClient26) (retErr error) { if err := manifestOK.wait(ctx); err != nil { return xerrors.Errorf("no manifest: %w", err) } @@ -1960,7 +1960,7 @@ const ( type apiConnRoutineManager struct { logger slog.Logger - aAPI proto.DRPCAgentClient24 + aAPI proto.DRPCAgentClient26 tAPI tailnetproto.DRPCTailnetClient24 eg *errgroup.Group stopCtx context.Context @@ -1969,7 +1969,7 @@ type apiConnRoutineManager struct { func newAPIConnRoutineManager( gracefulCtx, hardCtx context.Context, logger slog.Logger, - aAPI proto.DRPCAgentClient24, tAPI tailnetproto.DRPCTailnetClient24, + aAPI proto.DRPCAgentClient26, tAPI tailnetproto.DRPCTailnetClient24, ) *apiConnRoutineManager { // routines that remain in operation during graceful shutdown use the remainCtx. They'll still // exit if the errgroup hits an error, which usually means a problem with the conn. @@ -2002,7 +2002,7 @@ func newAPIConnRoutineManager( // but for Tailnet. func (a *apiConnRoutineManager) startAgentAPI( name string, behavior gracefulShutdownBehavior, - f func(context.Context, proto.DRPCAgentClient24) error, + f func(context.Context, proto.DRPCAgentClient26) error, ) { logger := a.logger.With(slog.F("name", name)) var ctx context.Context diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index 869d2f7d0923b..f9723e8a15758 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: .. (interfaces: Lister,DevcontainerCLI) +// Source: .. (interfaces: ContainerCLI,DevcontainerCLI) // // Generated by this command: // -// mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI +// mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI // // Package acmock is a generated GoMock package. @@ -18,32 +18,81 @@ import ( gomock "go.uber.org/mock/gomock" ) -// MockLister is a mock of Lister interface. -type MockLister struct { +// MockContainerCLI is a mock of ContainerCLI interface. +type MockContainerCLI struct { ctrl *gomock.Controller - recorder *MockListerMockRecorder + recorder *MockContainerCLIMockRecorder isgomock struct{} } -// MockListerMockRecorder is the mock recorder for MockLister. -type MockListerMockRecorder struct { - mock *MockLister +// MockContainerCLIMockRecorder is the mock recorder for MockContainerCLI. +type MockContainerCLIMockRecorder struct { + mock *MockContainerCLI } -// NewMockLister creates a new mock instance. -func NewMockLister(ctrl *gomock.Controller) *MockLister { - mock := &MockLister{ctrl: ctrl} - mock.recorder = &MockListerMockRecorder{mock} +// NewMockContainerCLI creates a new mock instance. +func NewMockContainerCLI(ctrl *gomock.Controller) *MockContainerCLI { + mock := &MockContainerCLI{ctrl: ctrl} + mock.recorder = &MockContainerCLIMockRecorder{mock} return mock } // EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockLister) EXPECT() *MockListerMockRecorder { +func (m *MockContainerCLI) EXPECT() *MockContainerCLIMockRecorder { return m.recorder } +// Copy mocks base method. +func (m *MockContainerCLI) Copy(ctx context.Context, containerName, src, dst string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Copy", ctx, containerName, src, dst) + ret0, _ := ret[0].(error) + return ret0 +} + +// Copy indicates an expected call of Copy. +func (mr *MockContainerCLIMockRecorder) Copy(ctx, containerName, src, dst any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Copy", reflect.TypeOf((*MockContainerCLI)(nil).Copy), ctx, containerName, src, dst) +} + +// DetectArchitecture mocks base method. +func (m *MockContainerCLI) DetectArchitecture(ctx context.Context, containerName string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DetectArchitecture", ctx, containerName) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// DetectArchitecture indicates an expected call of DetectArchitecture. +func (mr *MockContainerCLIMockRecorder) DetectArchitecture(ctx, containerName any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DetectArchitecture", reflect.TypeOf((*MockContainerCLI)(nil).DetectArchitecture), ctx, containerName) +} + +// ExecAs mocks base method. +func (m *MockContainerCLI) ExecAs(ctx context.Context, containerName, user string, args ...string) ([]byte, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, containerName, user} + for _, a := range args { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "ExecAs", varargs...) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ExecAs indicates an expected call of ExecAs. +func (mr *MockContainerCLIMockRecorder) ExecAs(ctx, containerName, user any, args ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, containerName, user}, args...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ExecAs", reflect.TypeOf((*MockContainerCLI)(nil).ExecAs), varargs...) +} + // List mocks base method. -func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (m *MockContainerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "List", ctx) ret0, _ := ret[0].(codersdk.WorkspaceAgentListContainersResponse) @@ -52,9 +101,9 @@ func (m *MockLister) List(ctx context.Context) (codersdk.WorkspaceAgentListConta } // List indicates an expected call of List. -func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call { +func (mr *MockContainerCLIMockRecorder) List(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockContainerCLI)(nil).List), ctx) } // MockDevcontainerCLI is a mock of DevcontainerCLI interface. @@ -81,6 +130,25 @@ func (m *MockDevcontainerCLI) EXPECT() *MockDevcontainerCLIMockRecorder { return m.recorder } +// Exec mocks base method. +func (m *MockDevcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath, cmd string, cmdArgs []string, opts ...agentcontainers.DevcontainerCLIExecOptions) error { + m.ctrl.T.Helper() + varargs := []any{ctx, workspaceFolder, configPath, cmd, cmdArgs} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Exec", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Exec indicates an expected call of Exec. +func (mr *MockDevcontainerCLIMockRecorder) Exec(ctx, workspaceFolder, configPath, cmd, cmdArgs any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, workspaceFolder, configPath, cmd, cmdArgs}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockDevcontainerCLI)(nil).Exec), varargs...) +} + // Up mocks base method. func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { m.ctrl.T.Helper() diff --git a/agent/agentcontainers/acmock/doc.go b/agent/agentcontainers/acmock/doc.go index b807efa253b75..d0951fc848eb1 100644 --- a/agent/agentcontainers/acmock/doc.go +++ b/agent/agentcontainers/acmock/doc.go @@ -1,4 +1,4 @@ // Package acmock contains a mock implementation of agentcontainers.Lister for use in tests. package acmock -//go:generate mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI +//go:generate mockgen -destination ./acmock.go -package acmock .. ContainerCLI,DevcontainerCLI diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index 349b85e3d269f..d826cb23cbc1f 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -43,7 +43,7 @@ type API struct { logger slog.Logger watcher watcher.Watcher execer agentexec.Execer - cl Lister + ccli ContainerCLI dccli DevcontainerCLI clock quartz.Clock scriptLogger func(logSourceID uuid.UUID) ScriptLogger @@ -80,11 +80,11 @@ func WithExecer(execer agentexec.Execer) Option { } } -// WithLister sets the agentcontainers.Lister implementation to use. -// The default implementation uses the Docker CLI to list containers. -func WithLister(cl Lister) Option { +// WithContainerCLI sets the agentcontainers.ContainerCLI implementation +// to use. The default implementation uses the Docker CLI. +func WithContainerCLI(ccli ContainerCLI) Option { return func(api *API) { - api.cl = cl + api.ccli = ccli } } @@ -186,8 +186,8 @@ func NewAPI(logger slog.Logger, options ...Option) *API { for _, opt := range options { opt(api) } - if api.cl == nil { - api.cl = NewDocker(api.execer) + if api.ccli == nil { + api.ccli = NewDockerCLI(api.execer) } if api.dccli == nil { api.dccli = NewDevcontainerCLI(logger.Named("devcontainer-cli"), api.execer) @@ -363,7 +363,7 @@ func (api *API) updateContainers(ctx context.Context) error { listCtx, listCancel := context.WithTimeout(ctx, listContainersTimeout) defer listCancel() - updated, err := api.cl.List(listCtx) + updated, err := api.ccli.List(listCtx) if err != nil { // If the context was canceled, we hold off on clearing the // containers cache. This is to avoid clearing the cache if @@ -378,6 +378,8 @@ func (api *API) updateContainers(ctx context.Context) error { return xerrors.Errorf("list containers failed: %w", err) } + // Clone to avoid test flakes due to data manipulation. + updated.Containers = slices.Clone(updated.Containers) api.mu.Lock() defer api.mu.Unlock() @@ -682,7 +684,7 @@ func (api *API) recreateDevcontainer(dc codersdk.WorkspaceAgentDevcontainer, con logger.Debug(ctx, "starting devcontainer recreation") - _, err = api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, WithOutput(infoW, errW), WithRemoveExistingContainer()) + _, err = api.dccli.Up(ctx, dc.WorkspaceFolder, configPath, WithUpOutput(infoW, errW), WithRemoveExistingContainer()) if err != nil { // No need to log if the API is closing (context canceled), as this // is expected behavior when the API is shutting down. diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index fb55825097190..313da6f9f615f 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -28,34 +28,69 @@ import ( "github.com/coder/quartz" ) -// fakeLister implements the agentcontainers.Lister interface for +// fakeContainerCLI implements the agentcontainers.ContainerCLI interface for // testing. -type fakeLister struct { +type fakeContainerCLI struct { containers codersdk.WorkspaceAgentListContainersResponse - err error + listErr error + arch string + archErr error + copyErr error + execErr error } -func (f *fakeLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { - return f.containers, f.err +func (f *fakeContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { + return f.containers, f.listErr +} + +func (f *fakeContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) { + return f.arch, f.archErr +} + +func (f *fakeContainerCLI) Copy(ctx context.Context, name, src, dst string) error { + return f.copyErr +} + +func (f *fakeContainerCLI) ExecAs(ctx context.Context, name, user string, args ...string) ([]byte, error) { + return nil, f.execErr } // fakeDevcontainerCLI implements the agentcontainers.DevcontainerCLI // interface for testing. type fakeDevcontainerCLI struct { - id string - err error - continueUp chan struct{} + upID string + upErr error + upErrC chan error // If set, send to return err, close to return upErr. + execErr error + execErrC chan error // If set, send to return err, close to return execErr. } func (f *fakeDevcontainerCLI) Up(ctx context.Context, _, _ string, _ ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { - if f.continueUp != nil { + if f.upErrC != nil { + select { + case <-ctx.Done(): + return "", ctx.Err() + case err, ok := <-f.upErrC: + if ok { + return f.upID, err + } + } + } + return f.upID, f.upErr +} + +func (f *fakeDevcontainerCLI) Exec(ctx context.Context, _, _ string, _ string, _ []string, _ ...agentcontainers.DevcontainerCLIExecOptions) error { + if f.execErrC != nil { select { case <-ctx.Done(): - return "", xerrors.New("test timeout") - case <-f.continueUp: + return ctx.Err() + case err, ok := <-f.execErrC: + if ok { + return err + } } } - return f.id, f.err + return f.execErr } // fakeWatcher implements the watcher.Watcher interface for testing. @@ -180,7 +215,7 @@ func TestAPI(t *testing.T) { // initialData to be stored in the handler initialData initialDataPayload // function to set up expectations for the mock - setupMock func(mcl *acmock.MockLister, preReq *gomock.Call) + setupMock func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) // expected result expected codersdk.WorkspaceAgentListContainersResponse // expected error @@ -189,7 +224,7 @@ func TestAPI(t *testing.T) { { name: "no initial data", initialData: initialDataPayload{makeResponse(), nil}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes() }, expected: makeResponse(fakeCt), @@ -207,7 +242,7 @@ func TestAPI(t *testing.T) { { name: "lister error only during initial data", initialData: initialDataPayload{makeResponse(), assert.AnError}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes() }, expected: makeResponse(fakeCt), @@ -215,7 +250,7 @@ func TestAPI(t *testing.T) { { name: "lister error after initial data", initialData: initialDataPayload{makeResponse(fakeCt), nil}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).After(preReq).AnyTimes() }, expectedErr: assert.AnError.Error(), @@ -223,7 +258,7 @@ func TestAPI(t *testing.T) { { name: "updated data", initialData: initialDataPayload{makeResponse(fakeCt), nil}, - setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + setupMock: func(mcl *acmock.MockContainerCLI, preReq *gomock.Call) { mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).After(preReq).AnyTimes() }, expected: makeResponse(fakeCt2), @@ -236,7 +271,7 @@ func TestAPI(t *testing.T) { mClock = quartz.NewMock(t) tickerTrap = mClock.Trap().TickerFunc("updaterLoop") mCtrl = gomock.NewController(t) - mLister = acmock.NewMockLister(mCtrl) + mLister = acmock.NewMockContainerCLI(mCtrl) logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) r = chi.NewRouter() ) @@ -250,7 +285,7 @@ func TestAPI(t *testing.T) { api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), - agentcontainers.WithLister(mLister), + agentcontainers.WithContainerCLI(mLister), ) defer api.Close() r.Mount("/", api.Routes()) @@ -326,7 +361,7 @@ func TestAPI(t *testing.T) { tests := []struct { name string containerID string - lister *fakeLister + lister *fakeContainerCLI devcontainerCLI *fakeDevcontainerCLI wantStatus []int wantBody []string @@ -334,7 +369,7 @@ func TestAPI(t *testing.T) { { name: "Missing container ID", containerID: "", - lister: &fakeLister{}, + lister: &fakeContainerCLI{}, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusBadRequest}, wantBody: []string{"Missing container ID or name"}, @@ -342,8 +377,8 @@ func TestAPI(t *testing.T) { { name: "List error", containerID: "container-id", - lister: &fakeLister{ - err: xerrors.New("list error"), + lister: &fakeContainerCLI{ + listErr: xerrors.New("list error"), }, devcontainerCLI: &fakeDevcontainerCLI{}, wantStatus: []int{http.StatusInternalServerError}, @@ -352,7 +387,7 @@ func TestAPI(t *testing.T) { { name: "Container not found", containerID: "nonexistent-container", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{validContainer}, }, @@ -364,7 +399,7 @@ func TestAPI(t *testing.T) { { name: "Missing workspace folder label", containerID: "missing-folder-container", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{missingFolderContainer}, }, @@ -376,13 +411,13 @@ func TestAPI(t *testing.T) { { name: "Devcontainer CLI error", containerID: "container-id", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{validContainer}, }, }, devcontainerCLI: &fakeDevcontainerCLI{ - err: xerrors.New("devcontainer CLI error"), + upErr: xerrors.New("devcontainer CLI error"), }, wantStatus: []int{http.StatusAccepted, http.StatusConflict}, wantBody: []string{"Devcontainer recreation initiated", "Devcontainer recreation already in progress"}, @@ -390,7 +425,7 @@ func TestAPI(t *testing.T) { { name: "OK", containerID: "container-id", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{validContainer}, }, @@ -416,14 +451,14 @@ func TestAPI(t *testing.T) { nowRecreateErrorTrap := mClock.Trap().Now("recreate", "errorTimes") nowRecreateSuccessTrap := mClock.Trap().Now("recreate", "successTimes") - tt.devcontainerCLI.continueUp = make(chan struct{}) + tt.devcontainerCLI.upErrC = make(chan error) // Setup router with the handler under test. r := chi.NewRouter() api := agentcontainers.NewAPI( logger, agentcontainers.WithClock(mClock), - agentcontainers.WithLister(tt.lister), + agentcontainers.WithContainerCLI(tt.lister), agentcontainers.WithDevcontainerCLI(tt.devcontainerCLI), agentcontainers.WithWatcher(watcher.NewNoop()), ) @@ -454,7 +489,7 @@ func TestAPI(t *testing.T) { // because we must check what state the devcontainer ends up in // after the recreation process is initiated and finished. if tt.wantStatus[0] != http.StatusAccepted { - close(tt.devcontainerCLI.continueUp) + close(tt.devcontainerCLI.upErrC) nowRecreateSuccessTrap.Close() nowRecreateErrorTrap.Close() return @@ -481,10 +516,10 @@ func TestAPI(t *testing.T) { assert.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusStarting, resp.Devcontainers[0].Container.DevcontainerStatus, "container dc status is not starting") // Allow the devcontainer CLI to continue the up process. - close(tt.devcontainerCLI.continueUp) + close(tt.devcontainerCLI.upErrC) // Ensure the devcontainer ends up in error state if the up call fails. - if tt.devcontainerCLI.err != nil { + if tt.devcontainerCLI.upErr != nil { nowRecreateSuccessTrap.Close() // The timestamp for the error will be stored, which gives // us a good anchor point to know when to do our request. @@ -559,7 +594,7 @@ func TestAPI(t *testing.T) { tests := []struct { name string - lister *fakeLister + lister *fakeContainerCLI knownDevcontainers []codersdk.WorkspaceAgentDevcontainer wantStatus int wantCount int @@ -567,20 +602,20 @@ func TestAPI(t *testing.T) { }{ { name: "List error", - lister: &fakeLister{ - err: xerrors.New("list error"), + lister: &fakeContainerCLI{ + listErr: xerrors.New("list error"), }, wantStatus: http.StatusInternalServerError, }, { name: "Empty containers", - lister: &fakeLister{}, + lister: &fakeContainerCLI{}, wantStatus: http.StatusOK, wantCount: 0, }, { name: "Only known devcontainers, no containers", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{}, }, @@ -597,7 +632,7 @@ func TestAPI(t *testing.T) { }, { name: "Runtime-detected devcontainer", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -631,7 +666,7 @@ func TestAPI(t *testing.T) { }, { name: "Mixed known and runtime-detected devcontainers", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -679,7 +714,7 @@ func TestAPI(t *testing.T) { }, { name: "Both running and non-running containers have container references", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -723,7 +758,7 @@ func TestAPI(t *testing.T) { }, { name: "Config path update", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -759,7 +794,7 @@ func TestAPI(t *testing.T) { }, { name: "Name generation and uniqueness", - lister: &fakeLister{ + lister: &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -831,7 +866,7 @@ func TestAPI(t *testing.T) { r := chi.NewRouter() apiOptions := []agentcontainers.Option{ agentcontainers.WithClock(mClock), - agentcontainers.WithLister(tt.lister), + agentcontainers.WithContainerCLI(tt.lister), agentcontainers.WithWatcher(watcher.NewNoop()), } @@ -914,7 +949,7 @@ func TestAPI(t *testing.T) { ctx := testutil.Context(t, testutil.WaitShort) logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) - fLister := &fakeLister{ + fLister := &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{container}, }, @@ -926,7 +961,7 @@ func TestAPI(t *testing.T) { api := agentcontainers.NewAPI(logger, agentcontainers.WithClock(mClock), - agentcontainers.WithLister(fLister), + agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithDevcontainers( []codersdk.WorkspaceAgentDevcontainer{dc}, @@ -1013,7 +1048,7 @@ func TestAPI(t *testing.T) { mClock.Set(startTime) tickerTrap := mClock.Trap().TickerFunc("updaterLoop") fWatcher := newFakeWatcher(t) - fLister := &fakeLister{ + fLister := &fakeContainerCLI{ containers: codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{container}, }, @@ -1022,7 +1057,7 @@ func TestAPI(t *testing.T) { logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug) api := agentcontainers.NewAPI( logger, - agentcontainers.WithLister(fLister), + agentcontainers.WithContainerCLI(fLister), agentcontainers.WithWatcher(fWatcher), agentcontainers.WithClock(mClock), ) diff --git a/agent/agentcontainers/containers.go b/agent/agentcontainers/containers.go index 5be288781d480..e728507e8f394 100644 --- a/agent/agentcontainers/containers.go +++ b/agent/agentcontainers/containers.go @@ -6,19 +6,32 @@ import ( "github.com/coder/coder/v2/codersdk" ) -// Lister is an interface for listing containers visible to the -// workspace agent. -type Lister interface { +// ContainerCLI is an interface for interacting with containers in a workspace. +type ContainerCLI interface { // List returns a list of containers visible to the workspace agent. // This should include running and stopped containers. List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) + // DetectArchitecture detects the architecture of a container. + DetectArchitecture(ctx context.Context, containerName string) (string, error) + // Copy copies a file from the host to a container. + Copy(ctx context.Context, containerName, src, dst string) error + // ExecAs executes a command in a container as a specific user. + ExecAs(ctx context.Context, containerName, user string, args ...string) ([]byte, error) } -// NoopLister is a Lister interface that never returns any containers. -type NoopLister struct{} +// noopContainerCLI is a ContainerCLI that does nothing. +type noopContainerCLI struct{} -var _ Lister = NoopLister{} +var _ ContainerCLI = noopContainerCLI{} -func (NoopLister) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (noopContainerCLI) List(_ context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { return codersdk.WorkspaceAgentListContainersResponse{}, nil } + +func (noopContainerCLI) DetectArchitecture(_ context.Context, _ string) (string, error) { + return "", nil +} +func (noopContainerCLI) Copy(_ context.Context, _ string, _ string, _ string) error { return nil } +func (noopContainerCLI) ExecAs(_ context.Context, _ string, _ string, _ ...string) ([]byte, error) { + return nil, nil +} diff --git a/agent/agentcontainers/containers_dockercli.go b/agent/agentcontainers/containers_dockercli.go index d5499f6b1af2b..83463481c97f7 100644 --- a/agent/agentcontainers/containers_dockercli.go +++ b/agent/agentcontainers/containers_dockercli.go @@ -228,23 +228,23 @@ func run(ctx context.Context, execer agentexec.Execer, cmd string, args ...strin return stdout, stderr, err } -// DockerCLILister is a ContainerLister that lists containers using the docker CLI -type DockerCLILister struct { +// dockerCLI is an implementation for Docker CLI that lists containers. +type dockerCLI struct { execer agentexec.Execer } -var _ Lister = &DockerCLILister{} +var _ ContainerCLI = (*dockerCLI)(nil) -func NewDocker(execer agentexec.Execer) Lister { - return &DockerCLILister{ - execer: agentexec.DefaultExecer, +func NewDockerCLI(execer agentexec.Execer) ContainerCLI { + return &dockerCLI{ + execer: execer, } } -func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { +func (dcli *dockerCLI) List(ctx context.Context) (codersdk.WorkspaceAgentListContainersResponse, error) { var stdoutBuf, stderrBuf bytes.Buffer // List all container IDs, one per line, with no truncation - cmd := dcl.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") + cmd := dcli.execer.CommandContext(ctx, "docker", "ps", "--all", "--quiet", "--no-trunc") cmd.Stdout = &stdoutBuf cmd.Stderr = &stderrBuf if err := cmd.Run(); err != nil { @@ -288,7 +288,7 @@ func (dcl *DockerCLILister) List(ctx context.Context) (codersdk.WorkspaceAgentLi // will still contain valid JSON. We will just end up missing // information about the removed container. We could potentially // log this error, but I'm not sure it's worth it. - dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcl.execer, ids...) + dockerInspectStdout, dockerInspectStderr, err := runDockerInspect(ctx, dcli.execer, ids...) if err != nil { return codersdk.WorkspaceAgentListContainersResponse{}, xerrors.Errorf("run docker inspect: %w: %s", err, dockerInspectStderr) } @@ -517,3 +517,71 @@ func isLoopbackOrUnspecified(ips string) bool { } return nip.IsLoopback() || nip.IsUnspecified() } + +// DetectArchitecture detects the architecture of a container by inspecting its +// image. +func (dcli *dockerCLI) DetectArchitecture(ctx context.Context, containerName string) (string, error) { + // Inspect the container to get the image name, which contains the architecture. + stdout, stderr, err := runCmd(ctx, dcli.execer, "docker", "inspect", "--format", "{{.Config.Image}}", containerName) + if err != nil { + return "", xerrors.Errorf("inspect container %s: %w: %s", containerName, err, stderr) + } + imageName := string(stdout) + if imageName == "" { + return "", xerrors.Errorf("no image found for container %s", containerName) + } + + stdout, stderr, err = runCmd(ctx, dcli.execer, "docker", "inspect", "--format", "{{.Architecture}}", imageName) + if err != nil { + return "", xerrors.Errorf("inspect image %s: %w: %s", imageName, err, stderr) + } + arch := string(stdout) + if arch == "" { + return "", xerrors.Errorf("no architecture found for image %s", imageName) + } + return arch, nil +} + +// Copy copies a file from the host to a container. +func (dcli *dockerCLI) Copy(ctx context.Context, containerName, src, dst string) error { + _, stderr, err := runCmd(ctx, dcli.execer, "docker", "cp", src, containerName+":"+dst) + if err != nil { + return xerrors.Errorf("copy %s to %s:%s: %w: %s", src, containerName, dst, err, stderr) + } + return nil +} + +// ExecAs executes a command in a container as a specific user. +func (dcli *dockerCLI) ExecAs(ctx context.Context, containerName, uid string, args ...string) ([]byte, error) { + execArgs := []string{"exec"} + if uid != "" { + altUID := uid + if uid == "root" { + // UID 0 is more portable than the name root, so we use that + // because some containers may not have a user named "root". + altUID = "0" + } + execArgs = append(execArgs, "--user", altUID) + } + execArgs = append(execArgs, containerName) + execArgs = append(execArgs, args...) + + stdout, stderr, err := runCmd(ctx, dcli.execer, "docker", execArgs...) + if err != nil { + return nil, xerrors.Errorf("exec in container %s as user %s: %w: %s", containerName, uid, err, stderr) + } + return stdout, nil +} + +// runCmd is a helper function that runs a command with the given +// arguments and returns the stdout and stderr output. +func runCmd(ctx context.Context, execer agentexec.Execer, cmd string, args ...string) (stdout, stderr []byte, err error) { + var stdoutBuf, stderrBuf bytes.Buffer + c := execer.CommandContext(ctx, cmd, args...) + c.Stdout = &stdoutBuf + c.Stderr = &stderrBuf + err = c.Run() + stdout = bytes.TrimSpace(stdoutBuf.Bytes()) + stderr = bytes.TrimSpace(stderrBuf.Bytes()) + return stdout, stderr, err +} diff --git a/agent/agentcontainers/containers_dockercli_test.go b/agent/agentcontainers/containers_dockercli_test.go new file mode 100644 index 0000000000000..c69110a757bc7 --- /dev/null +++ b/agent/agentcontainers/containers_dockercli_test.go @@ -0,0 +1,126 @@ +package agentcontainers_test + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/agentexec" + "github.com/coder/coder/v2/testutil" +) + +// TestIntegrationDockerCLI tests the DetectArchitecture, Copy, and +// ExecAs methods using a real Docker container. All tests share a +// single container to avoid setup overhead. +// +// Run manually with: CODER_TEST_USE_DOCKER=1 go test ./agent/agentcontainers -run TestIntegrationDockerCLI +// +//nolint:tparallel,paralleltest // Docker integration tests don't run in parallel to avoid flakiness. +func TestIntegrationDockerCLI(t *testing.T) { + if ctud, ok := os.LookupEnv("CODER_TEST_USE_DOCKER"); !ok || ctud != "1" { + t.Skip("Set CODER_TEST_USE_DOCKER=1 to run this test") + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err, "Could not connect to docker") + + // Start a simple busybox container for all subtests to share. + ct, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "busybox", + Tag: "latest", + Cmd: []string{"sleep", "infinity"}, + }, func(config *docker.HostConfig) { + config.AutoRemove = true + config.RestartPolicy = docker.RestartPolicy{Name: "no"} + }) + require.NoError(t, err, "Could not start test docker container") + t.Logf("Created container %q", ct.Container.Name) + t.Cleanup(func() { + assert.NoError(t, pool.Purge(ct), "Could not purge resource %q", ct.Container.Name) + t.Logf("Purged container %q", ct.Container.Name) + }) + + // Wait for container to start. + require.Eventually(t, func() bool { + ct, ok := pool.ContainerByName(ct.Container.Name) + return ok && ct.Container.State.Running + }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") + + dcli := agentcontainers.NewDockerCLI(agentexec.DefaultExecer) + ctx := testutil.Context(t, testutil.WaitMedium) // Longer timeout for multiple subtests + containerName := strings.TrimPrefix(ct.Container.Name, "/") + + t.Run("DetectArchitecture", func(t *testing.T) { + t.Parallel() + + arch, err := dcli.DetectArchitecture(ctx, containerName) + require.NoError(t, err, "DetectArchitecture failed") + require.NotEmpty(t, arch, "arch has no content") + require.Equal(t, runtime.GOARCH, arch, "architecture does not match runtime, did you run this test with a remote Docker socket?") + + t.Logf("Detected architecture: %s", arch) + }) + + t.Run("Copy", func(t *testing.T) { + t.Parallel() + + want := "Help, I'm trapped!" + tempFile := filepath.Join(t.TempDir(), "test-file.txt") + err := os.WriteFile(tempFile, []byte(want), 0o600) + require.NoError(t, err, "create test file failed") + + destPath := "/tmp/copied-file.txt" + err = dcli.Copy(ctx, containerName, tempFile, destPath) + require.NoError(t, err, "Copy failed") + + got, err := dcli.ExecAs(ctx, containerName, "", "cat", destPath) + require.NoError(t, err, "ExecAs failed after Copy") + require.Equal(t, want, string(got), "copied file content did not match original") + + t.Logf("Successfully copied file from %s to container %s:%s", tempFile, containerName, destPath) + }) + + t.Run("ExecAs", func(t *testing.T) { + t.Parallel() + + // Test ExecAs without specifying user (should use container's default). + want := "root" + got, err := dcli.ExecAs(ctx, containerName, "", "whoami") + require.NoError(t, err, "ExecAs without user should succeed") + require.Equal(t, want, string(got), "ExecAs without user should output expected string") + + // Test ExecAs with numeric UID (non root). + want = "1000" + _, err = dcli.ExecAs(ctx, containerName, want, "whoami") + require.Error(t, err, "ExecAs with UID 1000 should fail as user does not exist in busybox") + require.Contains(t, err.Error(), "whoami: unknown uid 1000", "ExecAs with UID 1000 should return 'unknown uid' error") + + // Test ExecAs with root user (should convert "root" to "0", which still outputs root due to passwd). + want = "root" + got, err = dcli.ExecAs(ctx, containerName, "root", "whoami") + require.NoError(t, err, "ExecAs with root user should succeed") + require.Equal(t, want, string(got), "ExecAs with root user should output expected string") + + // Test ExecAs with numeric UID. + want = "root" + got, err = dcli.ExecAs(ctx, containerName, "0", "whoami") + require.NoError(t, err, "ExecAs with UID 0 should succeed") + require.Equal(t, want, string(got), "ExecAs with UID 0 should output expected string") + + // Test ExecAs with multiple arguments. + want = "multiple args test" + got, err = dcli.ExecAs(ctx, containerName, "", "sh", "-c", "echo '"+want+"'") + require.NoError(t, err, "ExecAs with multiple arguments should succeed") + require.Equal(t, want, string(got), "ExecAs with multiple arguments should output expected string") + + t.Logf("Successfully executed commands in container %s", containerName) + }) +} diff --git a/agent/agentcontainers/containers_test.go b/agent/agentcontainers/containers_test.go index 59befb2fd2be0..387c8dccc961d 100644 --- a/agent/agentcontainers/containers_test.go +++ b/agent/agentcontainers/containers_test.go @@ -78,7 +78,7 @@ func TestIntegrationDocker(t *testing.T) { return ok && ct.Container.State.Running }, testutil.WaitShort, testutil.IntervalSlow, "Container did not start in time") - dcl := agentcontainers.NewDocker(agentexec.DefaultExecer) + dcl := agentcontainers.NewDockerCLI(agentexec.DefaultExecer) ctx := testutil.Context(t, testutil.WaitShort) actual, err := dcl.List(ctx) require.NoError(t, err, "Could not list containers") diff --git a/agent/agentcontainers/devcontainercli.go b/agent/agentcontainers/devcontainercli.go index 7e3122b182fdb..94b4de610a93b 100644 --- a/agent/agentcontainers/devcontainercli.go +++ b/agent/agentcontainers/devcontainercli.go @@ -17,38 +17,83 @@ import ( // DevcontainerCLI is an interface for the devcontainer CLI. type DevcontainerCLI interface { Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (id string, err error) + Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error } -// DevcontainerCLIUpOptions are options for the devcontainer CLI up +// DevcontainerCLIUpOptions are options for the devcontainer CLI Up // command. type DevcontainerCLIUpOptions func(*devcontainerCLIUpConfig) +type devcontainerCLIUpConfig struct { + args []string // Additional arguments for the Up command. + stdout io.Writer + stderr io.Writer +} + // WithRemoveExistingContainer is an option to remove the existing // container. func WithRemoveExistingContainer() DevcontainerCLIUpOptions { return func(o *devcontainerCLIUpConfig) { - o.removeExistingContainer = true + o.args = append(o.args, "--remove-existing-container") } } -// WithOutput sets stdout and stderr writers for Up command logs. -func WithOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions { +// WithUpOutput sets additional stdout and stderr writers for logs +// during Up operations. +func WithUpOutput(stdout, stderr io.Writer) DevcontainerCLIUpOptions { return func(o *devcontainerCLIUpConfig) { o.stdout = stdout o.stderr = stderr } } -type devcontainerCLIUpConfig struct { - removeExistingContainer bool - stdout io.Writer - stderr io.Writer +// DevcontainerCLIExecOptions are options for the devcontainer CLI Exec +// command. +type DevcontainerCLIExecOptions func(*devcontainerCLIExecConfig) + +type devcontainerCLIExecConfig struct { + args []string // Additional arguments for the Exec command. + stdout io.Writer + stderr io.Writer +} + +// WithExecOutput sets additional stdout and stderr writers for logs +// during Exec operations. +func WithExecOutput(stdout, stderr io.Writer) DevcontainerCLIExecOptions { + return func(o *devcontainerCLIExecConfig) { + o.stdout = stdout + o.stderr = stderr + } +} + +// WithContainerID sets the container ID to target a specific container. +func WithContainerID(id string) DevcontainerCLIExecOptions { + return func(o *devcontainerCLIExecConfig) { + o.args = append(o.args, "--container-id", id) + } +} + +// WithRemoteEnv sets environment variables for the Exec command. +func WithRemoteEnv(env ...string) DevcontainerCLIExecOptions { + return func(o *devcontainerCLIExecConfig) { + for _, e := range env { + o.args = append(o.args, "--remote-env", e) + } + } } func applyDevcontainerCLIUpOptions(opts []DevcontainerCLIUpOptions) devcontainerCLIUpConfig { - conf := devcontainerCLIUpConfig{ - removeExistingContainer: false, + conf := devcontainerCLIUpConfig{} + for _, opt := range opts { + if opt != nil { + opt(&conf) + } } + return conf +} + +func applyDevcontainerCLIExecOptions(opts []DevcontainerCLIExecOptions) devcontainerCLIExecConfig { + conf := devcontainerCLIExecConfig{} for _, opt := range opts { if opt != nil { opt(&conf) @@ -73,7 +118,7 @@ func NewDevcontainerCLI(logger slog.Logger, execer agentexec.Execer) Devcontaine func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...DevcontainerCLIUpOptions) (string, error) { conf := applyDevcontainerCLIUpOptions(opts) - logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath), slog.F("recreate", conf.removeExistingContainer)) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) args := []string{ "up", @@ -83,9 +128,7 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st if configPath != "" { args = append(args, "--config", configPath) } - if conf.removeExistingContainer { - args = append(args, "--remove-existing-container") - } + args = append(args, conf.args...) cmd := d.execer.CommandContext(ctx, "devcontainer", args...) // Capture stdout for parsing and stream logs for both default and provided writers. @@ -117,6 +160,40 @@ func (d *devcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath st return result.ContainerID, nil } +func (d *devcontainerCLI) Exec(ctx context.Context, workspaceFolder, configPath string, cmd string, cmdArgs []string, opts ...DevcontainerCLIExecOptions) error { + conf := applyDevcontainerCLIExecOptions(opts) + logger := d.logger.With(slog.F("workspace_folder", workspaceFolder), slog.F("config_path", configPath)) + + args := []string{"exec"} + if workspaceFolder != "" { + args = append(args, "--workspace-folder", workspaceFolder) + } + if configPath != "" { + args = append(args, "--config", configPath) + } + args = append(args, conf.args...) + args = append(args, cmd) + args = append(args, cmdArgs...) + c := d.execer.CommandContext(ctx, "devcontainer", args...) + + stdoutWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stdout", true))}} + if conf.stdout != nil { + stdoutWriters = append(stdoutWriters, conf.stdout) + } + c.Stdout = io.MultiWriter(stdoutWriters...) + stderrWriters := []io.Writer{&devcontainerCLILogWriter{ctx: ctx, logger: logger.With(slog.F("stderr", true))}} + if conf.stderr != nil { + stderrWriters = append(stderrWriters, conf.stderr) + } + c.Stderr = io.MultiWriter(stderrWriters...) + + if err := c.Run(); err != nil { + return xerrors.Errorf("devcontainer exec failed: %w", err) + } + + return nil +} + // parseDevcontainerCLILastLine parses the last line of the devcontainer CLI output // which is a JSON object. func parseDevcontainerCLILastLine(ctx context.Context, logger slog.Logger, p []byte) (result devcontainerCLIResult, err error) { diff --git a/agent/agentcontainers/devcontainercli_test.go b/agent/agentcontainers/devcontainercli_test.go index cdba0211ab94e..48325ab83fb21 100644 --- a/agent/agentcontainers/devcontainercli_test.go +++ b/agent/agentcontainers/devcontainercli_test.go @@ -126,9 +126,116 @@ func TestDevcontainerCLI_ArgsAndParsing(t *testing.T) { }) } }) + + t.Run("Exec", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + workspaceFolder string + configPath string + cmd string + cmdArgs []string + opts []agentcontainers.DevcontainerCLIExecOptions + wantArgs string + wantError bool + }{ + { + name: "simple command", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "echo", + cmdArgs: []string{"hello"}, + wantArgs: "exec --workspace-folder /test/workspace echo hello", + wantError: false, + }, + { + name: "command with multiple args", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + cmd: "ls", + cmdArgs: []string{"-la", "/workspace"}, + wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json ls -la /workspace", + wantError: false, + }, + { + name: "empty command args", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "bash", + cmdArgs: nil, + wantArgs: "exec --workspace-folder /test/workspace bash", + wantError: false, + }, + { + name: "workspace not found", + workspaceFolder: "/nonexistent/workspace", + configPath: "", + cmd: "echo", + cmdArgs: []string{"test"}, + wantArgs: "exec --workspace-folder /nonexistent/workspace echo test", + wantError: true, + }, + { + name: "with container ID", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "echo", + cmdArgs: []string{"hello"}, + opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithContainerID("test-container-123")}, + wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-123 echo hello", + wantError: false, + }, + { + name: "with container ID and config", + workspaceFolder: "/test/workspace", + configPath: "/test/config.json", + cmd: "bash", + cmdArgs: []string{"-c", "ls -la"}, + opts: []agentcontainers.DevcontainerCLIExecOptions{agentcontainers.WithContainerID("my-container")}, + wantArgs: "exec --workspace-folder /test/workspace --config /test/config.json --container-id my-container bash -c ls -la", + wantError: false, + }, + { + name: "with container ID and output capture", + workspaceFolder: "/test/workspace", + configPath: "", + cmd: "cat", + cmdArgs: []string{"/etc/hostname"}, + opts: []agentcontainers.DevcontainerCLIExecOptions{ + agentcontainers.WithContainerID("test-container-789"), + }, + wantArgs: "exec --workspace-folder /test/workspace --container-id test-container-789 cat /etc/hostname", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitMedium) + + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: tt.wantArgs, + wantError: tt.wantError, + logFile: "", // Exec doesn't need log file parsing + } + + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + err := dccli.Exec(ctx, tt.workspaceFolder, tt.configPath, tt.cmd, tt.cmdArgs, tt.opts...) + if tt.wantError { + assert.Error(t, err, "want error") + } else { + assert.NoError(t, err, "want no error") + } + }) + } + }) } -// TestDevcontainerCLI_WithOutput tests that WithOutput captures CLI +// TestDevcontainerCLI_WithOutput tests that WithUpOutput and WithExecOutput capture CLI // logs to provided writers. func TestDevcontainerCLI_WithOutput(t *testing.T) { t.Parallel() @@ -136,35 +243,77 @@ func TestDevcontainerCLI_WithOutput(t *testing.T) { // Prepare test executable and logger. testExePath, err := os.Executable() require.NoError(t, err, "get test executable path") - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) - ctx := testutil.Context(t, testutil.WaitMedium) - - // Buffers to capture stdout and stderr. - outBuf := &bytes.Buffer{} - errBuf := &bytes.Buffer{} - - // Simulate CLI execution with a standard up.log file. - wantArgs := "up --log-format json --workspace-folder /test/workspace" - testExecer := &testDevcontainerExecer{ - testExePath: testExePath, - wantArgs: wantArgs, - wantError: false, - logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"), - } - dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) - // Call Up with WithOutput to capture CLI logs. - containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithOutput(outBuf, errBuf)) - require.NoError(t, err, "Up should succeed") - require.NotEmpty(t, containerID, "expected non-empty container ID") + t.Run("Up", func(t *testing.T) { + t.Parallel() + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution with a standard up.log file. + wantArgs := "up --log-format json --workspace-folder /test/workspace" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: filepath.Join("testdata", "devcontainercli", "parse", "up.log"), + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) - // Read expected log content. - expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log")) - require.NoError(t, err, "reading expected log file") + // Call Up with WithUpOutput to capture CLI logs. + ctx := testutil.Context(t, testutil.WaitMedium) + containerID, err := dccli.Up(ctx, "/test/workspace", "", agentcontainers.WithUpOutput(outBuf, errBuf)) + require.NoError(t, err, "Up should succeed") + require.NotEmpty(t, containerID, "expected non-empty container ID") - // Verify stdout buffer contains the CLI logs and stderr is empty. - assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") - assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") + // Read expected log content. + expLog, err := os.ReadFile(filepath.Join("testdata", "devcontainercli", "parse", "up.log")) + require.NoError(t, err, "reading expected log file") + + // Verify stdout buffer contains the CLI logs and stderr is empty. + assert.Equal(t, string(expLog), outBuf.String(), "stdout buffer should match CLI logs") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty on success") + }) + + t.Run("Exec", func(t *testing.T) { + t.Parallel() + + logFile := filepath.Join(t.TempDir(), "exec.log") + f, err := os.Create(logFile) + require.NoError(t, err, "create exec log file") + _, err = f.WriteString("exec command log\n") + require.NoError(t, err, "write to exec log file") + err = f.Close() + require.NoError(t, err, "close exec log file") + + // Buffers to capture stdout and stderr. + outBuf := &bytes.Buffer{} + errBuf := &bytes.Buffer{} + + // Simulate CLI execution for exec command with container ID. + wantArgs := "exec --workspace-folder /test/workspace --container-id test-container-456 echo hello" + testExecer := &testDevcontainerExecer{ + testExePath: testExePath, + wantArgs: wantArgs, + wantError: false, + logFile: logFile, + } + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + dccli := agentcontainers.NewDevcontainerCLI(logger, testExecer) + + // Call Exec with WithExecOutput and WithContainerID to capture any command output. + ctx := testutil.Context(t, testutil.WaitMedium) + err = dccli.Exec(ctx, "/test/workspace", "", "echo", []string{"hello"}, + agentcontainers.WithContainerID("test-container-456"), + agentcontainers.WithExecOutput(outBuf, errBuf), + ) + require.NoError(t, err, "Exec should succeed") + + assert.NotEmpty(t, outBuf.String(), "stdout buffer should not be empty for exec with log file") + assert.Empty(t, errBuf.String(), "stderr buffer should be empty") + }) } // testDevcontainerExecer implements the agentexec.Execer interface for testing. @@ -243,13 +392,16 @@ func TestDevcontainerHelperProcess(t *testing.T) { } logFilePath := os.Getenv("TEST_DEVCONTAINER_LOG_FILE") - output, err := os.ReadFile(logFilePath) - if err != nil { - fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err) - os.Exit(2) + if logFilePath != "" { + // Read and output log file for commands that need it (like "up") + output, err := os.ReadFile(logFilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Reading log file %s failed: %v\n", logFilePath, err) + os.Exit(2) + } + _, _ = io.Copy(os.Stdout, bytes.NewReader(output)) } - _, _ = io.Copy(os.Stdout, bytes.NewReader(output)) if os.Getenv("TEST_DEVCONTAINER_WANT_ERROR") == "true" { os.Exit(1) } diff --git a/agent/proto/agent.pb.go b/agent/proto/agent.pb.go index 7e3f824d27344..f3656acf3978b 100644 --- a/agent/proto/agent.pb.go +++ b/agent/proto/agent.pb.go @@ -620,6 +620,61 @@ func (Connection_Type) EnumDescriptor() ([]byte, []int) { return file_agent_proto_agent_proto_rawDescGZIP(), []int{33, 1} } +type CreateSubAgentRequest_DisplayApp int32 + +const ( + CreateSubAgentRequest_VSCODE CreateSubAgentRequest_DisplayApp = 0 + CreateSubAgentRequest_VSCODE_INSIDERS CreateSubAgentRequest_DisplayApp = 1 + CreateSubAgentRequest_WEB_TERMINAL CreateSubAgentRequest_DisplayApp = 2 + CreateSubAgentRequest_SSH_HELPER CreateSubAgentRequest_DisplayApp = 3 + CreateSubAgentRequest_PORT_FORWARDING_HELPER CreateSubAgentRequest_DisplayApp = 4 +) + +// Enum value maps for CreateSubAgentRequest_DisplayApp. +var ( + CreateSubAgentRequest_DisplayApp_name = map[int32]string{ + 0: "VSCODE", + 1: "VSCODE_INSIDERS", + 2: "WEB_TERMINAL", + 3: "SSH_HELPER", + 4: "PORT_FORWARDING_HELPER", + } + CreateSubAgentRequest_DisplayApp_value = map[string]int32{ + "VSCODE": 0, + "VSCODE_INSIDERS": 1, + "WEB_TERMINAL": 2, + "SSH_HELPER": 3, + "PORT_FORWARDING_HELPER": 4, + } +) + +func (x CreateSubAgentRequest_DisplayApp) Enum() *CreateSubAgentRequest_DisplayApp { + p := new(CreateSubAgentRequest_DisplayApp) + *p = x + return p +} + +func (x CreateSubAgentRequest_DisplayApp) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (CreateSubAgentRequest_DisplayApp) Descriptor() protoreflect.EnumDescriptor { + return file_agent_proto_agent_proto_enumTypes[11].Descriptor() +} + +func (CreateSubAgentRequest_DisplayApp) Type() protoreflect.EnumType { + return &file_agent_proto_agent_proto_enumTypes[11] +} + +func (x CreateSubAgentRequest_DisplayApp) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use CreateSubAgentRequest_DisplayApp.Descriptor instead. +func (CreateSubAgentRequest_DisplayApp) EnumDescriptor() ([]byte, []int) { + return file_agent_proto_agent_proto_rawDescGZIP(), []int{36, 0} +} + type CreateSubAgentRequest_App_OpenIn int32 const ( @@ -650,11 +705,11 @@ func (x CreateSubAgentRequest_App_OpenIn) String() string { } func (CreateSubAgentRequest_App_OpenIn) Descriptor() protoreflect.EnumDescriptor { - return file_agent_proto_agent_proto_enumTypes[11].Descriptor() + return file_agent_proto_agent_proto_enumTypes[12].Descriptor() } func (CreateSubAgentRequest_App_OpenIn) Type() protoreflect.EnumType { - return &file_agent_proto_agent_proto_enumTypes[11] + return &file_agent_proto_agent_proto_enumTypes[12] } func (x CreateSubAgentRequest_App_OpenIn) Number() protoreflect.EnumNumber { @@ -699,11 +754,11 @@ func (x CreateSubAgentRequest_App_Share) String() string { } func (CreateSubAgentRequest_App_Share) Descriptor() protoreflect.EnumDescriptor { - return file_agent_proto_agent_proto_enumTypes[12].Descriptor() + return file_agent_proto_agent_proto_enumTypes[13].Descriptor() } func (CreateSubAgentRequest_App_Share) Type() protoreflect.EnumType { - return &file_agent_proto_agent_proto_enumTypes[12] + return &file_agent_proto_agent_proto_enumTypes[13] } func (x CreateSubAgentRequest_App_Share) Number() protoreflect.EnumNumber { @@ -2987,11 +3042,12 @@ type CreateSubAgentRequest struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` - Directory string `protobuf:"bytes,2,opt,name=directory,proto3" json:"directory,omitempty"` - Architecture string `protobuf:"bytes,3,opt,name=architecture,proto3" json:"architecture,omitempty"` - OperatingSystem string `protobuf:"bytes,4,opt,name=operating_system,json=operatingSystem,proto3" json:"operating_system,omitempty"` - Apps []*CreateSubAgentRequest_App `protobuf:"bytes,5,rep,name=apps,proto3" json:"apps,omitempty"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Directory string `protobuf:"bytes,2,opt,name=directory,proto3" json:"directory,omitempty"` + Architecture string `protobuf:"bytes,3,opt,name=architecture,proto3" json:"architecture,omitempty"` + OperatingSystem string `protobuf:"bytes,4,opt,name=operating_system,json=operatingSystem,proto3" json:"operating_system,omitempty"` + Apps []*CreateSubAgentRequest_App `protobuf:"bytes,5,rep,name=apps,proto3" json:"apps,omitempty"` + DisplayApps []CreateSubAgentRequest_DisplayApp `protobuf:"varint,6,rep,packed,name=display_apps,json=displayApps,proto3,enum=coder.agent.v2.CreateSubAgentRequest_DisplayApp" json:"display_apps,omitempty"` } func (x *CreateSubAgentRequest) Reset() { @@ -3061,6 +3117,13 @@ func (x *CreateSubAgentRequest) GetApps() []*CreateSubAgentRequest_App { return nil } +func (x *CreateSubAgentRequest) GetDisplayApps() []CreateSubAgentRequest_DisplayApp { + if x != nil { + return x.DisplayApps + } + return nil +} + type CreateSubAgentResponse struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -4813,7 +4876,7 @@ var file_agent_proto_agent_proto_rawDesc = []byte{ 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, - 0xbb, 0x08, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0xfd, 0x09, 0x0a, 0x15, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x6f, 0x72, 0x79, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, @@ -4826,204 +4889,216 @@ var file_agent_proto_agent_proto_rawDesc = []byte{ 0x70, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, - 0x41, 0x70, 0x70, 0x52, 0x04, 0x61, 0x70, 0x70, 0x73, 0x1a, 0xe1, 0x06, 0x0a, 0x03, 0x41, 0x70, - 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, - 0x64, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, - 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x0b, 0x64, 0x69, - 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1f, 0x0a, 0x08, - 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, - 0x52, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, - 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x03, 0x52, 0x05, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x88, 0x01, 0x01, 0x12, 0x5c, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x6c, - 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x35, 0x2e, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, - 0x68, 0x65, 0x63, 0x6b, 0x48, 0x04, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, - 0x65, 0x63, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x48, 0x05, 0x52, 0x06, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, - 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x09, 0x48, 0x06, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x4e, 0x0a, 0x07, - 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, 0x18, 0x09, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x30, 0x2e, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x48, - 0x07, 0x52, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, - 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, 0x48, 0x08, 0x52, 0x05, 0x6f, - 0x72, 0x64, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x4a, 0x0a, 0x05, 0x73, 0x68, 0x61, 0x72, 0x65, - 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2f, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, - 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, - 0x70, 0x2e, 0x53, 0x68, 0x61, 0x72, 0x65, 0x48, 0x09, 0x52, 0x05, 0x73, 0x68, 0x61, 0x72, 0x65, - 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x09, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, - 0x61, 0x69, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x0d, 0x20, - 0x01, 0x28, 0x09, 0x48, 0x0b, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x88, 0x01, 0x01, 0x1a, 0x59, 0x0a, - 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x1a, 0x0a, 0x08, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x08, - 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, 0x09, 0x74, 0x68, 0x72, 0x65, - 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x09, 0x74, 0x68, 0x72, - 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x22, 0x0a, 0x06, 0x4f, 0x70, 0x65, 0x6e, - 0x49, 0x6e, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x4c, 0x49, 0x4d, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, - 0x57, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x41, 0x42, 0x10, 0x01, 0x22, 0x31, 0x0a, 0x05, - 0x53, 0x68, 0x61, 0x72, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, 0x4e, 0x45, 0x52, 0x10, 0x00, - 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, 0x49, 0x43, 0x41, 0x54, 0x45, - 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, 0x49, 0x43, 0x10, 0x02, 0x42, - 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x42, 0x0f, 0x0a, 0x0d, 0x5f, - 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x42, 0x0b, 0x0a, 0x09, - 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, - 0x65, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x69, 0x64, 0x64, 0x65, 0x6e, 0x42, 0x07, - 0x0a, 0x05, 0x5f, 0x69, 0x63, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x6f, 0x70, 0x65, 0x6e, - 0x5f, 0x69, 0x6e, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x42, 0x08, 0x0a, - 0x06, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x75, 0x62, 0x64, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x75, 0x72, 0x6c, 0x22, 0x96, 0x02, - 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, - 0x74, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x67, 0x0a, 0x13, 0x61, 0x70, 0x70, 0x5f, - 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x73, 0x18, - 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, - 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, 0x41, 0x70, - 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x52, 0x11, - 0x61, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, - 0x73, 0x1a, 0x63, 0x0a, 0x10, 0x41, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x19, 0x0a, 0x05, 0x66, - 0x69, 0x65, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, 0x66, 0x69, - 0x65, 0x6c, 0x64, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x42, 0x08, 0x0a, 0x06, - 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x22, 0x27, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x22, - 0x18, 0x0a, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, - 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x0a, 0x14, 0x4c, 0x69, 0x73, - 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x22, 0x49, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, - 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x06, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, 0x62, 0x41, - 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x2a, 0x63, 0x0a, 0x09, - 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, 0x50, 0x50, - 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, - 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, 0x4c, 0x45, - 0x44, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, 0x49, 0x5a, - 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, - 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x59, 0x10, - 0x04, 0x32, 0x91, 0x0d, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, 0x0b, 0x47, - 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x4d, - 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x18, - 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, - 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x53, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, - 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, - 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, - 0x61, 0x74, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, - 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, 0x0a, 0x0f, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, - 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, - 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, - 0x6c, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, - 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, - 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, - 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x17, 0x2e, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, - 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x2a, 0x2e, - 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, - 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, - 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, 0x64, 0x65, - 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, - 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, - 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, - 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, 0x47, 0x65, - 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, - 0x6e, 0x65, 0x72, 0x73, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, - 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, - 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, - 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, - 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, - 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, - 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, - 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x9e, 0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, - 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x2e, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, - 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, - 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, - 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, - 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x89, 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, - 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, - 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x33, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, - 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, - 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x63, 0x6f, 0x64, - 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, - 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, - 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, 0x65, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, - 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, - 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, 0x2e, - 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, - 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x5f, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, - 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x41, 0x70, 0x70, 0x52, 0x04, 0x61, 0x70, 0x70, 0x73, 0x12, 0x53, 0x0a, 0x0c, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x61, 0x70, 0x70, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0e, 0x32, + 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, + 0x70, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, 0x73, 0x1a, 0xe1, + 0x06, 0x0a, 0x03, 0x41, 0x70, 0x70, 0x12, 0x12, 0x0a, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x73, 0x6c, 0x75, 0x67, 0x12, 0x1d, 0x0a, 0x07, 0x63, 0x6f, + 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x07, 0x63, + 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, 0x88, 0x01, 0x01, 0x12, 0x26, 0x0a, 0x0c, 0x64, 0x69, 0x73, + 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x48, + 0x01, 0x52, 0x0b, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x4e, 0x61, 0x6d, 0x65, 0x88, 0x01, + 0x01, 0x12, 0x1f, 0x0a, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x08, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x88, + 0x01, 0x01, 0x12, 0x19, 0x0a, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x48, 0x03, 0x52, 0x05, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x88, 0x01, 0x01, 0x12, 0x5c, 0x0a, + 0x0b, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x35, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x48, 0x65, + 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x48, 0x04, 0x52, 0x0b, 0x68, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x88, 0x01, 0x01, 0x12, 0x1b, 0x0a, 0x06, 0x68, + 0x69, 0x64, 0x64, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x48, 0x05, 0x52, 0x06, 0x68, + 0x69, 0x64, 0x64, 0x65, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x17, 0x0a, 0x04, 0x69, 0x63, 0x6f, 0x6e, + 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x48, 0x06, 0x52, 0x04, 0x69, 0x63, 0x6f, 0x6e, 0x88, 0x01, + 0x01, 0x12, 0x4e, 0x0a, 0x07, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, 0x18, 0x09, 0x20, 0x01, + 0x28, 0x0e, 0x32, 0x30, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, + 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x4f, 0x70, + 0x65, 0x6e, 0x49, 0x6e, 0x48, 0x07, 0x52, 0x06, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x88, 0x01, + 0x01, 0x12, 0x19, 0x0a, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x05, + 0x48, 0x08, 0x52, 0x05, 0x6f, 0x72, 0x64, 0x65, 0x72, 0x88, 0x01, 0x01, 0x12, 0x4a, 0x0a, 0x05, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x2f, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, + 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x2e, 0x41, 0x70, 0x70, 0x2e, 0x53, 0x68, 0x61, 0x72, 0x65, 0x48, 0x09, 0x52, 0x05, + 0x73, 0x68, 0x61, 0x72, 0x65, 0x88, 0x01, 0x01, 0x12, 0x21, 0x0a, 0x09, 0x73, 0x75, 0x62, 0x64, + 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x08, 0x48, 0x0a, 0x52, 0x09, 0x73, + 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x88, 0x01, 0x01, 0x12, 0x15, 0x0a, 0x03, 0x75, + 0x72, 0x6c, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x09, 0x48, 0x0b, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x88, + 0x01, 0x01, 0x1a, 0x59, 0x0a, 0x0b, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, + 0x6b, 0x12, 0x1a, 0x0a, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x05, 0x52, 0x08, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x76, 0x61, 0x6c, 0x12, 0x1c, 0x0a, + 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x09, 0x74, 0x68, 0x72, 0x65, 0x73, 0x68, 0x6f, 0x6c, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x75, + 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x22, 0x0a, + 0x06, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x6e, 0x12, 0x0f, 0x0a, 0x0b, 0x53, 0x4c, 0x49, 0x4d, 0x5f, + 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x10, 0x00, 0x12, 0x07, 0x0a, 0x03, 0x54, 0x41, 0x42, 0x10, + 0x01, 0x22, 0x31, 0x0a, 0x05, 0x53, 0x68, 0x61, 0x72, 0x65, 0x12, 0x09, 0x0a, 0x05, 0x4f, 0x57, + 0x4e, 0x45, 0x52, 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x41, 0x55, 0x54, 0x48, 0x45, 0x4e, 0x54, + 0x49, 0x43, 0x41, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x0a, 0x0a, 0x06, 0x50, 0x55, 0x42, 0x4c, + 0x49, 0x43, 0x10, 0x02, 0x42, 0x0a, 0x0a, 0x08, 0x5f, 0x63, 0x6f, 0x6d, 0x6d, 0x61, 0x6e, 0x64, + 0x42, 0x0f, 0x0a, 0x0d, 0x5f, 0x64, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x5f, 0x6e, 0x61, 0x6d, + 0x65, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x65, 0x78, 0x74, 0x65, 0x72, 0x6e, 0x61, 0x6c, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x42, 0x0e, 0x0a, 0x0c, 0x5f, 0x68, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x42, 0x09, 0x0a, 0x07, 0x5f, 0x68, 0x69, 0x64, + 0x64, 0x65, 0x6e, 0x42, 0x07, 0x0a, 0x05, 0x5f, 0x69, 0x63, 0x6f, 0x6e, 0x42, 0x0a, 0x0a, 0x08, + 0x5f, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x6e, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x6f, 0x72, 0x64, + 0x65, 0x72, 0x42, 0x08, 0x0a, 0x06, 0x5f, 0x73, 0x68, 0x61, 0x72, 0x65, 0x42, 0x0c, 0x0a, 0x0a, + 0x5f, 0x73, 0x75, 0x62, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x42, 0x06, 0x0a, 0x04, 0x5f, 0x75, + 0x72, 0x6c, 0x22, 0x6b, 0x0a, 0x0a, 0x44, 0x69, 0x73, 0x70, 0x6c, 0x61, 0x79, 0x41, 0x70, 0x70, + 0x12, 0x0a, 0x0a, 0x06, 0x56, 0x53, 0x43, 0x4f, 0x44, 0x45, 0x10, 0x00, 0x12, 0x13, 0x0a, 0x0f, + 0x56, 0x53, 0x43, 0x4f, 0x44, 0x45, 0x5f, 0x49, 0x4e, 0x53, 0x49, 0x44, 0x45, 0x52, 0x53, 0x10, + 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x57, 0x45, 0x42, 0x5f, 0x54, 0x45, 0x52, 0x4d, 0x49, 0x4e, 0x41, + 0x4c, 0x10, 0x02, 0x12, 0x0e, 0x0a, 0x0a, 0x53, 0x53, 0x48, 0x5f, 0x48, 0x45, 0x4c, 0x50, 0x45, + 0x52, 0x10, 0x03, 0x12, 0x1a, 0x0a, 0x16, 0x50, 0x4f, 0x52, 0x54, 0x5f, 0x46, 0x4f, 0x52, 0x57, + 0x41, 0x52, 0x44, 0x49, 0x4e, 0x47, 0x5f, 0x48, 0x45, 0x4c, 0x50, 0x45, 0x52, 0x10, 0x04, 0x22, + 0x96, 0x02, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, + 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2e, 0x0a, 0x05, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, 0x62, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x52, 0x05, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x67, 0x0a, 0x13, 0x61, 0x70, + 0x70, 0x5f, 0x63, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x37, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, - 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, + 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2e, + 0x41, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, + 0x52, 0x11, 0x61, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x45, 0x72, 0x72, + 0x6f, 0x72, 0x73, 0x1a, 0x63, 0x0a, 0x10, 0x41, 0x70, 0x70, 0x43, 0x72, 0x65, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x45, 0x72, 0x72, 0x6f, 0x72, 0x12, 0x14, 0x0a, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x05, 0x69, 0x6e, 0x64, 0x65, 0x78, 0x12, 0x19, 0x0a, + 0x05, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x05, + 0x66, 0x69, 0x65, 0x6c, 0x64, 0x88, 0x01, 0x01, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x42, 0x08, + 0x0a, 0x06, 0x5f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x22, 0x27, 0x0a, 0x15, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, + 0x64, 0x22, 0x18, 0x0a, 0x16, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x16, 0x0a, 0x14, 0x4c, + 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x22, 0x49, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x30, 0x0a, 0x06, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x75, + 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x2a, 0x63, + 0x0a, 0x09, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x12, 0x1a, 0x0a, 0x16, 0x41, + 0x50, 0x50, 0x5f, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x49, 0x53, 0x41, 0x42, + 0x4c, 0x45, 0x44, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x49, 0x4e, 0x49, 0x54, 0x49, 0x41, 0x4c, + 0x49, 0x5a, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x0b, 0x0a, 0x07, 0x48, 0x45, 0x41, 0x4c, 0x54, + 0x48, 0x59, 0x10, 0x03, 0x12, 0x0d, 0x0a, 0x09, 0x55, 0x4e, 0x48, 0x45, 0x41, 0x4c, 0x54, 0x48, + 0x59, 0x10, 0x04, 0x32, 0x91, 0x0d, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x4b, 0x0a, + 0x0b, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x22, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, + 0x74, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x18, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x4d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74, 0x12, 0x5a, 0x0a, 0x10, 0x47, 0x65, + 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, - 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, - 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0d, 0x4c, 0x69, 0x73, 0x74, 0x53, - 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, - 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, - 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, + 0x47, 0x65, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x12, 0x56, 0x0a, 0x0b, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x53, 0x74, 0x61, 0x74, 0x73, 0x12, 0x22, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x53, 0x74, 0x61, + 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x74, 0x61, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x54, + 0x0a, 0x0f, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, + 0x65, 0x12, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, + 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, + 0x79, 0x63, 0x6c, 0x65, 0x12, 0x72, 0x0a, 0x15, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x73, 0x12, 0x2b, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, + 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, + 0x6c, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2c, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x41, 0x70, 0x70, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4e, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, + 0x65, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x17, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x53, 0x74, 0x61, 0x72, 0x74, 0x75, 0x70, 0x12, 0x6e, 0x0a, 0x13, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, + 0x2a, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, + 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2b, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x62, 0x0a, 0x0f, 0x42, 0x61, 0x74, 0x63, + 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x12, 0x26, 0x2e, 0x63, 0x6f, + 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, + 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, + 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x42, 0x61, 0x74, 0x63, 0x68, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x4c, 0x6f, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x77, 0x0a, 0x16, + 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, + 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x12, 0x2d, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, + 0x6e, 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2e, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, + 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x41, 0x6e, 0x6e, 0x6f, 0x75, 0x6e, + 0x63, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x42, 0x61, 0x6e, 0x6e, 0x65, 0x72, 0x73, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x7e, 0x0a, 0x0f, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, + 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x12, 0x34, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, + 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, + 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, + 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x35, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, - 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, - 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, - 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, - 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x57, 0x6f, 0x72, 0x6b, 0x73, 0x70, 0x61, 0x63, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x64, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x9e, 0x01, 0x0a, 0x23, 0x47, 0x65, 0x74, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x3a, 0x2e, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, + 0x65, 0x74, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, + 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x3b, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x65, + 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, + 0x67, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x89, 0x01, 0x0a, 0x1c, 0x50, 0x75, 0x73, 0x68, 0x52, + 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, + 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x33, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, + 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, 0x73, 0x68, 0x52, 0x65, 0x73, + 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, 0x6f, 0x72, 0x69, 0x6e, 0x67, + 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x34, 0x2e, 0x63, + 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x50, 0x75, + 0x73, 0x68, 0x52, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x73, 0x4d, 0x6f, 0x6e, 0x69, 0x74, + 0x6f, 0x72, 0x69, 0x6e, 0x67, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x53, 0x0a, 0x10, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x27, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x52, 0x65, 0x70, 0x6f, 0x72, 0x74, 0x43, 0x6f, + 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x12, 0x5f, 0x0a, 0x0e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5f, 0x0a, 0x0e, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x25, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x26, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, + 0x76, 0x32, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5c, 0x0a, 0x0d, 0x4c, 0x69, 0x73, + 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x24, 0x2e, 0x63, 0x6f, 0x64, + 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, + 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x25, 0x2e, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2e, 0x76, + 0x32, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x53, 0x75, 0x62, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x69, 0x74, 0x68, 0x75, + 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, + 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -5038,7 +5113,7 @@ func file_agent_proto_agent_proto_rawDescGZIP() []byte { return file_agent_proto_agent_proto_rawDescData } -var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 13) +var file_agent_proto_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 14) var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 59) var file_agent_proto_agent_proto_goTypes = []interface{}{ (AppHealth)(0), // 0: coder.agent.v2.AppHealth @@ -5052,168 +5127,170 @@ var file_agent_proto_agent_proto_goTypes = []interface{}{ (Timing_Status)(0), // 8: coder.agent.v2.Timing.Status (Connection_Action)(0), // 9: coder.agent.v2.Connection.Action (Connection_Type)(0), // 10: coder.agent.v2.Connection.Type - (CreateSubAgentRequest_App_OpenIn)(0), // 11: coder.agent.v2.CreateSubAgentRequest.App.OpenIn - (CreateSubAgentRequest_App_Share)(0), // 12: coder.agent.v2.CreateSubAgentRequest.App.Share - (*WorkspaceApp)(nil), // 13: coder.agent.v2.WorkspaceApp - (*WorkspaceAgentScript)(nil), // 14: coder.agent.v2.WorkspaceAgentScript - (*WorkspaceAgentMetadata)(nil), // 15: coder.agent.v2.WorkspaceAgentMetadata - (*Manifest)(nil), // 16: coder.agent.v2.Manifest - (*WorkspaceAgentDevcontainer)(nil), // 17: coder.agent.v2.WorkspaceAgentDevcontainer - (*GetManifestRequest)(nil), // 18: coder.agent.v2.GetManifestRequest - (*ServiceBanner)(nil), // 19: coder.agent.v2.ServiceBanner - (*GetServiceBannerRequest)(nil), // 20: coder.agent.v2.GetServiceBannerRequest - (*Stats)(nil), // 21: coder.agent.v2.Stats - (*UpdateStatsRequest)(nil), // 22: coder.agent.v2.UpdateStatsRequest - (*UpdateStatsResponse)(nil), // 23: coder.agent.v2.UpdateStatsResponse - (*Lifecycle)(nil), // 24: coder.agent.v2.Lifecycle - (*UpdateLifecycleRequest)(nil), // 25: coder.agent.v2.UpdateLifecycleRequest - (*BatchUpdateAppHealthRequest)(nil), // 26: coder.agent.v2.BatchUpdateAppHealthRequest - (*BatchUpdateAppHealthResponse)(nil), // 27: coder.agent.v2.BatchUpdateAppHealthResponse - (*Startup)(nil), // 28: coder.agent.v2.Startup - (*UpdateStartupRequest)(nil), // 29: coder.agent.v2.UpdateStartupRequest - (*Metadata)(nil), // 30: coder.agent.v2.Metadata - (*BatchUpdateMetadataRequest)(nil), // 31: coder.agent.v2.BatchUpdateMetadataRequest - (*BatchUpdateMetadataResponse)(nil), // 32: coder.agent.v2.BatchUpdateMetadataResponse - (*Log)(nil), // 33: coder.agent.v2.Log - (*BatchCreateLogsRequest)(nil), // 34: coder.agent.v2.BatchCreateLogsRequest - (*BatchCreateLogsResponse)(nil), // 35: coder.agent.v2.BatchCreateLogsResponse - (*GetAnnouncementBannersRequest)(nil), // 36: coder.agent.v2.GetAnnouncementBannersRequest - (*GetAnnouncementBannersResponse)(nil), // 37: coder.agent.v2.GetAnnouncementBannersResponse - (*BannerConfig)(nil), // 38: coder.agent.v2.BannerConfig - (*WorkspaceAgentScriptCompletedRequest)(nil), // 39: coder.agent.v2.WorkspaceAgentScriptCompletedRequest - (*WorkspaceAgentScriptCompletedResponse)(nil), // 40: coder.agent.v2.WorkspaceAgentScriptCompletedResponse - (*Timing)(nil), // 41: coder.agent.v2.Timing - (*GetResourcesMonitoringConfigurationRequest)(nil), // 42: coder.agent.v2.GetResourcesMonitoringConfigurationRequest - (*GetResourcesMonitoringConfigurationResponse)(nil), // 43: coder.agent.v2.GetResourcesMonitoringConfigurationResponse - (*PushResourcesMonitoringUsageRequest)(nil), // 44: coder.agent.v2.PushResourcesMonitoringUsageRequest - (*PushResourcesMonitoringUsageResponse)(nil), // 45: coder.agent.v2.PushResourcesMonitoringUsageResponse - (*Connection)(nil), // 46: coder.agent.v2.Connection - (*ReportConnectionRequest)(nil), // 47: coder.agent.v2.ReportConnectionRequest - (*SubAgent)(nil), // 48: coder.agent.v2.SubAgent - (*CreateSubAgentRequest)(nil), // 49: coder.agent.v2.CreateSubAgentRequest - (*CreateSubAgentResponse)(nil), // 50: coder.agent.v2.CreateSubAgentResponse - (*DeleteSubAgentRequest)(nil), // 51: coder.agent.v2.DeleteSubAgentRequest - (*DeleteSubAgentResponse)(nil), // 52: coder.agent.v2.DeleteSubAgentResponse - (*ListSubAgentsRequest)(nil), // 53: coder.agent.v2.ListSubAgentsRequest - (*ListSubAgentsResponse)(nil), // 54: coder.agent.v2.ListSubAgentsResponse - (*WorkspaceApp_Healthcheck)(nil), // 55: coder.agent.v2.WorkspaceApp.Healthcheck - (*WorkspaceAgentMetadata_Result)(nil), // 56: coder.agent.v2.WorkspaceAgentMetadata.Result - (*WorkspaceAgentMetadata_Description)(nil), // 57: coder.agent.v2.WorkspaceAgentMetadata.Description - nil, // 58: coder.agent.v2.Manifest.EnvironmentVariablesEntry - nil, // 59: coder.agent.v2.Stats.ConnectionsByProtoEntry - (*Stats_Metric)(nil), // 60: coder.agent.v2.Stats.Metric - (*Stats_Metric_Label)(nil), // 61: coder.agent.v2.Stats.Metric.Label - (*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 62: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate - (*GetResourcesMonitoringConfigurationResponse_Config)(nil), // 63: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config - (*GetResourcesMonitoringConfigurationResponse_Memory)(nil), // 64: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory - (*GetResourcesMonitoringConfigurationResponse_Volume)(nil), // 65: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume - (*PushResourcesMonitoringUsageRequest_Datapoint)(nil), // 66: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint - (*PushResourcesMonitoringUsageRequest_Datapoint_MemoryUsage)(nil), // 67: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage - (*PushResourcesMonitoringUsageRequest_Datapoint_VolumeUsage)(nil), // 68: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage - (*CreateSubAgentRequest_App)(nil), // 69: coder.agent.v2.CreateSubAgentRequest.App - (*CreateSubAgentRequest_App_Healthcheck)(nil), // 70: coder.agent.v2.CreateSubAgentRequest.App.Healthcheck - (*CreateSubAgentResponse_AppCreationError)(nil), // 71: coder.agent.v2.CreateSubAgentResponse.AppCreationError - (*durationpb.Duration)(nil), // 72: google.protobuf.Duration - (*proto.DERPMap)(nil), // 73: coder.tailnet.v2.DERPMap - (*timestamppb.Timestamp)(nil), // 74: google.protobuf.Timestamp - (*emptypb.Empty)(nil), // 75: google.protobuf.Empty + (CreateSubAgentRequest_DisplayApp)(0), // 11: coder.agent.v2.CreateSubAgentRequest.DisplayApp + (CreateSubAgentRequest_App_OpenIn)(0), // 12: coder.agent.v2.CreateSubAgentRequest.App.OpenIn + (CreateSubAgentRequest_App_Share)(0), // 13: coder.agent.v2.CreateSubAgentRequest.App.Share + (*WorkspaceApp)(nil), // 14: coder.agent.v2.WorkspaceApp + (*WorkspaceAgentScript)(nil), // 15: coder.agent.v2.WorkspaceAgentScript + (*WorkspaceAgentMetadata)(nil), // 16: coder.agent.v2.WorkspaceAgentMetadata + (*Manifest)(nil), // 17: coder.agent.v2.Manifest + (*WorkspaceAgentDevcontainer)(nil), // 18: coder.agent.v2.WorkspaceAgentDevcontainer + (*GetManifestRequest)(nil), // 19: coder.agent.v2.GetManifestRequest + (*ServiceBanner)(nil), // 20: coder.agent.v2.ServiceBanner + (*GetServiceBannerRequest)(nil), // 21: coder.agent.v2.GetServiceBannerRequest + (*Stats)(nil), // 22: coder.agent.v2.Stats + (*UpdateStatsRequest)(nil), // 23: coder.agent.v2.UpdateStatsRequest + (*UpdateStatsResponse)(nil), // 24: coder.agent.v2.UpdateStatsResponse + (*Lifecycle)(nil), // 25: coder.agent.v2.Lifecycle + (*UpdateLifecycleRequest)(nil), // 26: coder.agent.v2.UpdateLifecycleRequest + (*BatchUpdateAppHealthRequest)(nil), // 27: coder.agent.v2.BatchUpdateAppHealthRequest + (*BatchUpdateAppHealthResponse)(nil), // 28: coder.agent.v2.BatchUpdateAppHealthResponse + (*Startup)(nil), // 29: coder.agent.v2.Startup + (*UpdateStartupRequest)(nil), // 30: coder.agent.v2.UpdateStartupRequest + (*Metadata)(nil), // 31: coder.agent.v2.Metadata + (*BatchUpdateMetadataRequest)(nil), // 32: coder.agent.v2.BatchUpdateMetadataRequest + (*BatchUpdateMetadataResponse)(nil), // 33: coder.agent.v2.BatchUpdateMetadataResponse + (*Log)(nil), // 34: coder.agent.v2.Log + (*BatchCreateLogsRequest)(nil), // 35: coder.agent.v2.BatchCreateLogsRequest + (*BatchCreateLogsResponse)(nil), // 36: coder.agent.v2.BatchCreateLogsResponse + (*GetAnnouncementBannersRequest)(nil), // 37: coder.agent.v2.GetAnnouncementBannersRequest + (*GetAnnouncementBannersResponse)(nil), // 38: coder.agent.v2.GetAnnouncementBannersResponse + (*BannerConfig)(nil), // 39: coder.agent.v2.BannerConfig + (*WorkspaceAgentScriptCompletedRequest)(nil), // 40: coder.agent.v2.WorkspaceAgentScriptCompletedRequest + (*WorkspaceAgentScriptCompletedResponse)(nil), // 41: coder.agent.v2.WorkspaceAgentScriptCompletedResponse + (*Timing)(nil), // 42: coder.agent.v2.Timing + (*GetResourcesMonitoringConfigurationRequest)(nil), // 43: coder.agent.v2.GetResourcesMonitoringConfigurationRequest + (*GetResourcesMonitoringConfigurationResponse)(nil), // 44: coder.agent.v2.GetResourcesMonitoringConfigurationResponse + (*PushResourcesMonitoringUsageRequest)(nil), // 45: coder.agent.v2.PushResourcesMonitoringUsageRequest + (*PushResourcesMonitoringUsageResponse)(nil), // 46: coder.agent.v2.PushResourcesMonitoringUsageResponse + (*Connection)(nil), // 47: coder.agent.v2.Connection + (*ReportConnectionRequest)(nil), // 48: coder.agent.v2.ReportConnectionRequest + (*SubAgent)(nil), // 49: coder.agent.v2.SubAgent + (*CreateSubAgentRequest)(nil), // 50: coder.agent.v2.CreateSubAgentRequest + (*CreateSubAgentResponse)(nil), // 51: coder.agent.v2.CreateSubAgentResponse + (*DeleteSubAgentRequest)(nil), // 52: coder.agent.v2.DeleteSubAgentRequest + (*DeleteSubAgentResponse)(nil), // 53: coder.agent.v2.DeleteSubAgentResponse + (*ListSubAgentsRequest)(nil), // 54: coder.agent.v2.ListSubAgentsRequest + (*ListSubAgentsResponse)(nil), // 55: coder.agent.v2.ListSubAgentsResponse + (*WorkspaceApp_Healthcheck)(nil), // 56: coder.agent.v2.WorkspaceApp.Healthcheck + (*WorkspaceAgentMetadata_Result)(nil), // 57: coder.agent.v2.WorkspaceAgentMetadata.Result + (*WorkspaceAgentMetadata_Description)(nil), // 58: coder.agent.v2.WorkspaceAgentMetadata.Description + nil, // 59: coder.agent.v2.Manifest.EnvironmentVariablesEntry + nil, // 60: coder.agent.v2.Stats.ConnectionsByProtoEntry + (*Stats_Metric)(nil), // 61: coder.agent.v2.Stats.Metric + (*Stats_Metric_Label)(nil), // 62: coder.agent.v2.Stats.Metric.Label + (*BatchUpdateAppHealthRequest_HealthUpdate)(nil), // 63: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate + (*GetResourcesMonitoringConfigurationResponse_Config)(nil), // 64: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config + (*GetResourcesMonitoringConfigurationResponse_Memory)(nil), // 65: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory + (*GetResourcesMonitoringConfigurationResponse_Volume)(nil), // 66: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume + (*PushResourcesMonitoringUsageRequest_Datapoint)(nil), // 67: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint + (*PushResourcesMonitoringUsageRequest_Datapoint_MemoryUsage)(nil), // 68: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage + (*PushResourcesMonitoringUsageRequest_Datapoint_VolumeUsage)(nil), // 69: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage + (*CreateSubAgentRequest_App)(nil), // 70: coder.agent.v2.CreateSubAgentRequest.App + (*CreateSubAgentRequest_App_Healthcheck)(nil), // 71: coder.agent.v2.CreateSubAgentRequest.App.Healthcheck + (*CreateSubAgentResponse_AppCreationError)(nil), // 72: coder.agent.v2.CreateSubAgentResponse.AppCreationError + (*durationpb.Duration)(nil), // 73: google.protobuf.Duration + (*proto.DERPMap)(nil), // 74: coder.tailnet.v2.DERPMap + (*timestamppb.Timestamp)(nil), // 75: google.protobuf.Timestamp + (*emptypb.Empty)(nil), // 76: google.protobuf.Empty } var file_agent_proto_agent_proto_depIdxs = []int32{ 1, // 0: coder.agent.v2.WorkspaceApp.sharing_level:type_name -> coder.agent.v2.WorkspaceApp.SharingLevel - 55, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck + 56, // 1: coder.agent.v2.WorkspaceApp.healthcheck:type_name -> coder.agent.v2.WorkspaceApp.Healthcheck 2, // 2: coder.agent.v2.WorkspaceApp.health:type_name -> coder.agent.v2.WorkspaceApp.Health - 72, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration - 56, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result - 57, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description - 58, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry - 73, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap - 14, // 8: coder.agent.v2.Manifest.scripts:type_name -> coder.agent.v2.WorkspaceAgentScript - 13, // 9: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp - 57, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description - 17, // 11: coder.agent.v2.Manifest.devcontainers:type_name -> coder.agent.v2.WorkspaceAgentDevcontainer - 59, // 12: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry - 60, // 13: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric - 21, // 14: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats - 72, // 15: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration + 73, // 3: coder.agent.v2.WorkspaceAgentScript.timeout:type_name -> google.protobuf.Duration + 57, // 4: coder.agent.v2.WorkspaceAgentMetadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result + 58, // 5: coder.agent.v2.WorkspaceAgentMetadata.description:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description + 59, // 6: coder.agent.v2.Manifest.environment_variables:type_name -> coder.agent.v2.Manifest.EnvironmentVariablesEntry + 74, // 7: coder.agent.v2.Manifest.derp_map:type_name -> coder.tailnet.v2.DERPMap + 15, // 8: coder.agent.v2.Manifest.scripts:type_name -> coder.agent.v2.WorkspaceAgentScript + 14, // 9: coder.agent.v2.Manifest.apps:type_name -> coder.agent.v2.WorkspaceApp + 58, // 10: coder.agent.v2.Manifest.metadata:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Description + 18, // 11: coder.agent.v2.Manifest.devcontainers:type_name -> coder.agent.v2.WorkspaceAgentDevcontainer + 60, // 12: coder.agent.v2.Stats.connections_by_proto:type_name -> coder.agent.v2.Stats.ConnectionsByProtoEntry + 61, // 13: coder.agent.v2.Stats.metrics:type_name -> coder.agent.v2.Stats.Metric + 22, // 14: coder.agent.v2.UpdateStatsRequest.stats:type_name -> coder.agent.v2.Stats + 73, // 15: coder.agent.v2.UpdateStatsResponse.report_interval:type_name -> google.protobuf.Duration 4, // 16: coder.agent.v2.Lifecycle.state:type_name -> coder.agent.v2.Lifecycle.State - 74, // 17: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp - 24, // 18: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle - 62, // 19: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate + 75, // 17: coder.agent.v2.Lifecycle.changed_at:type_name -> google.protobuf.Timestamp + 25, // 18: coder.agent.v2.UpdateLifecycleRequest.lifecycle:type_name -> coder.agent.v2.Lifecycle + 63, // 19: coder.agent.v2.BatchUpdateAppHealthRequest.updates:type_name -> coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate 5, // 20: coder.agent.v2.Startup.subsystems:type_name -> coder.agent.v2.Startup.Subsystem - 28, // 21: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup - 56, // 22: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result - 30, // 23: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata - 74, // 24: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp + 29, // 21: coder.agent.v2.UpdateStartupRequest.startup:type_name -> coder.agent.v2.Startup + 57, // 22: coder.agent.v2.Metadata.result:type_name -> coder.agent.v2.WorkspaceAgentMetadata.Result + 31, // 23: coder.agent.v2.BatchUpdateMetadataRequest.metadata:type_name -> coder.agent.v2.Metadata + 75, // 24: coder.agent.v2.Log.created_at:type_name -> google.protobuf.Timestamp 6, // 25: coder.agent.v2.Log.level:type_name -> coder.agent.v2.Log.Level - 33, // 26: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log - 38, // 27: coder.agent.v2.GetAnnouncementBannersResponse.announcement_banners:type_name -> coder.agent.v2.BannerConfig - 41, // 28: coder.agent.v2.WorkspaceAgentScriptCompletedRequest.timing:type_name -> coder.agent.v2.Timing - 74, // 29: coder.agent.v2.Timing.start:type_name -> google.protobuf.Timestamp - 74, // 30: coder.agent.v2.Timing.end:type_name -> google.protobuf.Timestamp + 34, // 26: coder.agent.v2.BatchCreateLogsRequest.logs:type_name -> coder.agent.v2.Log + 39, // 27: coder.agent.v2.GetAnnouncementBannersResponse.announcement_banners:type_name -> coder.agent.v2.BannerConfig + 42, // 28: coder.agent.v2.WorkspaceAgentScriptCompletedRequest.timing:type_name -> coder.agent.v2.Timing + 75, // 29: coder.agent.v2.Timing.start:type_name -> google.protobuf.Timestamp + 75, // 30: coder.agent.v2.Timing.end:type_name -> google.protobuf.Timestamp 7, // 31: coder.agent.v2.Timing.stage:type_name -> coder.agent.v2.Timing.Stage 8, // 32: coder.agent.v2.Timing.status:type_name -> coder.agent.v2.Timing.Status - 63, // 33: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.config:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config - 64, // 34: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.memory:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory - 65, // 35: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.volumes:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume - 66, // 36: coder.agent.v2.PushResourcesMonitoringUsageRequest.datapoints:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint + 64, // 33: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.config:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Config + 65, // 34: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.memory:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Memory + 66, // 35: coder.agent.v2.GetResourcesMonitoringConfigurationResponse.volumes:type_name -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse.Volume + 67, // 36: coder.agent.v2.PushResourcesMonitoringUsageRequest.datapoints:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint 9, // 37: coder.agent.v2.Connection.action:type_name -> coder.agent.v2.Connection.Action 10, // 38: coder.agent.v2.Connection.type:type_name -> coder.agent.v2.Connection.Type - 74, // 39: coder.agent.v2.Connection.timestamp:type_name -> google.protobuf.Timestamp - 46, // 40: coder.agent.v2.ReportConnectionRequest.connection:type_name -> coder.agent.v2.Connection - 69, // 41: coder.agent.v2.CreateSubAgentRequest.apps:type_name -> coder.agent.v2.CreateSubAgentRequest.App - 48, // 42: coder.agent.v2.CreateSubAgentResponse.agent:type_name -> coder.agent.v2.SubAgent - 71, // 43: coder.agent.v2.CreateSubAgentResponse.app_creation_errors:type_name -> coder.agent.v2.CreateSubAgentResponse.AppCreationError - 48, // 44: coder.agent.v2.ListSubAgentsResponse.agents:type_name -> coder.agent.v2.SubAgent - 72, // 45: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration - 74, // 46: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp - 72, // 47: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration - 72, // 48: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration - 3, // 49: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type - 61, // 50: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label - 0, // 51: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth - 74, // 52: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.collected_at:type_name -> google.protobuf.Timestamp - 67, // 53: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.memory:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage - 68, // 54: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.volumes:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage - 70, // 55: coder.agent.v2.CreateSubAgentRequest.App.healthcheck:type_name -> coder.agent.v2.CreateSubAgentRequest.App.Healthcheck - 11, // 56: coder.agent.v2.CreateSubAgentRequest.App.open_in:type_name -> coder.agent.v2.CreateSubAgentRequest.App.OpenIn - 12, // 57: coder.agent.v2.CreateSubAgentRequest.App.share:type_name -> coder.agent.v2.CreateSubAgentRequest.App.Share - 18, // 58: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest - 20, // 59: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest - 22, // 60: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest - 25, // 61: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest - 26, // 62: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest - 29, // 63: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest - 31, // 64: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest - 34, // 65: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest - 36, // 66: coder.agent.v2.Agent.GetAnnouncementBanners:input_type -> coder.agent.v2.GetAnnouncementBannersRequest - 39, // 67: coder.agent.v2.Agent.ScriptCompleted:input_type -> coder.agent.v2.WorkspaceAgentScriptCompletedRequest - 42, // 68: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:input_type -> coder.agent.v2.GetResourcesMonitoringConfigurationRequest - 44, // 69: coder.agent.v2.Agent.PushResourcesMonitoringUsage:input_type -> coder.agent.v2.PushResourcesMonitoringUsageRequest - 47, // 70: coder.agent.v2.Agent.ReportConnection:input_type -> coder.agent.v2.ReportConnectionRequest - 49, // 71: coder.agent.v2.Agent.CreateSubAgent:input_type -> coder.agent.v2.CreateSubAgentRequest - 51, // 72: coder.agent.v2.Agent.DeleteSubAgent:input_type -> coder.agent.v2.DeleteSubAgentRequest - 53, // 73: coder.agent.v2.Agent.ListSubAgents:input_type -> coder.agent.v2.ListSubAgentsRequest - 16, // 74: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest - 19, // 75: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner - 23, // 76: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse - 24, // 77: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle - 27, // 78: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse - 28, // 79: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup - 32, // 80: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse - 35, // 81: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse - 37, // 82: coder.agent.v2.Agent.GetAnnouncementBanners:output_type -> coder.agent.v2.GetAnnouncementBannersResponse - 40, // 83: coder.agent.v2.Agent.ScriptCompleted:output_type -> coder.agent.v2.WorkspaceAgentScriptCompletedResponse - 43, // 84: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:output_type -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse - 45, // 85: coder.agent.v2.Agent.PushResourcesMonitoringUsage:output_type -> coder.agent.v2.PushResourcesMonitoringUsageResponse - 75, // 86: coder.agent.v2.Agent.ReportConnection:output_type -> google.protobuf.Empty - 50, // 87: coder.agent.v2.Agent.CreateSubAgent:output_type -> coder.agent.v2.CreateSubAgentResponse - 52, // 88: coder.agent.v2.Agent.DeleteSubAgent:output_type -> coder.agent.v2.DeleteSubAgentResponse - 54, // 89: coder.agent.v2.Agent.ListSubAgents:output_type -> coder.agent.v2.ListSubAgentsResponse - 74, // [74:90] is the sub-list for method output_type - 58, // [58:74] is the sub-list for method input_type - 58, // [58:58] is the sub-list for extension type_name - 58, // [58:58] is the sub-list for extension extendee - 0, // [0:58] is the sub-list for field type_name + 75, // 39: coder.agent.v2.Connection.timestamp:type_name -> google.protobuf.Timestamp + 47, // 40: coder.agent.v2.ReportConnectionRequest.connection:type_name -> coder.agent.v2.Connection + 70, // 41: coder.agent.v2.CreateSubAgentRequest.apps:type_name -> coder.agent.v2.CreateSubAgentRequest.App + 11, // 42: coder.agent.v2.CreateSubAgentRequest.display_apps:type_name -> coder.agent.v2.CreateSubAgentRequest.DisplayApp + 49, // 43: coder.agent.v2.CreateSubAgentResponse.agent:type_name -> coder.agent.v2.SubAgent + 72, // 44: coder.agent.v2.CreateSubAgentResponse.app_creation_errors:type_name -> coder.agent.v2.CreateSubAgentResponse.AppCreationError + 49, // 45: coder.agent.v2.ListSubAgentsResponse.agents:type_name -> coder.agent.v2.SubAgent + 73, // 46: coder.agent.v2.WorkspaceApp.Healthcheck.interval:type_name -> google.protobuf.Duration + 75, // 47: coder.agent.v2.WorkspaceAgentMetadata.Result.collected_at:type_name -> google.protobuf.Timestamp + 73, // 48: coder.agent.v2.WorkspaceAgentMetadata.Description.interval:type_name -> google.protobuf.Duration + 73, // 49: coder.agent.v2.WorkspaceAgentMetadata.Description.timeout:type_name -> google.protobuf.Duration + 3, // 50: coder.agent.v2.Stats.Metric.type:type_name -> coder.agent.v2.Stats.Metric.Type + 62, // 51: coder.agent.v2.Stats.Metric.labels:type_name -> coder.agent.v2.Stats.Metric.Label + 0, // 52: coder.agent.v2.BatchUpdateAppHealthRequest.HealthUpdate.health:type_name -> coder.agent.v2.AppHealth + 75, // 53: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.collected_at:type_name -> google.protobuf.Timestamp + 68, // 54: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.memory:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.MemoryUsage + 69, // 55: coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.volumes:type_name -> coder.agent.v2.PushResourcesMonitoringUsageRequest.Datapoint.VolumeUsage + 71, // 56: coder.agent.v2.CreateSubAgentRequest.App.healthcheck:type_name -> coder.agent.v2.CreateSubAgentRequest.App.Healthcheck + 12, // 57: coder.agent.v2.CreateSubAgentRequest.App.open_in:type_name -> coder.agent.v2.CreateSubAgentRequest.App.OpenIn + 13, // 58: coder.agent.v2.CreateSubAgentRequest.App.share:type_name -> coder.agent.v2.CreateSubAgentRequest.App.Share + 19, // 59: coder.agent.v2.Agent.GetManifest:input_type -> coder.agent.v2.GetManifestRequest + 21, // 60: coder.agent.v2.Agent.GetServiceBanner:input_type -> coder.agent.v2.GetServiceBannerRequest + 23, // 61: coder.agent.v2.Agent.UpdateStats:input_type -> coder.agent.v2.UpdateStatsRequest + 26, // 62: coder.agent.v2.Agent.UpdateLifecycle:input_type -> coder.agent.v2.UpdateLifecycleRequest + 27, // 63: coder.agent.v2.Agent.BatchUpdateAppHealths:input_type -> coder.agent.v2.BatchUpdateAppHealthRequest + 30, // 64: coder.agent.v2.Agent.UpdateStartup:input_type -> coder.agent.v2.UpdateStartupRequest + 32, // 65: coder.agent.v2.Agent.BatchUpdateMetadata:input_type -> coder.agent.v2.BatchUpdateMetadataRequest + 35, // 66: coder.agent.v2.Agent.BatchCreateLogs:input_type -> coder.agent.v2.BatchCreateLogsRequest + 37, // 67: coder.agent.v2.Agent.GetAnnouncementBanners:input_type -> coder.agent.v2.GetAnnouncementBannersRequest + 40, // 68: coder.agent.v2.Agent.ScriptCompleted:input_type -> coder.agent.v2.WorkspaceAgentScriptCompletedRequest + 43, // 69: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:input_type -> coder.agent.v2.GetResourcesMonitoringConfigurationRequest + 45, // 70: coder.agent.v2.Agent.PushResourcesMonitoringUsage:input_type -> coder.agent.v2.PushResourcesMonitoringUsageRequest + 48, // 71: coder.agent.v2.Agent.ReportConnection:input_type -> coder.agent.v2.ReportConnectionRequest + 50, // 72: coder.agent.v2.Agent.CreateSubAgent:input_type -> coder.agent.v2.CreateSubAgentRequest + 52, // 73: coder.agent.v2.Agent.DeleteSubAgent:input_type -> coder.agent.v2.DeleteSubAgentRequest + 54, // 74: coder.agent.v2.Agent.ListSubAgents:input_type -> coder.agent.v2.ListSubAgentsRequest + 17, // 75: coder.agent.v2.Agent.GetManifest:output_type -> coder.agent.v2.Manifest + 20, // 76: coder.agent.v2.Agent.GetServiceBanner:output_type -> coder.agent.v2.ServiceBanner + 24, // 77: coder.agent.v2.Agent.UpdateStats:output_type -> coder.agent.v2.UpdateStatsResponse + 25, // 78: coder.agent.v2.Agent.UpdateLifecycle:output_type -> coder.agent.v2.Lifecycle + 28, // 79: coder.agent.v2.Agent.BatchUpdateAppHealths:output_type -> coder.agent.v2.BatchUpdateAppHealthResponse + 29, // 80: coder.agent.v2.Agent.UpdateStartup:output_type -> coder.agent.v2.Startup + 33, // 81: coder.agent.v2.Agent.BatchUpdateMetadata:output_type -> coder.agent.v2.BatchUpdateMetadataResponse + 36, // 82: coder.agent.v2.Agent.BatchCreateLogs:output_type -> coder.agent.v2.BatchCreateLogsResponse + 38, // 83: coder.agent.v2.Agent.GetAnnouncementBanners:output_type -> coder.agent.v2.GetAnnouncementBannersResponse + 41, // 84: coder.agent.v2.Agent.ScriptCompleted:output_type -> coder.agent.v2.WorkspaceAgentScriptCompletedResponse + 44, // 85: coder.agent.v2.Agent.GetResourcesMonitoringConfiguration:output_type -> coder.agent.v2.GetResourcesMonitoringConfigurationResponse + 46, // 86: coder.agent.v2.Agent.PushResourcesMonitoringUsage:output_type -> coder.agent.v2.PushResourcesMonitoringUsageResponse + 76, // 87: coder.agent.v2.Agent.ReportConnection:output_type -> google.protobuf.Empty + 51, // 88: coder.agent.v2.Agent.CreateSubAgent:output_type -> coder.agent.v2.CreateSubAgentResponse + 53, // 89: coder.agent.v2.Agent.DeleteSubAgent:output_type -> coder.agent.v2.DeleteSubAgentResponse + 55, // 90: coder.agent.v2.Agent.ListSubAgents:output_type -> coder.agent.v2.ListSubAgentsResponse + 75, // [75:91] is the sub-list for method output_type + 59, // [59:75] is the sub-list for method input_type + 59, // [59:59] is the sub-list for extension type_name + 59, // [59:59] is the sub-list for extension extendee + 0, // [0:59] is the sub-list for field type_name } func init() { file_agent_proto_agent_proto_init() } @@ -5918,7 +5995,7 @@ func file_agent_proto_agent_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_agent_proto_agent_proto_rawDesc, - NumEnums: 13, + NumEnums: 14, NumMessages: 59, NumExtensions: 0, NumServices: 1, diff --git a/agent/proto/agent.proto b/agent/proto/agent.proto index 67da0ef7787fc..e9455c449fdb7 100644 --- a/agent/proto/agent.proto +++ b/agent/proto/agent.proto @@ -423,6 +423,16 @@ message CreateSubAgentRequest { } repeated App apps = 5; + + enum DisplayApp { + VSCODE = 0; + VSCODE_INSIDERS = 1; + WEB_TERMINAL = 2; + SSH_HELPER = 3; + PORT_FORWARDING_HELPER = 4; + } + + repeated DisplayApp display_apps = 6; } message CreateSubAgentResponse { diff --git a/cli/clitest/clitest.go b/cli/clitest/clitest.go index fbc913e7b81d3..8d1f5302ce7ba 100644 --- a/cli/clitest/clitest.go +++ b/cli/clitest/clitest.go @@ -168,6 +168,12 @@ func StartWithAssert(t *testing.T, inv *serpent.Invocation, assertCallback func( switch { case errors.Is(err, context.Canceled): return + case err != nil && strings.Contains(err.Error(), "driver: bad connection"): + // When we cancel the context on a query that's being executed within + // a transaction, sometimes, instead of a context.Canceled error we get + // a "driver: bad connection" error. + // https://github.com/lib/pq/issues/1137 + return default: assert.NoError(t, err) } diff --git a/cli/configssh.go b/cli/configssh.go index cfea6b377f6ee..c1be60b604a9e 100644 --- a/cli/configssh.go +++ b/cli/configssh.go @@ -112,14 +112,19 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool { } func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { - escapedCoderBinary, err := sshConfigExecEscape(o.coderBinaryPath, o.forceUnixSeparators) + escapedCoderBinaryProxy, err := sshConfigProxyCommandEscape(o.coderBinaryPath, o.forceUnixSeparators) if err != nil { - return xerrors.Errorf("escape coder binary for ssh failed: %w", err) + return xerrors.Errorf("escape coder binary for ProxyCommand failed: %w", err) } - escapedGlobalConfig, err := sshConfigExecEscape(o.globalConfigPath, o.forceUnixSeparators) + escapedCoderBinaryMatchExec, err := sshConfigMatchExecEscape(o.coderBinaryPath) if err != nil { - return xerrors.Errorf("escape global config for ssh failed: %w", err) + return xerrors.Errorf("escape coder binary for Match exec failed: %w", err) + } + + escapedGlobalConfig, err := sshConfigProxyCommandEscape(o.globalConfigPath, o.forceUnixSeparators) + if err != nil { + return xerrors.Errorf("escape global config for ProxyCommand failed: %w", err) } rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig) @@ -155,7 +160,7 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { _, _ = buf.WriteString("\t") _, _ = fmt.Fprintf(buf, "ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h", - escapedCoderBinary, rootFlags, flags, o.userHostPrefix, + escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix, ) _, _ = buf.WriteString("\n") } @@ -174,11 +179,11 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error { // the ^^ options should always apply, but we only want to use the proxy command if Coder Connect is not running. if !o.skipProxyCommand { _, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n", - o.hostnameSuffix, escapedCoderBinary) + o.hostnameSuffix, escapedCoderBinaryMatchExec) _, _ = buf.WriteString("\t") _, _ = fmt.Fprintf(buf, "ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h", - escapedCoderBinary, rootFlags, flags, o.hostnameSuffix, + escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix, ) _, _ = buf.WriteString("\n") } @@ -759,7 +764,8 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after [] return data, nil, nil, nil } -// sshConfigExecEscape quotes the string if it contains spaces, as per +// sshConfigProxyCommandEscape prepares the path for use in ProxyCommand. +// It quotes the string if it contains spaces, as per // `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to // run the command, and as such the formatting/escape requirements // cannot simply be covered by `fmt.Sprintf("%q", path)`. @@ -804,7 +810,7 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after [] // This is a control flag, and that is ok. It is a control flag // based on the OS of the user. Making this a different file is excessive. // nolint:revive -func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) { +func sshConfigProxyCommandEscape(path string, forceUnixPath bool) (string, error) { if forceUnixPath { // This is a workaround for #7639, where the filepath separator is // incorrectly the Windows separator (\) instead of the unix separator (/). @@ -814,9 +820,9 @@ func sshConfigExecEscape(path string, forceUnixPath bool) (string, error) { // This is unlikely to ever happen, but newlines are allowed on // certain filesystems, but cannot be used inside ssh config. if strings.ContainsAny(path, "\n") { - return "", xerrors.Errorf("invalid path: %s", path) + return "", xerrors.Errorf("invalid path: %q", path) } - // In the unlikely even that a path contains quotes, they must be + // In the unlikely event that a path contains quotes, they must be // escaped so that they are not interpreted as shell quotes. if strings.Contains(path, "\"") { path = strings.ReplaceAll(path, "\"", "\\\"") diff --git a/cli/configssh_internal_test.go b/cli/configssh_internal_test.go index d3eee395de0a3..acf534e7ae157 100644 --- a/cli/configssh_internal_test.go +++ b/cli/configssh_internal_test.go @@ -139,7 +139,7 @@ func Test_sshConfigSplitOnCoderSection(t *testing.T) { // This test tries to mimic the behavior of OpenSSH // when executing e.g. a ProxyCommand. // nolint:tparallel -func Test_sshConfigExecEscape(t *testing.T) { +func Test_sshConfigProxyCommandEscape(t *testing.T) { t.Parallel() tests := []struct { @@ -171,7 +171,7 @@ func Test_sshConfigExecEscape(t *testing.T) { err = os.WriteFile(bin, contents, 0o755) //nolint:gosec require.NoError(t, err) - escaped, err := sshConfigExecEscape(bin, false) + escaped, err := sshConfigProxyCommandEscape(bin, false) if tt.wantErr { require.Error(t, err) return @@ -186,6 +186,63 @@ func Test_sshConfigExecEscape(t *testing.T) { } } +// This test tries to mimic the behavior of OpenSSH +// when executing e.g. a match exec command. +// nolint:tparallel +func Test_sshConfigMatchExecEscape(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path string + wantErrOther bool + wantErrWindows bool + }{ + {"no spaces", "simple", false, false}, + {"spaces", "path with spaces", false, false}, + {"quotes", "path with \"quotes\"", true, true}, + {"backslashes", "path with\\backslashes", false, false}, + {"tabs", "path with \ttabs", false, true}, + {"newline fails", "path with \nnewline", true, true}, + } + // nolint:paralleltest // Fixes a flake + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + cmd := "/bin/sh" + arg := "-c" + contents := []byte("#!/bin/sh\necho yay\n") + if runtime.GOOS == "windows" { + cmd = "cmd.exe" + arg = "/c" + contents = []byte("@echo yay\n") + } + + dir := filepath.Join(t.TempDir(), tt.path) + bin := filepath.Join(dir, "coder.bat") // Windows will treat it as batch, Linux doesn't care + escaped, err := sshConfigMatchExecEscape(bin) + if (runtime.GOOS == "windows" && tt.wantErrWindows) || (runtime.GOOS != "windows" && tt.wantErrOther) { + require.Error(t, err) + return + } + require.NoError(t, err) + + err = os.MkdirAll(dir, 0o755) + require.NoError(t, err) + + err = os.WriteFile(bin, contents, 0o755) //nolint:gosec + require.NoError(t, err) + + // OpenSSH processes %% escape sequences into % + escaped = strings.ReplaceAll(escaped, "%%", "%") + b, err := exec.Command(cmd, arg, escaped).CombinedOutput() //nolint:gosec + require.NoError(t, err) + got := strings.TrimSpace(string(b)) + require.Equal(t, "yay", got) + }) + } +} + func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { t.Parallel() @@ -236,7 +293,7 @@ func Test_sshConfigExecEscapeSeparatorForce(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - found, err := sshConfigExecEscape(tt.path, tt.forceUnix) + found, err := sshConfigProxyCommandEscape(tt.path, tt.forceUnix) if tt.wantErr { require.Error(t, err) return diff --git a/cli/configssh_other.go b/cli/configssh_other.go index fde7cc0e47e63..07417487e8c8f 100644 --- a/cli/configssh_other.go +++ b/cli/configssh_other.go @@ -2,4 +2,35 @@ package cli +import ( + "strings" + + "golang.org/x/xerrors" +) + var hideForceUnixSlashes = true + +// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement. +// +// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and +// has no supported escape sequences for ". This means we cannot include " within the command to execute. +func sshConfigMatchExecEscape(path string) (string, error) { + // This is unlikely to ever happen, but newlines are allowed on + // certain filesystems, but cannot be used inside ssh config. + if strings.ContainsAny(path, "\n") { + return "", xerrors.Errorf("invalid path: %s", path) + } + // Quotes are allowed in path names on unix-like file systems, but OpenSSH's parsing of `Match exec` doesn't allow + // them. + if strings.Contains(path, `"`) { + return "", xerrors.Errorf("path must not contain quotes: %q", path) + } + + // OpenSSH passes the match exec string directly to the user's shell. sh, bash and zsh accept spaces, tabs and + // backslashes simply escaped by a `\`. It's hard to predict exactly what more exotic shells might do, but this + // should work for macOS and most Linux distros in their default configuration. + path = strings.ReplaceAll(path, `\`, `\\`) // must be first, since later replacements add backslashes. + path = strings.ReplaceAll(path, " ", "\\ ") + path = strings.ReplaceAll(path, "\t", "\\\t") + return path, nil +} diff --git a/cli/configssh_windows.go b/cli/configssh_windows.go index 642a388fc873c..5df0d6b50c00e 100644 --- a/cli/configssh_windows.go +++ b/cli/configssh_windows.go @@ -2,5 +2,58 @@ package cli +import ( + "fmt" + "strings" + + "golang.org/x/xerrors" +) + // Must be a var for unit tests to conform behavior var hideForceUnixSlashes = false + +// sshConfigMatchExecEscape prepares the path for use in `Match exec` statement. +// +// OpenSSH parses the Match line with a very simple tokenizer that accepts "-enclosed strings for the exec command, and +// has no supported escape sequences for ". This means we cannot include " within the command to execute. +// +// To make matters worse, on Windows, OpenSSH passes the string directly to cmd.exe for execution, and as far as I can +// tell, the only supported way to call a path that has spaces in it is to surround it with ". +// +// So, we can't actually include " directly, but here is a horrible workaround: +// +// "for /f %%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a connect exists %h" +// +// The key insight here is to store the character " in a variable (%a in this case, but the % itself needs to be +// escaped, so it becomes %%a), and then use that variable to construct the double-quoted path: +// +// %%aC:\Program Files\Coder\bin\coder.exe%%a. +// +// How do we generate a single " character without actually using that character? I couldn't find any command in cmd.exe +// to do it, but powershell.exe can convert ASCII to characters like this: `[char]34` (where 34 is the code point for "). +// +// Other notes: +// - @ in `@cmd.exe` suppresses echoing it, so you don't get this command printed +// - we need another invocation of cmd.exe (e.g. `do @cmd.exe /c %%aC:\Program Files\Coder\bin\coder.exe%%a`). Without +// it the double-quote gets interpreted as part of the path, and you get: '"C:\Program' is not recognized. +// Constructing the string and then passing it to another instance of cmd.exe does this trick here. +// - OpenSSH passes the `Match exec` command to cmd.exe regardless of whether the user has a unix-like shell like +// git bash, so we don't have a `forceUnixPath` option like for the ProxyCommand which does respect the user's +// configured shell on Windows. +func sshConfigMatchExecEscape(path string) (string, error) { + // This is unlikely to ever happen, but newlines are allowed on + // certain filesystems, but cannot be used inside ssh config. + if strings.ContainsAny(path, "\n") { + return "", xerrors.Errorf("invalid path: %s", path) + } + // Windows does not allow double-quotes or tabs in paths. If we get one it is an error. + if strings.ContainsAny(path, "\"\t") { + return "", xerrors.Errorf("path must not contain quotes or tabs: %q", path) + } + + if strings.ContainsAny(path, " ") { + // c.f. function comment for how this works. + path = fmt.Sprintf("for /f %%%%a in ('powershell.exe -Command [char]34') do @cmd.exe /c %%%%a%s%%%%a", path) //nolint:gocritic // We don't want %q here. + } + return path, nil +} diff --git a/cli/open_test.go b/cli/open_test.go index 97d24f0634d9d..4441e51e58c4b 100644 --- a/cli/open_test.go +++ b/cli/open_test.go @@ -306,7 +306,7 @@ func TestOpenVSCodeDevContainer(t *testing.T) { containerFolder := "/workspace/coder" ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) + mcl := acmock.NewMockContainerCLI(ctrl) mcl.EXPECT().List(gomock.Any()).Return( codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ @@ -337,7 +337,7 @@ func TestOpenVSCodeDevContainer(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mcl)) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() @@ -481,7 +481,7 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { containerFolder := "/workspace/coder" ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) + mcl := acmock.NewMockContainerCLI(ctrl) mcl.EXPECT().List(gomock.Any()).Return( codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ @@ -511,7 +511,7 @@ func TestOpenVSCodeDevContainer_NoAgentDirectory(t *testing.T) { _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mcl)) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/server.go b/cli/server.go index 9f55b63fc765c..d9badd02d9fbf 100644 --- a/cli/server.go +++ b/cli/server.go @@ -2359,6 +2359,10 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d if err != nil { return nil, xerrors.Errorf("get postgres version: %w", err) } + defer version.Close() + if version.Err() != nil { + return nil, xerrors.Errorf("version select: %w", version.Err()) + } if !version.Next() { return nil, xerrors.Errorf("no rows returned for version select") } @@ -2367,7 +2371,6 @@ func ConnectToPostgres(ctx context.Context, logger slog.Logger, driver string, d if err != nil { return nil, xerrors.Errorf("scan version: %w", err) } - _ = version.Close() if versionNum < 130000 { return nil, xerrors.Errorf("PostgreSQL version must be v13.0.0 or higher! Got: %d", versionNum) diff --git a/cli/ssh.go b/cli/ssh.go index 51f53e10bcbd2..4adbf12cccf7e 100644 --- a/cli/ssh.go +++ b/cli/ssh.go @@ -16,7 +16,6 @@ import ( "path/filepath" "regexp" "slices" - "strconv" "strings" "sync" "time" @@ -31,7 +30,6 @@ import ( "golang.org/x/term" "golang.org/x/xerrors" "gvisor.dev/gvisor/pkg/tcpip/adapters/gonet" - "tailscale.com/tailcfg" "tailscale.com/types/netlogtype" "cdr.dev/slog" @@ -40,11 +38,13 @@ import ( "github.com/coder/coder/v2/cli/cliui" "github.com/coder/coder/v2/cli/cliutil" "github.com/coder/coder/v2/coderd/autobuild/notify" + "github.com/coder/coder/v2/coderd/util/maps" "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/workspacesdk" "github.com/coder/coder/v2/cryptorand" "github.com/coder/coder/v2/pty" + "github.com/coder/coder/v2/tailnet" "github.com/coder/quartz" "github.com/coder/retry" "github.com/coder/serpent" @@ -1456,28 +1456,6 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn, } node := agentConn.Node() derpMap := agentConn.DERPMap() - derpLatency := map[string]float64{} - - // Convert DERP region IDs to friendly names for display in the UI. - for rawRegion, latency := range node.DERPLatency { - regionParts := strings.SplitN(rawRegion, "-", 2) - regionID, err := strconv.Atoi(regionParts[0]) - if err != nil { - continue - } - region, found := derpMap.Regions[regionID] - if !found { - // It's possible that a workspace agent is using an old DERPMap - // and reports regions that do not exist. If that's the case, - // report the region as unknown! - region = &tailcfg.DERPRegion{ - RegionID: regionID, - RegionName: fmt.Sprintf("Unnamed %d", regionID), - } - } - // Convert the microseconds to milliseconds. - derpLatency[region.RegionName] = latency * 1000 - } totalRx := uint64(0) totalTx := uint64(0) @@ -1491,27 +1469,20 @@ func collectNetworkStats(ctx context.Context, agentConn *workspacesdk.AgentConn, uploadSecs := float64(totalTx) / dur.Seconds() downloadSecs := float64(totalRx) / dur.Seconds() - // Sometimes the preferred DERP doesn't match the one we're actually - // connected with. Perhaps because the agent prefers a different DERP and - // we're using that server instead. - preferredDerpID := node.PreferredDERP - if pingResult.DERPRegionID != 0 { - preferredDerpID = pingResult.DERPRegionID - } - preferredDerp, ok := derpMap.Regions[preferredDerpID] - preferredDerpName := fmt.Sprintf("Unnamed %d", preferredDerpID) - if ok { - preferredDerpName = preferredDerp.RegionName - } + preferredDerpName := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + derpLatency := tailnet.ExtractDERPLatency(node, derpMap) if _, ok := derpLatency[preferredDerpName]; !ok { derpLatency[preferredDerpName] = 0 } + derpLatencyMs := maps.Map(derpLatency, func(dur time.Duration) float64 { + return float64(dur) / float64(time.Millisecond) + }) return &sshNetworkStats{ P2P: p2p, Latency: float64(latency.Microseconds()) / 1000, PreferredDERP: preferredDerpName, - DERPLatency: derpLatency, + DERPLatency: derpLatencyMs, UploadBytesSec: int64(uploadSecs), DownloadBytesSec: int64(downloadSecs), }, nil diff --git a/cli/ssh_test.go b/cli/ssh_test.go index 8845200273697..1774d8d131a9d 100644 --- a/cli/ssh_test.go +++ b/cli/ssh_test.go @@ -2057,7 +2057,7 @@ func TestSSH_Container(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) client, workspace, agentToken := setupWorkspaceForAgent(t) ctrl := gomock.NewController(t) - mLister := acmock.NewMockLister(ctrl) + mLister := acmock.NewMockContainerCLI(ctrl) mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{ { @@ -2069,7 +2069,7 @@ func TestSSH_Container(t *testing.T) { }, nil).AnyTimes() _ = agenttest.New(t, client.URL, agentToken, func(o *agent.Options) { o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mLister)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mLister)) }) _ = coderdtest.NewWorkspaceAgentWaiter(t, client, workspace.ID).Wait() diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 1cefe8767f3b0..26e63ceb8418f 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -332,6 +332,10 @@ NETWORKING / HTTP OPTIONS: The maximum lifetime duration users can specify when creating an API token. + --max-admin-token-lifetime duration, $CODER_MAX_ADMIN_TOKEN_LIFETIME (default: 168h0m0s) + The maximum lifetime duration administrators can specify when creating + an API token. + --proxy-health-interval duration, $CODER_PROXY_HEALTH_INTERVAL (default: 1m0s) The interval in which coderd should be checking the status of workspace proxies. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 7403819a2d10b..cc064e8fa2d6e 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -25,6 +25,10 @@ networking: # The maximum lifetime duration users can specify when creating an API token. # (default: 876600h0m0s, type: duration) maxTokenLifetime: 876600h0m0s + # The maximum lifetime duration administrators can specify when creating an API + # token. + # (default: 168h0m0s, type: duration) + maxAdminTokenLifetime: 168h0m0s # The token expiry duration for browser sessions. Sessions may last longer if they # are actively making requests, but this functionality can be disabled via # --disable-session-expiry-refresh. diff --git a/cli/vpndaemon_windows.go b/cli/vpndaemon_windows.go index 227bd0fe8e0db..cf74558ffa1ab 100644 --- a/cli/vpndaemon_windows.go +++ b/cli/vpndaemon_windows.go @@ -65,7 +65,6 @@ func (r *RootCmd) vpnDaemonRun() *serpent.Command { logger.Info(ctx, "starting tunnel") tunnel, err := vpn.NewTunnel(ctx, logger, pipe, vpn.NewClient(), vpn.UseOSNetworkingStack(), - vpn.UseAsLogger(), vpn.UseCustomLogSinks(sinks...), ) if err != nil { diff --git a/coderd/agentapi/subagent.go b/coderd/agentapi/subagent.go index 43399fed16e77..ae668c96e5b86 100644 --- a/coderd/agentapi/subagent.go +++ b/coderd/agentapi/subagent.go @@ -55,6 +55,31 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create createdAt := a.Clock.Now() + displayApps := make([]database.DisplayApp, 0, len(req.DisplayApps)) + for idx, displayApp := range req.DisplayApps { + var app database.DisplayApp + + switch displayApp { + case agentproto.CreateSubAgentRequest_PORT_FORWARDING_HELPER: + app = database.DisplayAppPortForwardingHelper + case agentproto.CreateSubAgentRequest_SSH_HELPER: + app = database.DisplayAppSSHHelper + case agentproto.CreateSubAgentRequest_VSCODE: + app = database.DisplayAppVscode + case agentproto.CreateSubAgentRequest_VSCODE_INSIDERS: + app = database.DisplayAppVscodeInsiders + case agentproto.CreateSubAgentRequest_WEB_TERMINAL: + app = database.DisplayAppWebTerminal + default: + return nil, codersdk.ValidationError{ + Field: fmt.Sprintf("display_apps[%d]", idx), + Detail: fmt.Sprintf("%q is not a valid display app", displayApp), + } + } + + displayApps = append(displayApps, app) + } + subAgent, err := a.Database.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{ ID: uuid.New(), ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID}, @@ -73,7 +98,7 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create ConnectionTimeoutSeconds: parentAgent.ConnectionTimeoutSeconds, TroubleshootingURL: parentAgent.TroubleshootingURL, MOTDFile: "", - DisplayApps: []database.DisplayApp{}, + DisplayApps: displayApps, DisplayOrder: 0, APIKeyScope: parentAgent.APIKeyScope, }) diff --git a/coderd/agentapi/subagent_test.go b/coderd/agentapi/subagent_test.go index e202b62df8fb2..cd7c892189fa5 100644 --- a/coderd/agentapi/subagent_test.go +++ b/coderd/agentapi/subagent_test.go @@ -939,6 +939,204 @@ func TestSubAgentAPI(t *testing.T) { }) }) + t.Run("CreateSubAgentWithDisplayApps", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + displayApps []proto.CreateSubAgentRequest_DisplayApp + expectedApps []database.DisplayApp + expectedError *codersdk.ValidationError + }{ + { + name: "NoDisplayApps", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{}, + expectedApps: []database.DisplayApp{}, + }, + { + name: "SingleDisplayApp_VSCode", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscode, + }, + }, + { + name: "SingleDisplayApp_VSCodeInsiders", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE_INSIDERS, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscodeInsiders, + }, + }, + { + name: "SingleDisplayApp_WebTerminal", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_WEB_TERMINAL, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppWebTerminal, + }, + }, + { + name: "SingleDisplayApp_SSHHelper", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_SSH_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppSSHHelper, + }, + }, + { + name: "SingleDisplayApp_PortForwardingHelper", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppPortForwardingHelper, + }, + }, + { + name: "MultipleDisplayApps", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + proto.CreateSubAgentRequest_WEB_TERMINAL, + proto.CreateSubAgentRequest_SSH_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscode, + database.DisplayAppWebTerminal, + database.DisplayAppSSHHelper, + }, + }, + { + name: "AllDisplayApps", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + proto.CreateSubAgentRequest_VSCODE_INSIDERS, + proto.CreateSubAgentRequest_WEB_TERMINAL, + proto.CreateSubAgentRequest_SSH_HELPER, + proto.CreateSubAgentRequest_PORT_FORWARDING_HELPER, + }, + expectedApps: []database.DisplayApp{ + database.DisplayAppVscode, + database.DisplayAppVscodeInsiders, + database.DisplayAppWebTerminal, + database.DisplayAppSSHHelper, + database.DisplayAppPortForwardingHelper, + }, + }, + { + name: "InvalidDisplayApp", + displayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_DisplayApp(9999), // Invalid enum value + }, + expectedError: &codersdk.ValidationError{ + Field: "display_apps[0]", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitLong) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "test-agent", + Directory: "/workspaces/test", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: tt.displayApps, + }) + if tt.expectedError != nil { + require.Error(t, err) + require.Nil(t, createResp) + + var validationErr codersdk.ValidationError + require.ErrorAs(t, err, &validationErr) + require.Equal(t, tt.expectedError.Field, validationErr.Field) + require.Contains(t, validationErr.Detail, "is not a valid display app") + } else { + require.NoError(t, err) + require.NotNil(t, createResp.Agent) + + agentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + + require.Equal(t, len(tt.expectedApps), len(subAgent.DisplayApps), "display apps count mismatch") + + for i, expectedApp := range tt.expectedApps { + require.Equal(t, expectedApp, subAgent.DisplayApps[i], "display app at index %d doesn't match", i) + } + } + }) + } + }) + + t.Run("CreateSubAgentWithDisplayAppsAndApps", func(t *testing.T) { + t.Parallel() + + log := testutil.Logger(t) + ctx := testutil.Context(t, testutil.WaitLong) + clock := quartz.NewMock(t) + + db, org := newDatabaseWithOrg(t) + user, agent := newUserWithWorkspaceAgent(t, db, org) + api := newAgentAPI(t, log, db, clock, user, org, agent) + + // Test that display apps and regular apps can coexist + createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{ + Name: "test-agent", + Directory: "/workspaces/test", + Architecture: "amd64", + OperatingSystem: "linux", + DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{ + proto.CreateSubAgentRequest_VSCODE, + proto.CreateSubAgentRequest_WEB_TERMINAL, + }, + Apps: []*proto.CreateSubAgentRequest_App{ + { + Slug: "custom-app", + DisplayName: ptr.Ref("Custom App"), + Url: ptr.Ref("http://localhost:8080"), + }, + }, + }) + require.NoError(t, err) + require.NotNil(t, createResp.Agent) + require.Empty(t, createResp.AppCreationErrors) + + agentID, err := uuid.FromBytes(createResp.Agent.Id) + require.NoError(t, err) + + // Verify display apps + subAgent, err := api.Database.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + require.Len(t, subAgent.DisplayApps, 2) + require.Equal(t, database.DisplayAppVscode, subAgent.DisplayApps[0]) + require.Equal(t, database.DisplayAppWebTerminal, subAgent.DisplayApps[1]) + + // Verify regular apps + apps, err := api.Database.GetWorkspaceAppsByAgentID(dbauthz.AsSystemRestricted(ctx), agentID) //nolint:gocritic // this is a test. + require.NoError(t, err) + require.Len(t, apps, 1) + require.Equal(t, "custom-app", apps[0].Slug) + require.Equal(t, "Custom App", apps[0].DisplayName) + }) + t.Run("ListSubAgents", func(t *testing.T) { t.Parallel() diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 07a0407c0014d..d11a0635d6f52 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -15705,6 +15705,9 @@ const docTemplate = `{ "description": "DisableExpiryRefresh will disable automatically refreshing api\nkeys when they are used from the api. This means the api key lifetime at\ncreation is the lifetime of the api key.", "type": "boolean" }, + "max_admin_token_lifetime": { + "type": "integer" + }, "max_token_lifetime": { "type": "integer" } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 076f170d27e72..aabe0b9b12672 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -14283,6 +14283,9 @@ "description": "DisableExpiryRefresh will disable automatically refreshing api\nkeys when they are used from the api. This means the api key lifetime at\ncreation is the lifetime of the api key.", "type": "boolean" }, + "max_admin_token_lifetime": { + "type": "integer" + }, "max_token_lifetime": { "type": "integer" } diff --git a/coderd/apikey.go b/coderd/apikey.go index ddcf7767719e5..895be440ef930 100644 --- a/coderd/apikey.go +++ b/coderd/apikey.go @@ -18,6 +18,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/telemetry" "github.com/coder/coder/v2/codersdk" @@ -75,7 +76,7 @@ func (api *API) postToken(rw http.ResponseWriter, r *http.Request) { } if createToken.Lifetime != 0 { - err := api.validateAPIKeyLifetime(createToken.Lifetime) + err := api.validateAPIKeyLifetime(ctx, user.ID, createToken.Lifetime) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Failed to validate create API key request.", @@ -338,35 +339,69 @@ func (api *API) deleteAPIKey(rw http.ResponseWriter, r *http.Request) { // @Success 200 {object} codersdk.TokenConfig // @Router /users/{user}/keys/tokens/tokenconfig [get] func (api *API) tokenConfig(rw http.ResponseWriter, r *http.Request) { - values, err := api.DeploymentValues.WithoutSecrets() + user := httpmw.UserParam(r) + maxLifetime, err := api.getMaxTokenLifetime(r.Context(), user.ID) if err != nil { - httpapi.InternalServerError(rw, err) + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to get token configuration.", + Detail: err.Error(), + }) return } httpapi.Write( r.Context(), rw, http.StatusOK, codersdk.TokenConfig{ - MaxTokenLifetime: values.Sessions.MaximumTokenDuration.Value(), + MaxTokenLifetime: maxLifetime, }, ) } -func (api *API) validateAPIKeyLifetime(lifetime time.Duration) error { +func (api *API) validateAPIKeyLifetime(ctx context.Context, userID uuid.UUID, lifetime time.Duration) error { if lifetime <= 0 { return xerrors.New("lifetime must be positive number greater than 0") } - if lifetime > api.DeploymentValues.Sessions.MaximumTokenDuration.Value() { + maxLifetime, err := api.getMaxTokenLifetime(ctx, userID) + if err != nil { + return xerrors.Errorf("failed to get max token lifetime: %w", err) + } + + if lifetime > maxLifetime { return xerrors.Errorf( "lifetime must be less than %v", - api.DeploymentValues.Sessions.MaximumTokenDuration, + maxLifetime, ) } return nil } +// getMaxTokenLifetime returns the maximum allowed token lifetime for a user. +// It distinguishes between regular users and owners. +func (api *API) getMaxTokenLifetime(ctx context.Context, userID uuid.UUID) (time.Duration, error) { + subject, _, err := httpmw.UserRBACSubject(ctx, api.Database, userID, rbac.ScopeAll) + if err != nil { + return 0, xerrors.Errorf("failed to get user rbac subject: %w", err) + } + + roles, err := subject.Roles.Expand() + if err != nil { + return 0, xerrors.Errorf("failed to expand user roles: %w", err) + } + + maxLifetime := api.DeploymentValues.Sessions.MaximumTokenDuration.Value() + for _, role := range roles { + if role.Identifier.Name == codersdk.RoleOwner { + // Owners have a different max lifetime. + maxLifetime = api.DeploymentValues.Sessions.MaximumAdminTokenDuration.Value() + break + } + } + + return maxLifetime, nil +} + func (api *API) createAPIKey(ctx context.Context, params apikey.CreateParams) (*http.Cookie, *database.APIKey, error) { key, sessionToken, err := apikey.Generate(params) if err != nil { diff --git a/coderd/apikey_test.go b/coderd/apikey_test.go index 43e3325339983..dbf5a3520a6f0 100644 --- a/coderd/apikey_test.go +++ b/coderd/apikey_test.go @@ -144,6 +144,88 @@ func TestTokenUserSetMaxLifetime(t *testing.T) { require.ErrorContains(t, err, "lifetime must be less") } +func TestTokenAdminSetMaxLifetime(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + dc := coderdtest.DeploymentValues(t) + dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 7) + dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 14) + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dc, + }) + adminUser := coderdtest.CreateFirstUser(t, client) + nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) + + // Admin should be able to create a token with a lifetime longer than the non-admin max. + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 10, + }) + require.NoError(t, err) + + // Admin should NOT be able to create a token with a lifetime longer than the admin max. + _, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 15, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") + + // Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 8, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") + + // Non-admin should be able to create a token with a lifetime shorter than the non-admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 6, + }) + require.NoError(t, err) +} + +func TestTokenAdminSetMaxLifetimeShorter(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + dc := coderdtest.DeploymentValues(t) + dc.Sessions.MaximumTokenDuration = serpent.Duration(time.Hour * 24 * 14) + dc.Sessions.MaximumAdminTokenDuration = serpent.Duration(time.Hour * 24 * 7) + client := coderdtest.New(t, &coderdtest.Options{ + DeploymentValues: dc, + }) + adminUser := coderdtest.CreateFirstUser(t, client) + nonAdminClient, _ := coderdtest.CreateAnotherUser(t, client, adminUser.OrganizationID) + + // Admin should NOT be able to create a token with a lifetime longer than the admin max. + _, err := client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 8, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") + + // Admin should be able to create a token with a lifetime shorter than the admin max. + _, err = client.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 6, + }) + require.NoError(t, err) + + // Non-admin should be able to create a token with a lifetime longer than the admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 10, + }) + require.NoError(t, err) + + // Non-admin should NOT be able to create a token with a lifetime longer than the non-admin max. + _, err = nonAdminClient.CreateToken(ctx, codersdk.Me, codersdk.CreateTokenRequest{ + Lifetime: time.Hour * 24 * 15, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "lifetime must be less") +} + func TestTokenCustomDefaultLifetime(t *testing.T) { t.Parallel() diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 40b1423a0f730..9978aa0bcaff5 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -47,14 +47,6 @@ func ListLazy[F any, T any](convert func(F) T) func(list []F) []T { } } -func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { - into := make(map[K]T) - for k, item := range params { - into[k] = convert(item) - } - return into -} - type ExternalAuthMeta struct { Authenticated bool ValidateError string diff --git a/coderd/database/pubsub/pubsub.go b/coderd/database/pubsub/pubsub.go index 8019754e15bd9..c4b454abdfbda 100644 --- a/coderd/database/pubsub/pubsub.go +++ b/coderd/database/pubsub/pubsub.go @@ -552,15 +552,14 @@ func (p *PGPubsub) startListener(ctx context.Context, connectURL string) error { sentErrCh = true }), } - select { - case err = <-errCh: - if err != nil { - _ = p.pgListener.Close() - return xerrors.Errorf("create pq listener: %w", err) - } - case <-ctx.Done(): + // We don't respect context cancellation here. There's a bug in the pq library + // where if you close the listener before or while the connection is being + // established, the connection will be established anyway, and will not be + // closed. + // https://github.com/lib/pq/issues/1192 + if err := <-errCh; err != nil { _ = p.pgListener.Close() - return ctx.Err() + return xerrors.Errorf("create pq listener: %w", err) } return nil } diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 1a2c418a014bb..11588a09fb797 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -135,6 +135,8 @@ func (m *Manager) WithHandlers(reg map[database.NotificationMethod]Handler) { m.handlers = reg } +var ErrManagerAlreadyClosed = xerrors.New("manager already closed") + // Run initiates the control loop in the background, which spawns a given number of notifier goroutines. // Manager requires system-level permissions to interact with the store. // Run is only intended to be run once. @@ -146,7 +148,11 @@ func (m *Manager) Run(ctx context.Context) { go func() { err := m.loop(ctx) if err != nil { - m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) + if xerrors.Is(err, ErrManagerAlreadyClosed) { + m.log.Warn(ctx, "notification manager stopped with error", slog.Error(err)) + } else { + m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) + } } }() }) @@ -163,7 +169,7 @@ func (m *Manager) loop(ctx context.Context) error { m.mu.Lock() if m.closed { m.mu.Unlock() - return xerrors.New("manager already closed") + return ErrManagerAlreadyClosed } var eg errgroup.Group diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 111f4185d3910..31165cf89f65b 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -739,6 +739,9 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo Metadata: &sdkproto.Metadata{ CoderUrl: s.AccessURL.String(), WorkspaceName: input.WorkspaceName, + // There is no owner for a template import, but we can assume + // the "Everyone" group as a placeholder. + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, } @@ -759,6 +762,9 @@ func (s *server) acquireProtoJob(ctx context.Context, job database.ProvisionerJo UserVariableValues: convertVariableValues(userVariableValues), Metadata: &sdkproto.Metadata{ CoderUrl: s.AccessURL.String(), + // There is no owner for a template import, but we can assume + // the "Everyone" group as a placeholder. + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, } diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 1756aa68e15fc..67fb267fb4c23 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -568,8 +568,9 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateDryRun_{ TemplateDryRun: &proto.AcquiredJob_TemplateDryRun{ Metadata: &sdkproto.Metadata{ - CoderUrl: (&url.URL{}).String(), - WorkspaceName: "testing", + CoderUrl: (&url.URL{}).String(), + WorkspaceName: "testing", + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, }) @@ -600,7 +601,8 @@ func TestAcquireJob(t *testing.T) { want, err := json.Marshal(&proto.AcquiredJob_TemplateImport_{ TemplateImport: &proto.AcquiredJob_TemplateImport{ Metadata: &sdkproto.Metadata{ - CoderUrl: (&url.URL{}).String(), + CoderUrl: (&url.URL{}).String(), + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, }) @@ -643,7 +645,8 @@ func TestAcquireJob(t *testing.T) { {Name: "first", Sensitive: true, Value: "first_value"}, }, Metadata: &sdkproto.Metadata{ - CoderUrl: (&url.URL{}).String(), + CoderUrl: (&url.URL{}).String(), + WorkspaceOwnerGroups: []string{database.EveryoneGroup}, }, }, }) diff --git a/coderd/util/maps/maps.go b/coderd/util/maps/maps.go index 8aaa6669cb8af..6a858bf3f7085 100644 --- a/coderd/util/maps/maps.go +++ b/coderd/util/maps/maps.go @@ -6,6 +6,14 @@ import ( "golang.org/x/exp/constraints" ) +func Map[K comparable, F any, T any](params map[K]F, convert func(F) T) map[K]T { + into := make(map[K]T) + for k, item := range params { + into[k] = convert(item) + } + return into +} + // Subset returns true if all the keys of a are present // in b and have the same values. // If the corresponding value of a[k] is the zero value in diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index a9b981f820be2..f32c7b1458ca2 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1320,18 +1320,18 @@ func TestWorkspaceAgentContainers(t *testing.T) { for _, tc := range []struct { name string - setupMock func(*acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) + setupMock func(*acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) }{ { name: "test response", - setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + setupMock: func(mcl *acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) { mcl.EXPECT().List(gomock.Any()).Return(testResponse, nil).AnyTimes() return testResponse, nil }, }, { name: "error response", - setupMock: func(mcl *acmock.MockLister) (codersdk.WorkspaceAgentListContainersResponse, error) { + setupMock: func(mcl *acmock.MockContainerCLI) (codersdk.WorkspaceAgentListContainersResponse, error) { mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError).AnyTimes() return codersdk.WorkspaceAgentListContainersResponse{}, assert.AnError }, @@ -1342,7 +1342,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) + mcl := acmock.NewMockContainerCLI(ctrl) expected, expectedErr := tc.setupMock(mcl) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ @@ -1358,7 +1358,7 @@ func TestWorkspaceAgentContainers(t *testing.T) { _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { o.Logger = logger.Named("agent") o.ExperimentalDevcontainersEnabled = true - o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithLister(mcl)) + o.ContainerAPIOptions = append(o.ContainerAPIOptions, agentcontainers.WithContainerCLI(mcl)) }) resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() require.Len(t, resources, 1, "expected one resource") @@ -1419,11 +1419,11 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { for _, tc := range []struct { name string - setupMock func(*acmock.MockLister, *acmock.MockDevcontainerCLI) (status int) + setupMock func(*acmock.MockContainerCLI, *acmock.MockDevcontainerCLI) (status int) }{ { name: "Recreate", - setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { + setupMock: func(mcl *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{devContainer}, }, nil).AnyTimes() @@ -1433,14 +1433,14 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }, { name: "Container does not exist", - setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { + setupMock: func(mcl *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).AnyTimes() return http.StatusNotFound }, }, { name: "Not a devcontainer", - setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { + setupMock: func(mcl *acmock.MockContainerCLI, mdccli *acmock.MockDevcontainerCLI) int { mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ Containers: []codersdk.WorkspaceAgentContainer{plainContainer}, }, nil).AnyTimes() @@ -1452,7 +1452,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { t.Parallel() ctrl := gomock.NewController(t) - mcl := acmock.NewMockLister(ctrl) + mcl := acmock.NewMockContainerCLI(ctrl) mdccli := acmock.NewMockDevcontainerCLI(ctrl) wantStatus := tc.setupMock(mcl, mdccli) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) @@ -1471,7 +1471,7 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { o.ExperimentalDevcontainersEnabled = true o.ContainerAPIOptions = append( o.ContainerAPIOptions, - agentcontainers.WithLister(mcl), + agentcontainers.WithContainerCLI(mcl), agentcontainers.WithDevcontainerCLI(mdccli), agentcontainers.WithWatcher(watcher.NewNoop()), ) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 696e6bda52682..ac72ed2fc1ec1 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -468,6 +468,8 @@ type SessionLifetime struct { DefaultTokenDuration serpent.Duration `json:"default_token_lifetime,omitempty" typescript:",notnull"` MaximumTokenDuration serpent.Duration `json:"max_token_lifetime,omitempty" typescript:",notnull"` + + MaximumAdminTokenDuration serpent.Duration `json:"max_admin_token_lifetime,omitempty" typescript:",notnull"` } type DERP struct { @@ -2340,6 +2342,17 @@ func (c *DeploymentValues) Options() serpent.OptionSet { YAML: "maxTokenLifetime", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, + { + Name: "Maximum Admin Token Lifetime", + Description: "The maximum lifetime duration administrators can specify when creating an API token.", + Flag: "max-admin-token-lifetime", + Env: "CODER_MAX_ADMIN_TOKEN_LIFETIME", + Default: (7 * 24 * time.Hour).String(), + Value: &c.Sessions.MaximumAdminTokenDuration, + Group: &deploymentGroupNetworkingHTTP, + YAML: "maxAdminTokenLifetime", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + }, { Name: "Default Token Lifetime", Description: "The default lifetime duration for API tokens. This value is used when creating a token without specifying a duration, such as when authenticating the CLI or an IDE plugin.", diff --git a/docs/admin/templates/extending-templates/docker-in-workspaces.md b/docs/admin/templates/extending-templates/docker-in-workspaces.md index 51b1634d20371..073049ba0ecdc 100644 --- a/docs/admin/templates/extending-templates/docker-in-workspaces.md +++ b/docs/admin/templates/extending-templates/docker-in-workspaces.md @@ -200,7 +200,7 @@ Before using Podman, please review the following documentation: - [Shortcomings of Rootless Podman](https://github.com/containers/podman/blob/main/rootless.md#shortcomings-of-rootless-podman) 1. Enable - [smart-device-manager](https://gitlab.com/arm-research/smarter/smarter-device-manager#enabling-access) + [smart-device-manager](https://github.com/smarter-project/smarter-device-manager#enabling-access) to securely expose a FUSE devices to pods. ```shell diff --git a/docs/reference/api/general.md b/docs/reference/api/general.md index 12454145569bb..e0fb97a1513e0 100644 --- a/docs/reference/api/general.md +++ b/docs/reference/api/general.md @@ -454,6 +454,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 6b0f8254a720c..4191ab8970e92 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -2625,6 +2625,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", @@ -3124,6 +3125,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 }, "ssh_keygen_algorithm": "string", @@ -6767,18 +6769,20 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith "default_duration": 0, "default_token_lifetime": 0, "disable_expiry_refresh": true, + "max_admin_token_lifetime": 0, "max_token_lifetime": 0 } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `default_duration` | integer | false | | Default duration is only for browser, workspace app and oauth sessions. | -| `default_token_lifetime` | integer | false | | | -| `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | -| `max_token_lifetime` | integer | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------------|---------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `default_duration` | integer | false | | Default duration is only for browser, workspace app and oauth sessions. | +| `default_token_lifetime` | integer | false | | | +| `disable_expiry_refresh` | boolean | false | | Disable expiry refresh will disable automatically refreshing api keys when they are used from the api. This means the api key lifetime at creation is the lifetime of the api key. | +| `max_admin_token_lifetime` | integer | false | | | +| `max_token_lifetime` | integer | false | | | ## codersdk.SlimRole diff --git a/docs/reference/cli/server.md b/docs/reference/cli/server.md index 1b4052e335e66..8b47ac00dbc7b 100644 --- a/docs/reference/cli/server.md +++ b/docs/reference/cli/server.md @@ -910,6 +910,17 @@ Periodically check for new releases of Coder and inform the owner. The check is The maximum lifetime duration users can specify when creating an API token. +### --max-admin-token-lifetime + +| | | +|-------------|----------------------------------------------------| +| Type | duration | +| Environment | $CODER_MAX_ADMIN_TOKEN_LIFETIME | +| YAML | networking.http.maxAdminTokenLifetime | +| Default | 168h0m0s | + +The maximum lifetime duration administrators can specify when creating an API token. + ### --default-token-lifetime | | | diff --git a/dogfood/coder/main.tf b/dogfood/coder/main.tf index c983102eb9fca..af4417b78c04f 100644 --- a/dogfood/coder/main.tf +++ b/dogfood/coder/main.tf @@ -2,7 +2,7 @@ terraform { required_providers { coder = { source = "coder/coder" - version = "~> 2.0" + version = "~> 2.5" } docker = { source = "kreuzwerker/docker" @@ -267,21 +267,23 @@ module "personalize" { module "code-server" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/code-server/coder" - version = "1.2.0" + version = "1.3.0" agent_id = coder_agent.dev.id folder = local.repo_dir auto_install_extensions = true + group = "Web Editors" } module "vscode-web" { count = data.coder_workspace.me.start_count source = "dev.registry.coder.com/coder/vscode-web/coder" - version = "1.1.0" + version = "1.2.0" agent_id = coder_agent.dev.id folder = local.repo_dir extensions = ["github.copilot"] auto_install_extensions = true # will install extensions from the repos .vscode/extensions.json file accept_license = true + group = "Web Editors" } module "jetbrains" { diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index d11304742d974..edacc0c43fc0b 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -333,6 +333,10 @@ NETWORKING / HTTP OPTIONS: The maximum lifetime duration users can specify when creating an API token. + --max-admin-token-lifetime duration, $CODER_MAX_ADMIN_TOKEN_LIFETIME (default: 168h0m0s) + The maximum lifetime duration administrators can specify when creating + an API token. + --proxy-health-interval duration, $CODER_PROXY_HEALTH_INTERVAL (default: 1m0s) The interval in which coderd should be checking the status of workspace proxies. diff --git a/provisioner/terraform/modules.go b/provisioner/terraform/modules.go index 363afe3f40fc0..e0da5f1578069 100644 --- a/provisioner/terraform/modules.go +++ b/provisioner/terraform/modules.go @@ -103,6 +103,13 @@ func GetModulesArchive(root fs.FS) ([]byte, error) { if !fileMode.IsRegular() && !fileMode.IsDir() { return nil } + + // .git directories are not needed in the archive and only cause + // hash differences for identical modules. + if fileMode.IsDir() && d.Name() == ".git" { + return fs.SkipDir + } + fileInfo, err := d.Info() if err != nil { return xerrors.Errorf("failed to archive module file %q: %w", filePath, err) diff --git a/provisionerd/proto/provisionerd.proto b/provisionerd/proto/provisionerd.proto index 0accc48f00a58..55c7289a8ae33 100644 --- a/provisionerd/proto/provisionerd.proto +++ b/provisionerd/proto/provisionerd.proto @@ -130,7 +130,7 @@ message UpdateJobRequest { repeated provisioner.TemplateVariable template_variables = 4; repeated provisioner.VariableValue user_variable_values = 5; bytes readme = 6; - map workspace_tags = 7; + map workspace_tags = 7; } message UpdateJobResponse { diff --git a/provisionerd/runner/runner.go b/provisionerd/runner/runner.go index ed1f134556fba..2894dadb8ff0a 100644 --- a/provisionerd/runner/runner.go +++ b/provisionerd/runner/runner.go @@ -552,8 +552,9 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p CreatedAt: time.Now().UnixMilli(), }) startProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Metadata{ - CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, - WorkspaceTransition: sdkproto.WorkspaceTransition_START, + CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, + WorkspaceOwnerGroups: r.job.GetTemplateImport().Metadata.WorkspaceOwnerGroups, + WorkspaceTransition: sdkproto.WorkspaceTransition_START, }) if err != nil { return nil, r.failedJobf("template import provision for start: %s", err) @@ -567,8 +568,9 @@ func (r *Runner) runTemplateImport(ctx context.Context) (*proto.CompletedJob, *p CreatedAt: time.Now().UnixMilli(), }) stopProvision, err := r.runTemplateImportProvision(ctx, updateResponse.VariableValues, &sdkproto.Metadata{ - CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, - WorkspaceTransition: sdkproto.WorkspaceTransition_STOP, + CoderUrl: r.job.GetTemplateImport().Metadata.CoderUrl, + WorkspaceOwnerGroups: r.job.GetTemplateImport().Metadata.WorkspaceOwnerGroups, + WorkspaceTransition: sdkproto.WorkspaceTransition_STOP, }) if err != nil { return nil, r.failedJobf("template import provision for stop: %s", err) diff --git a/provisionersdk/proto/provisioner.proto b/provisionersdk/proto/provisioner.proto index 4d75187aa42da..a0ebd144031e0 100644 --- a/provisionersdk/proto/provisioner.proto +++ b/provisionersdk/proto/provisioner.proto @@ -76,12 +76,12 @@ message RichParameterValue { // If a prebuild remains unclaimed for longer than ttl seconds, it is deleted and // recreated to prevent staleness. message ExpirationPolicy { - int32 ttl = 1; + int32 ttl = 1; } message Prebuild { int32 instances = 1; - ExpirationPolicy expiration_policy = 2; + ExpirationPolicy expiration_policy = 2; } // Preset represents a set of preset parameters for a template version. @@ -153,7 +153,7 @@ message Agent { string id = 1; string name = 2; map env = 3; - // Field 4 was startup_script, now removed. + // Field 4 was startup_script, now removed. string operating_system = 5; string architecture = 6; string directory = 7; @@ -166,16 +166,16 @@ message Agent { string troubleshooting_url = 12; string motd_file = 13; // Field 14 was bool login_before_ready = 14, now removed. - // Field 15, 16, 17 were related to scripts, which are now removed. + // Field 15, 16, 17 were related to scripts, which are now removed. repeated Metadata metadata = 18; - // Field 19 was startup_script_behavior, now removed. + // Field 19 was startup_script_behavior, now removed. DisplayApps display_apps = 20; - repeated Script scripts = 21; - repeated Env extra_envs = 22; - int64 order = 23; - ResourcesMonitoring resources_monitoring = 24; - repeated Devcontainer devcontainers = 25; - string api_key_scope = 26; + repeated Script scripts = 21; + repeated Env extra_envs = 22; + int64 order = 23; + ResourcesMonitoring resources_monitoring = 24; + repeated Devcontainer devcontainers = 25; + string api_key_scope = 26; } enum AppSharingLevel { @@ -185,19 +185,19 @@ enum AppSharingLevel { } message ResourcesMonitoring { - MemoryResourceMonitor memory = 1; - repeated VolumeResourceMonitor volumes = 2; + MemoryResourceMonitor memory = 1; + repeated VolumeResourceMonitor volumes = 2; } message MemoryResourceMonitor { - bool enabled = 1; - int32 threshold = 2; + bool enabled = 1; + int32 threshold = 2; } message VolumeResourceMonitor { - string path = 1; - bool enabled = 2; - int32 threshold = 3; + string path = 1; + bool enabled = 2; + int32 threshold = 3; } message DisplayApps { @@ -209,27 +209,27 @@ message DisplayApps { } message Env { - string name = 1; - string value = 2; + string name = 1; + string value = 2; } // Script represents a script to be run on the workspace. message Script { - string display_name = 1; - string icon = 2; - string script = 3; - string cron = 4; - bool start_blocks_login = 5; - bool run_on_start = 6; - bool run_on_stop = 7; - int32 timeout_seconds = 8; - string log_path = 9; + string display_name = 1; + string icon = 2; + string script = 3; + string cron = 4; + bool start_blocks_login = 5; + bool run_on_start = 6; + bool run_on_stop = 7; + int32 timeout_seconds = 8; + string log_path = 9; } message Devcontainer { - string workspace_folder = 1; - string config_path = 2; - string name = 3; + string workspace_folder = 1; + string config_path = 2; + string name = 3; } enum AppOpenIn { @@ -308,9 +308,9 @@ message RunningAgentAuthToken { string token = 2; } enum PrebuiltWorkspaceBuildStage { - NONE = 0; // Default value for builds unrelated to prebuilds. - CREATE = 1; // A prebuilt workspace is being provisioned. - CLAIM = 2; // A prebuilt workspace is being claimed. + NONE = 0; // Default value for builds unrelated to prebuilds. + CREATE = 1; // A prebuilt workspace is being provisioned. + CLAIM = 2; // A prebuilt workspace is being claimed. } // Metadata is information about a workspace used in the execution of a build diff --git a/provisionersdk/session.go b/provisionersdk/session.go index 8c5b8cf40b70d..fe6e3e2ca1f97 100644 --- a/provisionersdk/session.go +++ b/provisionersdk/session.go @@ -100,7 +100,11 @@ func (s *Session) requestReader(done <-chan struct{}) <-chan *proto.Request { for { req, err := s.stream.Recv() if err != nil { - s.Logger.Info(s.Context(), "recv done on Session", slog.Error(err)) + if !xerrors.Is(err, io.EOF) { + s.Logger.Warn(s.Context(), "recv done on Session", slog.Error(err)) + } else { + s.Logger.Info(s.Context(), "recv done on Session") + } return } select { diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 5463ad7a44dd6..28807bd547c2a 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -24,7 +24,10 @@ import type dayjs from "dayjs"; import userAgentParser from "ua-parser-js"; import { OneWayWebSocket } from "../utils/OneWayWebSocket"; import { delay } from "../utils/delay"; -import type { PostWorkspaceUsageRequest } from "./typesGenerated"; +import type { + DynamicParametersRequest, + PostWorkspaceUsageRequest, +} from "./typesGenerated"; import * as TypesGen from "./typesGenerated"; const getMissingParameters = ( @@ -73,8 +76,10 @@ const getMissingParameters = ( if (templateParameter.options.length === 0) { continue; } - - // Check if there is a new value + // For multi-select, extra steps are necessary to JSON parse the value. + if (templateParameter.form_type === "multi-select") { + continue; + } let buildParameter = newBuildParameters.find( (p) => p.name === templateParameter.name, ); @@ -231,7 +236,7 @@ export const watchWorkspaceAgentLogs = ( /** * WebSocket compression in Safari (confirmed in 16.5) is broken when * the server sends large messages. The following error is seen: - * WebSocket connection to 'wss://...' failed: The operation couldn’t be completed. + * WebSocket connection to 'wss://...' failed: The operation couldn't be completed. */ if (userAgentParser(navigator.userAgent).browser.name === "Safari") { searchParams.set("no_compression", ""); @@ -990,6 +995,17 @@ class ApiMethods { return response.data; }; + getTemplateVersionDynamicParameters = async ( + versionId: string, + data: TypesGen.DynamicParametersRequest, + ): Promise => { + const response = await this.axios.post( + `/api/v2/templateversions/${versionId}/dynamic-parameters/evaluate`, + data, + ); + return response.data; + }; + getTemplateVersionRichParameters = async ( versionId: string, ): Promise => { @@ -2132,6 +2148,38 @@ class ApiMethods { await this.axios.delete(`/api/v2/licenses/${licenseId}`); }; + getDynamicParameters = async ( + templateVersionId: string, + ownerId: string, + oldBuildParameters: TypesGen.WorkspaceBuildParameter[], + ) => { + const request: DynamicParametersRequest = { + id: 1, + owner_id: ownerId, + inputs: Object.fromEntries( + new Map(oldBuildParameters.map((param) => [param.name, param.value])), + ), + }; + + const dynamicParametersResponse = + await this.getTemplateVersionDynamicParameters( + templateVersionId, + request, + ); + + return dynamicParametersResponse.parameters.map((p) => ({ + ...p, + description_plaintext: p.description || "", + default_value: p.default_value?.valid ? p.default_value.value : "", + options: p.options + ? p.options.map((opt) => ({ + ...opt, + value: opt.value?.valid ? opt.value.value : "", + })) + : [], + })); + }; + /** Steps to change the workspace version * - Get the latest template to access the latest active version * - Get the current build parameters @@ -2145,11 +2193,23 @@ class ApiMethods { workspace: TypesGen.Workspace, templateVersionId: string, newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + isDynamicParametersEnabled = false, ): Promise => { - const [currentBuildParameters, templateParameters] = await Promise.all([ - this.getWorkspaceBuildParameters(workspace.latest_build.id), - this.getTemplateVersionRichParameters(templateVersionId), - ]); + const currentBuildParameters = await this.getWorkspaceBuildParameters( + workspace.latest_build.id, + ); + + let templateParameters: TypesGen.TemplateVersionParameter[] = []; + if (isDynamicParametersEnabled) { + templateParameters = await this.getDynamicParameters( + templateVersionId, + workspace.owner_id, + currentBuildParameters, + ); + } else { + templateParameters = + await this.getTemplateVersionRichParameters(templateVersionId); + } const missingParameters = getMissingParameters( currentBuildParameters, @@ -2180,6 +2240,7 @@ class ApiMethods { updateWorkspace = async ( workspace: TypesGen.Workspace, newBuildParameters: TypesGen.WorkspaceBuildParameter[] = [], + isDynamicParametersEnabled = false, ): Promise => { const [template, oldBuildParameters] = await Promise.all([ this.getTemplate(workspace.template_id), @@ -2187,8 +2248,19 @@ class ApiMethods { ]); const activeVersionId = template.active_version_id; - const templateParameters = - await this.getTemplateVersionRichParameters(activeVersionId); + + let templateParameters: TypesGen.TemplateVersionParameter[] = []; + + if (isDynamicParametersEnabled) { + templateParameters = await this.getDynamicParameters( + activeVersionId, + workspace.owner_id, + oldBuildParameters, + ); + } else { + templateParameters = + await this.getTemplateVersionRichParameters(activeVersionId); + } const missingParameters = getMissingParameters( oldBuildParameters, diff --git a/site/src/api/queries/workspaces.ts b/site/src/api/queries/workspaces.ts index 6c6a1aa19825c..5a4cdb46dd4e9 100644 --- a/site/src/api/queries/workspaces.ts +++ b/site/src/api/queries/workspaces.ts @@ -163,6 +163,7 @@ export const updateDeadline = ( export const changeVersion = ( workspace: Workspace, queryClient: QueryClient, + isDynamicParametersEnabled: boolean, ) => { return { mutationFn: ({ @@ -172,7 +173,12 @@ export const changeVersion = ( versionId: string; buildParameters?: WorkspaceBuildParameter[]; }) => { - return API.changeWorkspaceVersion(workspace, versionId, buildParameters); + return API.changeWorkspaceVersion( + workspace, + versionId, + buildParameters, + isDynamicParametersEnabled, + ); }, onSuccess: async (build: WorkspaceBuild) => { await updateWorkspaceBuild(build, queryClient); @@ -185,8 +191,18 @@ export const updateWorkspace = ( queryClient: QueryClient, ) => { return { - mutationFn: (buildParameters?: WorkspaceBuildParameter[]) => { - return API.updateWorkspace(workspace, buildParameters); + mutationFn: ({ + buildParameters, + isDynamicParametersEnabled, + }: { + buildParameters?: WorkspaceBuildParameter[]; + isDynamicParametersEnabled: boolean; + }) => { + return API.updateWorkspace( + workspace, + buildParameters, + isDynamicParametersEnabled, + ); }, onSuccess: async (build: WorkspaceBuild) => { await updateWorkspaceBuild(build, queryClient); diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9fa6e45fa30da..c662b27386401 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -2519,6 +2519,7 @@ export interface SessionLifetime { readonly default_duration: number; readonly default_token_lifetime?: number; readonly max_token_lifetime?: number; + readonly max_admin_token_lifetime?: number; } // From codersdk/client.go diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx index 7dbd536204254..2ec8ab40781c7 100644 --- a/site/src/components/Dialog/Dialog.tsx +++ b/site/src/components/Dialog/Dialog.tsx @@ -45,7 +45,7 @@ export const DialogContent = forwardRef< > = ({ }) => (
(({ className, ...props }, ref) => ( )); diff --git a/site/src/modules/workspaces/DynamicParameter/useDynamicParametersOptOut.ts b/site/src/modules/workspaces/DynamicParameter/useDynamicParametersOptOut.ts new file mode 100644 index 0000000000000..6401f5f7f3564 --- /dev/null +++ b/site/src/modules/workspaces/DynamicParameter/useDynamicParametersOptOut.ts @@ -0,0 +1,42 @@ +import { useQuery } from "react-query"; + +export const optOutKey = (id: string): string => `parameters.${id}.optOut`; + +interface UseDynamicParametersOptOutOptions { + templateId: string | undefined; + templateUsesClassicParameters: boolean | undefined; + enabled: boolean; +} + +export const useDynamicParametersOptOut = ({ + templateId, + templateUsesClassicParameters, + enabled, +}: UseDynamicParametersOptOutOptions) => { + return useQuery({ + enabled: !!templateId && enabled, + queryKey: ["dynamicParametersOptOut", templateId], + queryFn: () => { + if (!templateId) { + // This should not happen if enabled is working correctly, + // but as a type guard and sanity check. + throw new Error("templateId is required"); + } + const localStorageKey = optOutKey(templateId); + const storedOptOutString = localStorage.getItem(localStorageKey); + + let optedOut: boolean; + + if (storedOptOutString !== null) { + optedOut = storedOptOutString === "true"; + } else { + optedOut = Boolean(templateUsesClassicParameters); + } + + return { + templateId, + optedOut, + }; + }, + }); +}; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx new file mode 100644 index 0000000000000..04bb92a5e79b2 --- /dev/null +++ b/site/src/modules/workspaces/WorkspaceMoreActions/UpdateBuildParametersDialogExperimental.tsx @@ -0,0 +1,71 @@ +import type { TemplateVersionParameter } from "api/typesGenerated"; +import { Button } from "components/Button/Button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "components/Dialog/Dialog"; +import type { FC } from "react"; +import { useNavigate } from "react-router-dom"; + +type UpdateBuildParametersDialogExperimentalProps = { + open: boolean; + onClose: () => void; + missedParameters: TemplateVersionParameter[]; + workspaceOwnerName: string; + workspaceName: string; + templateVersionId: string | undefined; +}; + +export const UpdateBuildParametersDialogExperimental: FC< + UpdateBuildParametersDialogExperimentalProps +> = ({ + missedParameters, + open, + onClose, + workspaceOwnerName, + workspaceName, + templateVersionId, +}) => { + const navigate = useNavigate(); + + const handleGoToParameters = () => { + onClose(); + navigate( + `/@${workspaceOwnerName}/${workspaceName}/settings/parameters?templateVersionId=${templateVersionId}`, + ); + }; + + return ( + !isOpen && onClose()}> + + + Update workspace parameters + + This template has{" "} + + {missedParameters.length} new parameter + {missedParameters.length === 1 ? "" : "s"} + {" "} + that must be configured to complete the update. + + + Would you like to go to the workspace parameters page to review and + update these parameters before continuing? + + + + + + + + + ); +}; diff --git a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx index 22e9638ee7caa..8cdbafad435a3 100644 --- a/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx +++ b/site/src/modules/workspaces/WorkspaceMoreActions/WorkspaceMoreActions.tsx @@ -21,12 +21,15 @@ import { SettingsIcon, TrashIcon, } from "lucide-react"; +import { useDashboard } from "modules/dashboard/useDashboard"; +import { useDynamicParametersOptOut } from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut"; import { type FC, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "react-query"; import { Link as RouterLink } from "react-router-dom"; import { ChangeWorkspaceVersionDialog } from "./ChangeWorkspaceVersionDialog"; import { DownloadLogsDialog } from "./DownloadLogsDialog"; import { UpdateBuildParametersDialog } from "./UpdateBuildParametersDialog"; +import { UpdateBuildParametersDialogExperimental } from "./UpdateBuildParametersDialogExperimental"; import { WorkspaceDeleteDialog } from "./WorkspaceDeleteDialog"; import { useWorkspaceDuplication } from "./useWorkspaceDuplication"; @@ -40,6 +43,15 @@ export const WorkspaceMoreActions: FC = ({ disabled, }) => { const queryClient = useQueryClient(); + const { experiments } = useDashboard(); + const isDynamicParametersEnabled = experiments.includes("dynamic-parameters"); + + const optOutQuery = useDynamicParametersOptOut({ + templateId: workspace.template_id, + templateUsesClassicParameters: + workspace.template_use_classic_parameter_flow, + enabled: isDynamicParametersEnabled, + }); // Permissions const { data: permissions } = useQuery(workspacePermissions(workspace)); @@ -50,7 +62,7 @@ export const WorkspaceMoreActions: FC = ({ // Change version const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false); const changeVersionMutation = useMutation( - changeVersion(workspace, queryClient), + changeVersion(workspace, queryClient, optOutQuery.data?.optedOut === false), ); // Delete @@ -142,25 +154,46 @@ export const WorkspaceMoreActions: FC = ({ onClose={() => setIsDownloadDialogOpen(false)} /> - { - changeVersionMutation.reset(); - }} - onUpdate={(buildParameters) => { - if (changeVersionMutation.error instanceof MissingBuildParameters) { - changeVersionMutation.mutate({ - versionId: changeVersionMutation.error.versionId, - buildParameters, - }); + {!isDynamicParametersEnabled || optOutQuery.data?.optedOut ? ( + + open={changeVersionMutation.error instanceof MissingBuildParameters} + onClose={() => { + changeVersionMutation.reset(); + }} + onUpdate={(buildParameters) => { + if (changeVersionMutation.error instanceof MissingBuildParameters) { + changeVersionMutation.mutate({ + versionId: changeVersionMutation.error.versionId, + buildParameters, + }); + } + }} + /> + ) : ( + { + changeVersionMutation.reset(); + }} + workspaceOwnerName={workspace.owner_name} + workspaceName={workspace.name} + templateVersionId={ + changeVersionMutation.error instanceof MissingBuildParameters + ? changeVersionMutation.error?.versionId + : undefined + } + /> + )} { - updateWorkspaceMutation.mutate(buildParameters); + updateWorkspaceMutation.mutate({ + buildParameters, + isDynamicParametersEnabled: optOutQuery.data?.optedOut === false, + }); setIsConfirmingUpdate(false); }; @@ -67,6 +85,7 @@ export const useWorkspaceUpdate = ({ latestVersion, }, missingBuildParameters: { + workspace, error: updateWorkspaceMutation.error, onClose: () => { updateWorkspaceMutation.reset(); @@ -134,22 +153,57 @@ const UpdateConfirmationDialog: FC = ({ }; type MissingBuildParametersDialogProps = { + workspace: Workspace; error: unknown; onClose: () => void; onUpdate: (buildParameters: WorkspaceBuildParameter[]) => void; }; const MissingBuildParametersDialog: FC = ({ + workspace, error, ...dialogProps }) => { - return ( + const { experiments } = useDashboard(); + const isDynamicParametersEnabled = experiments.includes("dynamic-parameters"); + const optOutQuery = useDynamicParametersOptOut({ + templateId: workspace.template_id, + templateUsesClassicParameters: + workspace.template_use_classic_parameter_flow, + enabled: isDynamicParametersEnabled, + }); + + const missedParameters = + error instanceof MissingBuildParameters ? error.parameters : []; + const versionId = + error instanceof MissingBuildParameters ? error.versionId : undefined; + const isOpen = error instanceof MissingBuildParameters; + + if (optOutQuery.isError) { + return ; + } + if (isDynamicParametersEnabled && !optOutQuery.data) { + return ; + } + + // If dynamic parameters experiment is not enabled, or if opted out, use classic dialog + const shouldUseClassicDialog = + !isDynamicParametersEnabled || optOutQuery.data?.optedOut; + + return shouldUseClassicDialog ? ( + ) : ( + ); }; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx index 4f2d0a4e4f8f7..fe63391916128 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspaceExperimentRouter.tsx @@ -2,6 +2,10 @@ import { templateByName } from "api/queries/templates"; import { ErrorAlert } from "components/Alert/ErrorAlert"; import { Loader } from "components/Loader/Loader"; import { useDashboard } from "modules/dashboard/useDashboard"; +import { + optOutKey, + useDynamicParametersOptOut, +} from "modules/workspaces/DynamicParameter/useDynamicParametersOptOut"; import type { FC } from "react"; import { useQuery } from "react-query"; import { useParams } from "react-router-dom"; @@ -11,39 +15,26 @@ import { ExperimentalFormContext } from "./ExperimentalFormContext"; const CreateWorkspaceExperimentRouter: FC = () => { const { experiments } = useDashboard(); - const dynamicParametersEnabled = experiments.includes("dynamic-parameters"); + const isDynamicParametersEnabled = experiments.includes("dynamic-parameters"); const { organization: organizationName = "default", template: templateName } = useParams() as { organization?: string; template: string }; const templateQuery = useQuery({ ...templateByName(organizationName, templateName), - enabled: dynamicParametersEnabled, + enabled: isDynamicParametersEnabled, }); - const optOutQuery = useQuery({ + const optOutQuery = useDynamicParametersOptOut({ + templateId: templateQuery.data?.id, + templateUsesClassicParameters: + templateQuery.data?.use_classic_parameter_flow, enabled: !!templateQuery.data, - queryKey: [organizationName, "template", templateQuery.data?.id, "optOut"], - queryFn: () => { - const templateId = templateQuery.data?.id; - const localStorageKey = optOutKey(templateId ?? ""); - const storedOptOutString = localStorage.getItem(localStorageKey); - - let optOutResult: boolean; - - if (storedOptOutString !== null) { - optOutResult = storedOptOutString === "true"; - } else { - optOutResult = !!templateQuery.data?.use_classic_parameter_flow; - } - - return { - templateId: templateId, - optedOut: optOutResult, - }; - }, }); - if (dynamicParametersEnabled) { + if (isDynamicParametersEnabled) { + if (templateQuery.isError) { + return ; + } if (optOutQuery.isError) { return ; } @@ -77,5 +68,3 @@ const CreateWorkspaceExperimentRouter: FC = () => { }; export default CreateWorkspaceExperimentRouter; - -const optOutKey = (id: string) => `parameters.${id}.optOut`; diff --git a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx index 09056aa66af72..1527e084503d4 100644 --- a/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx +++ b/site/src/pages/CreateWorkspacePage/CreateWorkspacePageViewExperimental.tsx @@ -620,8 +620,12 @@ export const CreateWorkspacePageViewExperimental: FC< creatingWorkspace || isPresetParameter; - // Hide preset parameters if showPresetParameters is false - if (!showPresetParameters && isPresetParameter) { + // Always show preset parameters if they have any diagnostics + if ( + !showPresetParameters && + isPresetParameter && + parameter.diagnostics.length === 0 + ) { return null; } diff --git a/site/src/pages/TaskPage/TaskApps.tsx b/site/src/pages/TaskPage/TaskApps.tsx index 7954447853c1e..316d7b91dd633 100644 --- a/site/src/pages/TaskPage/TaskApps.tsx +++ b/site/src/pages/TaskPage/TaskApps.tsx @@ -32,10 +32,13 @@ export const TaskApps: FC = ({ task }) => { .flatMap((a) => a?.apps) .filter((a) => !!a && a.slug !== AI_APP_CHAT_SLUG); + const embeddedApps = apps.filter((app) => !app.external); + const externalApps = apps.filter((app) => app.external); + const [activeAppId, setActiveAppId] = useState(() => { - const appId = task.workspace.latest_app_status?.app_id; + const appId = embeddedApps[0]?.id; if (!appId) { - throw new Error("No active app found in task"); + throw new Error("No apps found in task"); } return appId; }); @@ -52,9 +55,6 @@ export const TaskApps: FC = ({ task }) => { throw new Error(`Agent for app ${activeAppId} not found in task workspace`); } - const embeddedApps = apps.filter((app) => !app.external); - const externalApps = apps.filter((app) => app.external); - return (
diff --git a/site/src/pages/TaskPage/TaskPage.tsx b/site/src/pages/TaskPage/TaskPage.tsx index 287568852d209..ea32ea5d43f40 100644 --- a/site/src/pages/TaskPage/TaskPage.tsx +++ b/site/src/pages/TaskPage/TaskPage.tsx @@ -92,10 +92,10 @@ const TaskPage = () => {

- Building the workspace + Starting your workspace

- Your task will run as soon as the workspace is ready + This should take a few minutes
diff --git a/site/src/pages/TaskPage/TaskSidebar.tsx b/site/src/pages/TaskPage/TaskSidebar.tsx index 872d64a60cbca..e1d31b8e6b33c 100644 --- a/site/src/pages/TaskPage/TaskSidebar.tsx +++ b/site/src/pages/TaskPage/TaskSidebar.tsx @@ -40,6 +40,8 @@ export const TaskSidebar: FC = ({ task }) => { .flatMap((r) => r.agents) .flatMap((a) => a?.apps) .find((a) => a?.slug === AI_APP_CHAT_SLUG); + const showChatApp = + chatApp && (chatApp.health === "disabled" || chatApp.health === "healthy"); return (
)} + {(templateVersionId || workspace.latest_build.template_version_id) && ( +
+ +

+ {templateVersionId ?? workspace.latest_build.template_version_id} +

+
+ )} +
{standardParameters.length > 0 && (
@@ -236,10 +248,21 @@ export const WorkspaceParametersPageViewExperimental: FC< diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx index c8577f191d47e..94c12f0372b59 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.test.tsx @@ -123,8 +123,8 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(2); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false); }); it("warns about and updates running workspaces", async () => { @@ -160,9 +160,9 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(3); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); }); it("warns about and ignores dormant workspaces", async () => { @@ -199,8 +199,8 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(2); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[1], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); }); it("warns about running workspaces and then dormant workspaces", async () => { @@ -241,9 +241,9 @@ describe("WorkspacesPage", () => { await waitFor(() => { expect(updateWorkspace).toHaveBeenCalledTimes(3); }); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2]); - expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3]); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[0], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[2], [], false); + expect(updateWorkspace).toHaveBeenCalledWith(workspaces[3], [], false); }); }); diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index b6309638e209d..acdded15d4bc9 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -37,6 +37,8 @@ function useSafeSearchParams() { } const WorkspacesPage: FC = () => { + const { experiments } = useDashboard(); + const isDynamicParametersEnabled = experiments.includes("dynamic-parameters"); const queryClient = useQueryClient(); // If we use a useSearchParams for each hook, the values will not be in sync. // So we have to use a single one, centralizing the values, and pass it to @@ -162,7 +164,10 @@ const WorkspacesPage: FC = () => { checkedWorkspaces={checkedWorkspaces} open={confirmingBatchAction === "update"} onConfirm={async () => { - await batchActions.updateAll(checkedWorkspaces); + await batchActions.updateAll({ + workspaces: checkedWorkspaces, + isDynamicParametersEnabled, + }); setConfirmingBatchAction(null); }} onClose={() => { diff --git a/site/src/pages/WorkspacesPage/batchActions.tsx b/site/src/pages/WorkspacesPage/batchActions.tsx index cbec017eb8583..806c7a03afddb 100644 --- a/site/src/pages/WorkspacesPage/batchActions.tsx +++ b/site/src/pages/WorkspacesPage/batchActions.tsx @@ -45,11 +45,15 @@ export function useBatchActions(options: UseBatchActionsProps) { }); const updateAllMutation = useMutation({ - mutationFn: (workspaces: readonly Workspace[]) => { + mutationFn: (payload: { + workspaces: readonly Workspace[]; + isDynamicParametersEnabled: boolean; + }) => { + const { workspaces, isDynamicParametersEnabled } = payload; return Promise.all( workspaces .filter((w) => w.outdated && !w.dormant_at) - .map((w) => API.updateWorkspace(w)), + .map((w) => API.updateWorkspace(w, [], isDynamicParametersEnabled)), ); }, onSuccess, diff --git a/tailnet/derpmap.go b/tailnet/derpmap.go index e2722c1ff9ab4..6f284dad05991 100644 --- a/tailnet/derpmap.go +++ b/tailnet/derpmap.go @@ -8,8 +8,11 @@ import ( "net/http" "os" "strconv" + "strings" + "time" "golang.org/x/xerrors" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" ) @@ -152,6 +155,49 @@ regionLoop: return derpMap, nil } +func ExtractPreferredDERPName(pingResult *ipnstate.PingResult, node *Node, derpMap *tailcfg.DERPMap) string { + // Sometimes the preferred DERP doesn't match the one we're actually + // connected with. Perhaps because the agent prefers a different DERP and + // we're using that server instead. + preferredDerpID := node.PreferredDERP + if pingResult.DERPRegionID != 0 { + preferredDerpID = pingResult.DERPRegionID + } + preferredDerp, ok := derpMap.Regions[preferredDerpID] + preferredDerpName := fmt.Sprintf("Unnamed %d", preferredDerpID) + if ok { + preferredDerpName = preferredDerp.RegionName + } + + return preferredDerpName +} + +// ExtractDERPLatency extracts a map of derp region names to their latencies +func ExtractDERPLatency(node *Node, derpMap *tailcfg.DERPMap) map[string]time.Duration { + latencyMs := make(map[string]time.Duration) + + // Convert DERP region IDs to friendly names for display in the UI. + for rawRegion, latency := range node.DERPLatency { + regionParts := strings.SplitN(rawRegion, "-", 2) + regionID, err := strconv.Atoi(regionParts[0]) + if err != nil { + continue + } + region, found := derpMap.Regions[regionID] + if !found { + // It's possible that a workspace agent is using an old DERPMap + // and reports regions that do not exist. If that's the case, + // report the region as unknown! + region = &tailcfg.DERPRegion{ + RegionID: regionID, + RegionName: fmt.Sprintf("Unnamed %d", regionID), + } + } + latencyMs[region.RegionName] = time.Duration(latency * float64(time.Second)) + } + return latencyMs +} + // CompareDERPMaps returns true if the given DERPMaps are equivalent. Ordering // of slices is ignored. // diff --git a/tailnet/derpmap_test.go b/tailnet/derpmap_test.go index a91969bfeca09..c723437cad0d2 100644 --- a/tailnet/derpmap_test.go +++ b/tailnet/derpmap_test.go @@ -10,6 +10,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "tailscale.com/ipn/ipnstate" "tailscale.com/tailcfg" "github.com/coder/coder/v2/tailnet" @@ -162,3 +163,111 @@ func TestNewDERPMap(t *testing.T) { require.ErrorContains(t, err, "DERP map has no DERP nodes") }) } + +func TestExtractDERPLatency(t *testing.T) { + t.Parallel() + + derpMap := &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionName: "Region One", + Nodes: []*tailcfg.DERPNode{ + {Name: "node1", RegionID: 1}, + }, + }, + 2: { + RegionID: 2, + RegionName: "Region Two", + Nodes: []*tailcfg.DERPNode{ + {Name: "node2", RegionID: 2}, + }, + }, + }, + } + + t.Run("Basic", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{ + "1-node1": 0.05, + "2-node2": 0.1, + }, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.EqualValues(t, 50, latencyMs["Region One"].Milliseconds()) + require.EqualValues(t, 100, latencyMs["Region Two"].Milliseconds()) + require.Len(t, latencyMs, 2) + }) + + t.Run("UnknownRegion", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{ + "999-node999": 0.2, + }, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.EqualValues(t, 200, latencyMs["Unnamed 999"].Milliseconds()) + require.Len(t, latencyMs, 1) + }) + + t.Run("InvalidRegionFormat", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{ + "invalid": 0.3, + "1-node1": 0.05, + "abc-node": 0.15, + }, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.EqualValues(t, 50, latencyMs["Region One"].Milliseconds()) + require.Len(t, latencyMs, 1) + require.NotContains(t, latencyMs, "invalid") + require.NotContains(t, latencyMs, "abc-node") + }) + + t.Run("EmptyInput", func(t *testing.T) { + t.Parallel() + node := &tailnet.Node{ + DERPLatency: map[string]float64{}, + } + latencyMs := tailnet.ExtractDERPLatency(node, derpMap) + require.Empty(t, latencyMs) + }) +} + +func TestExtractPreferredDERPName(t *testing.T) { + t.Parallel() + derpMap := &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: {RegionName: "New York"}, + 2: {RegionName: "London"}, + }, + } + + t.Run("UsesPingRegion", func(t *testing.T) { + t.Parallel() + pingResult := &ipnstate.PingResult{DERPRegionID: 2} + node := &tailnet.Node{PreferredDERP: 1} + result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + require.Equal(t, "London", result) + }) + + t.Run("UsesNodePreferred", func(t *testing.T) { + t.Parallel() + pingResult := &ipnstate.PingResult{DERPRegionID: 0} + node := &tailnet.Node{PreferredDERP: 1} + result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + require.Equal(t, "New York", result) + }) + + t.Run("UnknownRegion", func(t *testing.T) { + t.Parallel() + pingResult := &ipnstate.PingResult{DERPRegionID: 99} + node := &tailnet.Node{PreferredDERP: 1} + result := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + require.Equal(t, "Unnamed 99", result) + }) +} diff --git a/tailnet/test/integration/integration.go b/tailnet/test/integration/integration.go index 1190a3aa98b0d..70320567841a9 100644 --- a/tailnet/test/integration/integration.go +++ b/tailnet/test/integration/integration.go @@ -28,8 +28,10 @@ import ( "golang.org/x/xerrors" "tailscale.com/derp" "tailscale.com/derp/derphttp" + "tailscale.com/net/packet" "tailscale.com/tailcfg" "tailscale.com/types/key" + "tailscale.com/wgengine/capture" "cdr.dev/slog" "github.com/coder/coder/v2/coderd/httpapi" @@ -54,6 +56,7 @@ type Client struct { ID uuid.UUID ListenPort uint16 ShouldRunTests bool + TunnelSrc bool } var Client1 = Client{ @@ -61,6 +64,7 @@ var Client1 = Client{ ID: uuid.MustParse("00000000-0000-0000-0000-000000000001"), ListenPort: client1Port, ShouldRunTests: true, + TunnelSrc: true, } var Client2 = Client{ @@ -68,21 +72,20 @@ var Client2 = Client{ ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"), ListenPort: client2Port, ShouldRunTests: false, + TunnelSrc: false, } type TestTopology struct { Name string - // SetupNetworking creates interfaces and network namespaces for the test. - // The most simple implementation is NetworkSetupDefault, which only creates - // a network namespace shared for all tests. - SetupNetworking func(t *testing.T, logger slog.Logger) TestNetworking + + NetworkingProvider NetworkingProvider // Server is the server starter for the test. It is executed in the server // subprocess. Server ServerStarter - // StartClient gets called in each client subprocess. It's expected to + // ClientStarter.StartClient gets called in each client subprocess. It's expected to // create the tailnet.Conn and ensure connectivity to it's peer. - StartClient func(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me Client, peer Client) *tailnet.Conn + ClientStarter ClientStarter // RunTests is the main test function. It's called in each of the client // subprocesses. If tests can only run once, they should check the client ID @@ -97,6 +100,17 @@ type ServerStarter interface { StartServer(t *testing.T, logger slog.Logger, listenAddr string) } +type NetworkingProvider interface { + // SetupNetworking creates interfaces and network namespaces for the test. + // The most simple implementation is NetworkSetupDefault, which only creates + // a network namespace shared for all tests. + SetupNetworking(t *testing.T, logger slog.Logger) TestNetworking +} + +type ClientStarter interface { + StartClient(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me Client, peer Client) *tailnet.Conn +} + type SimpleServerOptions struct { // FailUpgradeDERP will make the DERP server fail to handle the initial DERP // upgrade in a way that causes the client to fallback to @@ -369,77 +383,107 @@ http { _, _ = ExecBackground(t, "server.nginx", nil, "nginx", []string{"-c", cfgPath}) } -// StartClientDERP creates a client connection to the server for coordination -// and creates a tailnet.Conn which will only use DERP to connect to the peer. -func StartClientDERP(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { - return startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{ - Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)}, - DERPMap: derpMap, - BlockEndpoints: true, - Logger: logger, - DERPForceWebSockets: false, - ListenPort: me.ListenPort, - // These tests don't have internet connection, so we need to force - // magicsock to do anything. - ForceNetworkUp: true, - }) +type BasicClientStarter struct { + BlockEndpoints bool + DERPForceWebsockets bool + // WaitForConnection means wait for (any) peer connection before returning from StartClient + WaitForConnection bool + // WaitForConnection means wait for a direct peer connection before returning from StartClient + WaitForDirect bool + // Service is a network service (e.g. an echo server) to start on the client. If Wait* is set, the service is + // started prior to waiting. + Service NetworkService + LogPackets bool } -// StartClientDERPWebSockets does the same thing as StartClientDERP but will -// only use DERP WebSocket fallback. -func StartClientDERPWebSockets(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { - return startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{ - Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)}, - DERPMap: derpMap, - BlockEndpoints: true, - Logger: logger, - DERPForceWebSockets: true, - ListenPort: me.ListenPort, - // These tests don't have internet connection, so we need to force - // magicsock to do anything. - ForceNetworkUp: true, - }) +type NetworkService interface { + StartService(t *testing.T, logger slog.Logger, conn *tailnet.Conn) } -// StartClientDirect does the same thing as StartClientDERP but disables -// BlockEndpoints (which enables Direct connections), and waits for a direct -// connection to be established between the two peers. -func StartClientDirect(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { +func (b BasicClientStarter) StartClient(t *testing.T, logger slog.Logger, serverURL *url.URL, derpMap *tailcfg.DERPMap, me, peer Client) *tailnet.Conn { + var hook capture.Callback + if b.LogPackets { + pktLogger := packetLogger{logger} + hook = pktLogger.LogPacket + } conn := startClientOptions(t, logger, serverURL, me, peer, &tailnet.Options{ Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.PrefixFromUUID(me.ID)}, DERPMap: derpMap, - BlockEndpoints: false, + BlockEndpoints: b.BlockEndpoints, Logger: logger, - DERPForceWebSockets: true, + DERPForceWebSockets: b.DERPForceWebsockets, ListenPort: me.ListenPort, // These tests don't have internet connection, so we need to force // magicsock to do anything. ForceNetworkUp: true, + CaptureHook: hook, }) - // Wait for direct connection to be established. - peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID) - require.Eventually(t, func() bool { - t.Log("attempting ping to peer to judge direct connection") - ctx := testutil.Context(t, testutil.WaitShort) - _, p2p, pong, err := conn.Ping(ctx, peerIP) - if err != nil { - t.Logf("ping failed: %v", err) - return false - } - if !p2p { - t.Log("ping succeeded, but not direct yet") - return false - } - t.Logf("ping succeeded, direct connection established via %s", pong.Endpoint) - return true - }, testutil.WaitLong, testutil.IntervalMedium) + if b.Service != nil { + b.Service.StartService(t, logger, conn) + } + + if b.WaitForConnection || b.WaitForDirect { + // Wait for connection to be established. + peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID) + require.Eventually(t, func() bool { + t.Log("attempting ping to peer to judge direct connection") + ctx := testutil.Context(t, testutil.WaitShort) + _, p2p, pong, err := conn.Ping(ctx, peerIP) + if err != nil { + t.Logf("ping failed: %v", err) + return false + } + if !p2p && b.WaitForDirect { + t.Log("ping succeeded, but not direct yet") + return false + } + t.Logf("ping succeeded, p2p=%t, endpoint=%s", p2p, pong.Endpoint) + return true + }, testutil.WaitLong, testutil.IntervalMedium) + } return conn } -type ClientStarter struct { - Options *tailnet.Options +const EchoPort = 2381 + +type UDPEchoService struct{} + +func (UDPEchoService) StartService(t *testing.T, logger slog.Logger, _ *tailnet.Conn) { + // tailnet doesn't handle UDP connections "in-process" the way we do for TCP, so we need to listen in the OS, + // and tailnet will forward packets. + l, err := net.ListenUDP("udp", &net.UDPAddr{ + IP: net.IPv6zero, // all interfaces + Port: EchoPort, + }) + require.NoError(t, err) + logger.Info(context.Background(), "started UDPEcho server") + t.Cleanup(func() { + lCloseErr := l.Close() + if lCloseErr != nil { + t.Logf("error closing UDPEcho listener: %v", lCloseErr) + } + }) + go func() { + buf := make([]byte, 1500) + for { + n, remote, readErr := l.ReadFromUDP(buf) + if readErr != nil { + logger.Info(context.Background(), "error reading UDPEcho listener", slog.Error(readErr)) + return + } + logger.Info(context.Background(), "received UDPEcho packet", + slog.F("len", n), slog.F("remote", remote)) + n, writeErr := l.WriteToUDP(buf[:n], remote) + if writeErr != nil { + logger.Info(context.Background(), "error writing UDPEcho listener", slog.Error(writeErr)) + return + } + logger.Info(context.Background(), "wrote UDPEcho packet", + slog.F("len", n), slog.F("remote", remote)) + } + }() } func startClientOptions(t *testing.T, logger slog.Logger, serverURL *url.URL, me, peer Client, options *tailnet.Options) *tailnet.Conn { @@ -467,9 +511,16 @@ func startClientOptions(t *testing.T, logger slog.Logger, serverURL *url.URL, me _ = conn.Close() }) - ctrl := tailnet.NewTunnelSrcCoordController(logger, conn) - ctrl.AddDestination(peer.ID) - coordination := ctrl.New(coord) + var coordination tailnet.CloserWaiter + if me.TunnelSrc { + ctrl := tailnet.NewTunnelSrcCoordController(logger, conn) + ctrl.AddDestination(peer.ID) + coordination = ctrl.New(coord) + } else { + // use the "Agent" controller so that we act as a tunnel destination and send "ReadyForHandshake" acks. + ctrl := tailnet.NewAgentCoordinationController(logger, conn) + coordination = ctrl.New(coord) + } t.Cleanup(func() { cctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) defer cancel() @@ -492,11 +543,17 @@ func basicDERPMap(serverURLStr string) (*tailcfg.DERPMap, error) { } hostname := serverURL.Hostname() - ipv4 := "" + ipv4 := "none" + ipv6 := "none" ip, err := netip.ParseAddr(hostname) if err == nil { hostname = "" - ipv4 = ip.String() + if ip.Is4() { + ipv4 = ip.String() + } + if ip.Is6() { + ipv6 = ip.String() + } } return &tailcfg.DERPMap{ @@ -511,7 +568,7 @@ func basicDERPMap(serverURLStr string) (*tailcfg.DERPMap, error) { RegionID: 1, HostName: hostname, IPv4: ipv4, - IPv6: "none", + IPv6: ipv6, DERPPort: port, STUNPort: -1, ForceHTTP: true, @@ -648,3 +705,35 @@ func (w *testWriter) Flush() { } w.capturedLines = nil } + +type packetLogger struct { + l slog.Logger +} + +func (p packetLogger) LogPacket(path capture.Path, when time.Time, pkt []byte, _ packet.CaptureMeta) { + q := new(packet.Parsed) + q.Decode(pkt) + p.l.Info(context.Background(), "Packet", + slog.F("path", pathString(path)), + slog.F("when", when), + slog.F("decode", q.String()), + slog.F("len", len(pkt)), + ) +} + +func pathString(path capture.Path) string { + switch path { + case capture.FromLocal: + return "Local" + case capture.FromPeer: + return "Peer" + case capture.SynthesizedToLocal: + return "SynthesizedToLocal" + case capture.SynthesizedToPeer: + return "SynthesizedToPeer" + case capture.PathDisco: + return "Disco" + default: + return "<>" + } +} diff --git a/tailnet/test/integration/integration_test.go b/tailnet/test/integration/integration_test.go index b2cfa900674f0..260c21a6458f5 100644 --- a/tailnet/test/integration/integration_test.go +++ b/tailnet/test/integration/integration_test.go @@ -76,70 +76,90 @@ func TestMain(m *testing.M) { var topologies = []integration.TestTopology{ { // Test that DERP over loopback works. - Name: "BasicLoopbackDERP", - SetupNetworking: integration.SetupNetworkingLoopback, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + Name: "BasicLoopbackDERP", + NetworkingProvider: integration.NetworkingLoopback{}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, { // Test that DERP over "easy" NAT works. The server, client 1 and client // 2 are on different networks with their own routers, which are joined // by a bridge. - Name: "EasyNATDERP", - SetupNetworking: integration.SetupNetworkingEasyNAT, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + Name: "EasyNATDERP", + NetworkingProvider: integration.NetworkingNAT{StunCount: 0, Client1Hard: false, Client2Hard: false}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, { // Test that direct over "easy" NAT works with IP/ports grabbed from // STUN. - Name: "EasyNATDirect", - SetupNetworking: integration.SetupNetworkingEasyNATWithSTUN, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDirect, - RunTests: integration.TestSuite, + Name: "EasyNATDirect", + NetworkingProvider: integration.NetworkingNAT{StunCount: 1, Client1Hard: false, Client2Hard: false}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{WaitForDirect: true}, + RunTests: integration.TestSuite, }, { // Test that direct over hard NAT <=> easy NAT works. - Name: "HardNATEasyNATDirect", - SetupNetworking: integration.SetupNetworkingHardNATEasyNATDirect, - Server: integration.SimpleServerOptions{}, - StartClient: integration.StartClientDirect, - RunTests: integration.TestSuite, + Name: "HardNATEasyNATDirect", + NetworkingProvider: integration.NetworkingNAT{StunCount: 2, Client1Hard: true, Client2Hard: false}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{WaitForDirect: true}, + RunTests: integration.TestSuite, + }, + { + // Test that direct over normal MTU works. + Name: "DirectMTU1500", + NetworkingProvider: integration.TriangleNetwork{InterClientMTU: 1500}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{ + WaitForDirect: true, + Service: integration.UDPEchoService{}, + LogPackets: true, + }, + RunTests: integration.TestBigUDP, + }, + { + // Test that small MTU works. + Name: "MTU1280", + NetworkingProvider: integration.TriangleNetwork{InterClientMTU: 1280}, + Server: integration.SimpleServerOptions{}, + ClientStarter: integration.BasicClientStarter{Service: integration.UDPEchoService{}, LogPackets: true}, + RunTests: integration.TestBigUDP, }, { // Test that DERP over WebSocket (as well as DERPForceWebSockets works). // This does not test the actual DERP failure detection code and // automatic fallback. - Name: "DERPForceWebSockets", - SetupNetworking: integration.SetupNetworkingEasyNAT, + Name: "DERPForceWebSockets", + NetworkingProvider: integration.NetworkingNAT{StunCount: 0, Client1Hard: false, Client2Hard: false}, Server: integration.SimpleServerOptions{ FailUpgradeDERP: false, DERPWebsocketOnly: true, }, - StartClient: integration.StartClientDERPWebSockets, - RunTests: integration.TestSuite, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true, DERPForceWebsockets: true}, + RunTests: integration.TestSuite, }, { // Test that falling back to DERP over WebSocket works. - Name: "DERPFallbackWebSockets", - SetupNetworking: integration.SetupNetworkingEasyNAT, + Name: "DERPFallbackWebSockets", + NetworkingProvider: integration.NetworkingNAT{StunCount: 0, Client1Hard: false, Client2Hard: false}, Server: integration.SimpleServerOptions{ FailUpgradeDERP: true, DERPWebsocketOnly: false, }, // Use a basic client that will try `Upgrade: derp` first. - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, { - Name: "BasicLoopbackDERPNGINX", - SetupNetworking: integration.SetupNetworkingLoopback, - Server: integration.NGINXServerOptions{}, - StartClient: integration.StartClientDERP, - RunTests: integration.TestSuite, + Name: "BasicLoopbackDERPNGINX", + NetworkingProvider: integration.NetworkingLoopback{}, + Server: integration.NGINXServerOptions{}, + ClientStarter: integration.BasicClientStarter{BlockEndpoints: true}, + RunTests: integration.TestSuite, }, } @@ -151,7 +171,6 @@ func TestIntegration(t *testing.T) { } for _, topo := range topologies { - topo := topo t.Run(topo.Name, func(t *testing.T) { // These can run in parallel because every test should be in an // isolated NetNS. @@ -166,7 +185,11 @@ func TestIntegration(t *testing.T) { } log := testutil.Logger(t) - networking := topo.SetupNetworking(t, log) + networking := topo.NetworkingProvider.SetupNetworking(t, log) + + tempDir := t.TempDir() + // useful for debugging: + // networking.Client1.Process.CapturePackets(t, "client1", tempDir) // Useful for debugging network namespaces by avoiding cleanup. // t.Cleanup(func() { @@ -181,7 +204,6 @@ func TestIntegration(t *testing.T) { } // Write the DERP maps to a file. - tempDir := t.TempDir() client1DERPMapPath := filepath.Join(tempDir, "client1-derp-map.json") client1DERPMap, err := networking.Client1.ResolveDERPMap() require.NoError(t, err, "resolve client 1 DERP map") @@ -270,7 +292,7 @@ func handleTestSubprocess(t *testing.T) { waitForServerAvailable(t, serverURL) - conn := topo.StartClient(t, logger, serverURL, &derpMap, me, peer) + conn := topo.ClientStarter.StartClient(t, logger, serverURL, &derpMap, me, peer) if me.ShouldRunTests { // Wait for connectivity. diff --git a/tailnet/test/integration/network.go b/tailnet/test/integration/network.go index b496879fd1219..871423974f3eb 100644 --- a/tailnet/test/integration/network.go +++ b/tailnet/test/integration/network.go @@ -5,9 +5,11 @@ package integration import ( "bytes" + "context" "fmt" "os" "os/exec" + "path" "testing" "github.com/stretchr/testify/require" @@ -71,11 +73,21 @@ type TestNetworkingProcess struct { NetNS *os.File } -// SetupNetworkingLoopback creates a network namespace with a loopback interface +func (p TestNetworkingProcess) CapturePackets(t *testing.T, name, dir string) { + dumpfile := path.Join(dir, name+".pcap") + _, _ = ExecBackground(t, name+".pcap", p.NetNS, "tcpdump", []string{ + "-i", "any", + "-w", dumpfile, + }) +} + +// NetworkingLoopback creates a network namespace with a loopback interface // for all tests to share. This is the simplest networking setup. The network // namespace only exists for isolation on the host and doesn't serve any routing // purpose. -func SetupNetworkingLoopback(t *testing.T, _ slog.Logger) TestNetworking { +type NetworkingLoopback struct{} + +func (NetworkingLoopback) SetupNetworking(t *testing.T, _ slog.Logger) TestNetworking { // Create a single network namespace for all tests so we can have an // isolated loopback interface. netNSFile := createNetNS(t, uniqNetName(t)) @@ -102,91 +114,25 @@ func SetupNetworkingLoopback(t *testing.T, _ slog.Logger) TestNetworking { } } -func easyNAT(t *testing.T) fakeInternet { - internet := createFakeInternet(t) - - _, err := commandInNetNS(internet.BridgeNetNS, "sysctl", []string{"-w", "net.ipv4.ip_forward=1"}).Output() - require.NoError(t, wrapExitErr(err), "enable IP forwarding in bridge NetNS") - - // Set up iptables masquerade rules to allow each router to NAT packets. - leaves := []struct { - fakeRouterLeaf - clientPort int - natPort int - }{ - {internet.Client1, client1Port, client1RouterPort}, - {internet.Client2, client2Port, client2RouterPort}, - } - for _, leaf := range leaves { - _, err := commandInNetNS(leaf.RouterNetNS, "sysctl", []string{"-w", "net.ipv4.ip_forward=1"}).Output() - require.NoError(t, wrapExitErr(err), "enable IP forwarding in router NetNS") - - // All non-UDP traffic should use regular masquerade e.g. for HTTP. - _, err = commandInNetNS(leaf.RouterNetNS, "iptables", []string{ - "-t", "nat", - "-A", "POSTROUTING", - // Every interface except loopback. - "!", "-o", "lo", - // Every protocol except UDP. - "!", "-p", "udp", - "-j", "MASQUERADE", - }).Output() - require.NoError(t, wrapExitErr(err), "add iptables non-UDP masquerade rule") - - // Outgoing traffic should get NATed to the router's IP. - _, err = commandInNetNS(leaf.RouterNetNS, "iptables", []string{ - "-t", "nat", - "-A", "POSTROUTING", - "-p", "udp", - "--sport", fmt.Sprint(leaf.clientPort), - "-j", "SNAT", - "--to-source", fmt.Sprintf("%s:%d", leaf.RouterIP, leaf.natPort), - }).Output() - require.NoError(t, wrapExitErr(err), "add iptables SNAT rule") - - // Incoming traffic should be forwarded to the client's IP. - _, err = commandInNetNS(leaf.RouterNetNS, "iptables", []string{ - "-t", "nat", - "-A", "PREROUTING", - "-p", "udp", - "--dport", fmt.Sprint(leaf.natPort), - "-j", "DNAT", - "--to-destination", fmt.Sprintf("%s:%d", leaf.ClientIP, leaf.clientPort), - }).Output() - require.NoError(t, wrapExitErr(err), "add iptables DNAT rule") - } - - return internet -} - -// SetupNetworkingEasyNAT creates a fake internet and sets up "easy NAT" -// forwarding rules. +// NetworkingNAT creates a fake internet and sets up "NAT" +// forwarding rules, either easy or hard. // See createFakeInternet. // NAT is achieved through a single iptables masquerade rule. -func SetupNetworkingEasyNAT(t *testing.T, _ slog.Logger) TestNetworking { - return easyNAT(t).Net +type NetworkingNAT struct { + StunCount int + Client1Hard bool + Client2Hard bool } -// SetupNetworkingEasyNATWithSTUN does the same as SetupNetworkingEasyNAT, but -// also creates a namespace and bridge address for a STUN server. -func SetupNetworkingEasyNATWithSTUN(t *testing.T, _ slog.Logger) TestNetworking { - internet := easyNAT(t) - internet.Net.STUNs = []TestNetworkingSTUN{ - prepareSTUNServer(t, &internet, 0), - } - - return internet.Net -} - -// hardNAT creates a fake internet with multiple STUN servers and sets up "hard -// NAT" forwarding rules. If bothHard is false, only the first client will have -// hard NAT rules, and the second client will have easy NAT rules. -// -//nolint:revive -func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { +// SetupNetworking creates a fake internet with multiple STUN servers and sets up +// NAT forwarding rules. Client NATs are controlled by the switches ClientXHard, which if true, sets up hard +// nat. +func (n NetworkingNAT) SetupNetworking(t *testing.T, l slog.Logger) TestNetworking { + logger := l.Named("setup-networking").Leveled(slog.LevelDebug) internet := createFakeInternet(t) - internet.Net.STUNs = make([]TestNetworkingSTUN, stunCount) - for i := 0; i < stunCount; i++ { + logger.Debug(context.Background(), "preparing STUN", slog.F("stun_count", n.StunCount)) + internet.Net.STUNs = make([]TestNetworkingSTUN, n.StunCount) + for i := 0; i < n.StunCount; i++ { internet.Net.STUNs[i] = prepareSTUNServer(t, &internet, i) } @@ -202,8 +148,14 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { natStartPortSTUN int }{ { - fakeRouterLeaf: internet.Client1, - peerIP: internet.Client2.RouterIP, + fakeRouterLeaf: internet.Client1, + // If peerIP is empty, we do easy NAT (even for STUN) + peerIP: func() string { + if n.Client1Hard { + return internet.Client2.RouterIP + } + return "" + }(), clientPort: client1Port, natPortPeer: client1RouterPort, natStartPortSTUN: client1RouterPortSTUN, @@ -212,7 +164,7 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { fakeRouterLeaf: internet.Client2, // If peerIP is empty, we do easy NAT (even for STUN) peerIP: func() string { - if bothHard { + if n.Client2Hard { return internet.Client1.RouterIP } return "" @@ -235,6 +187,9 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { // NAT from this client to each STUN server. Only do this if we're doing // hard NAT, as the rule above will also touch STUN traffic in easy NAT. if leaf.peerIP != "" { + logger.Debug(context.Background(), "creating NAT to STUN", + slog.F("client_ip", leaf.ClientIP), slog.F("peer_ip", leaf.peerIP), + ) for i, stun := range internet.Net.STUNs { natPort := leaf.natStartPortSTUN + i iptablesNAT(t, leaf.RouterNetNS, leaf.ClientIP, leaf.clientPort, leaf.RouterIP, natPort, stun.IP) @@ -242,11 +197,7 @@ func hardNAT(t *testing.T, stunCount int, bothHard bool) fakeInternet { } } - return internet -} - -func SetupNetworkingHardNATEasyNATDirect(t *testing.T, _ slog.Logger) TestNetworking { - return hardNAT(t, 2, false).Net + return internet.Net } type vethPair struct { @@ -438,6 +389,149 @@ func createFakeInternet(t *testing.T) fakeInternet { return router } +type TriangleNetwork struct { + InterClientMTU int +} + +type fakeTriangleNetwork struct { + NamePrefix string + ServerNetNS *os.File + Client1NetNS *os.File + Client2NetNS *os.File + ServerClient1VethPair vethPair + ServerClient2VethPair vethPair + Client1Client2VethPair vethPair +} + +// SetupNetworking creates multiple namespaces with veth pairs between them +// with the following topology: +// . +// . ┌────────────────────────────────────────────┐ +// . │ Server │ +// . └─────┬───────────────────────────────────┬──┘ +// . │fdac:38fa:ffff:2::3 │fdac:38fa:ffff:3::3 +// . veth│ veth│ +// . │fdac:38fa:ffff:2::1 │fdac:38fa:ffff:3::2 +// . ┌───────┴──────┐ ┌─────┴───────┐ +// . │ │ fdac:38fa:ffff:1::2│ │ +// . │ Client 1 ├──────────────────────┤ Client 2 │ +// . │ │fdac:38fa:ffff:1::1 │ │ +// . └──────────────┘ └─────────────┘ +func (n TriangleNetwork) SetupNetworking(t *testing.T, l slog.Logger) TestNetworking { + logger := l.Named("setup-networking").Leveled(slog.LevelDebug) + t.Helper() + var ( + namePrefix = uniqNetName(t) + "_" + network = fakeTriangleNetwork{ + NamePrefix: namePrefix, + } + // Unique Local Address prefix + ula = "fdac:38fa:ffff:" + ) + + // Create three network namespaces for server, client1, and client2 + network.ServerNetNS = createNetNS(t, namePrefix+"server") + network.Client1NetNS = createNetNS(t, namePrefix+"client1") + network.Client2NetNS = createNetNS(t, namePrefix+"client2") + + // Create veth pair between server and client1 + network.ServerClient1VethPair = vethPair{ + Outer: namePrefix + "s-1", + Inner: namePrefix + "1-s", + } + err := createVethPair(network.ServerClient1VethPair.Outer, network.ServerClient1VethPair.Inner) + require.NoErrorf(t, err, "create veth pair %q <-> %q", + network.ServerClient1VethPair.Outer, network.ServerClient1VethPair.Inner) + + // Move server-client1 veth ends to their respective namespaces + err = setVethNetNS(network.ServerClient1VethPair.Outer, int(network.ServerNetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to server NetNS", network.ServerClient1VethPair.Outer) + err = setVethNetNS(network.ServerClient1VethPair.Inner, int(network.Client1NetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client1 NetNS", network.ServerClient1VethPair.Inner) + + // Create veth pair between server and client2 + network.ServerClient2VethPair = vethPair{ + Outer: namePrefix + "s-2", + Inner: namePrefix + "2-s", + } + err = createVethPair(network.ServerClient2VethPair.Outer, network.ServerClient2VethPair.Inner) + require.NoErrorf(t, err, "create veth pair %q <-> %q", + network.ServerClient2VethPair.Outer, network.ServerClient2VethPair.Inner) + + // Move server-client2 veth ends to their respective namespaces + err = setVethNetNS(network.ServerClient2VethPair.Outer, int(network.ServerNetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to server NetNS", network.ServerClient2VethPair.Outer) + err = setVethNetNS(network.ServerClient2VethPair.Inner, int(network.Client2NetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client2 NetNS", network.ServerClient2VethPair.Inner) + + // Create veth pair between client1 and client2 + network.Client1Client2VethPair = vethPair{ + Outer: namePrefix + "1-2", + Inner: namePrefix + "2-1", + } + logger.Debug(context.Background(), "creating inter-client link", slog.F("mtu", n.InterClientMTU)) + err = createVethPair(network.Client1Client2VethPair.Outer, network.Client1Client2VethPair.Inner, + withMTU(n.InterClientMTU)) + require.NoErrorf(t, err, "create veth pair %q <-> %q", + network.Client1Client2VethPair.Outer, network.Client1Client2VethPair.Inner) + + // Move client1-client2 veth ends to their respective namespaces + err = setVethNetNS(network.Client1Client2VethPair.Outer, int(network.Client1NetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client1 NetNS", network.Client1Client2VethPair.Outer) + err = setVethNetNS(network.Client1Client2VethPair.Inner, int(network.Client2NetNS.Fd())) + require.NoErrorf(t, err, "set veth %q to client2 NetNS", network.Client1Client2VethPair.Inner) + + // Set IP addresses according to the diagram: + err = setInterfaceIP6(network.ServerNetNS, network.ServerClient1VethPair.Outer, ula+"2::3") + require.NoErrorf(t, err, "set IP on server-client1 interface") + err = setInterfaceIP6(network.ServerNetNS, network.ServerClient2VethPair.Outer, ula+"3::3") + require.NoErrorf(t, err, "set IP on server-client2 interface") + + err = setInterfaceIP6(network.Client1NetNS, network.ServerClient1VethPair.Inner, ula+"2::1") + require.NoErrorf(t, err, "set IP on client1-server interface") + err = setInterfaceIP6(network.Client1NetNS, network.Client1Client2VethPair.Outer, ula+"1::1") + require.NoErrorf(t, err, "set IP on client1-client2 interface") + + err = setInterfaceIP6(network.Client2NetNS, network.ServerClient2VethPair.Inner, ula+"3::2") + require.NoErrorf(t, err, "set IP on client2-server interface") + err = setInterfaceIP6(network.Client2NetNS, network.Client1Client2VethPair.Inner, ula+"1::2") + require.NoErrorf(t, err, "set IP on client2-client1 interface") + + // Bring up all interfaces + interfaces := []struct { + netNS *os.File + ifaceName string + }{ + {network.ServerNetNS, network.ServerClient1VethPair.Outer}, + {network.ServerNetNS, network.ServerClient2VethPair.Outer}, + {network.Client1NetNS, network.ServerClient1VethPair.Inner}, + {network.Client1NetNS, network.Client1Client2VethPair.Outer}, + {network.Client2NetNS, network.ServerClient2VethPair.Inner}, + {network.Client2NetNS, network.Client1Client2VethPair.Inner}, + } + for _, iface := range interfaces { + err = setInterfaceUp(iface.netNS, iface.ifaceName) + require.NoErrorf(t, err, "bring up interface %q", iface.ifaceName) + // Note: routes are not needed as we are fully connected, so nothing needs to forward IP to a further + // destination. + } + + return TestNetworking{ + Server: TestNetworkingServer{ + Process: TestNetworkingProcess{NetNS: network.ServerNetNS}, + ListenAddr: "[::]:8080", // Server listens on all IPs + }, + Client1: TestNetworkingClient{ + Process: TestNetworkingProcess{NetNS: network.Client1NetNS}, + ServerAccessURL: "http://[" + ula + "2::3]:8080", // Client1 accesses server directly + }, + Client2: TestNetworkingClient{ + Process: TestNetworkingProcess{NetNS: network.Client2NetNS}, + ServerAccessURL: "http://[" + ula + "3::3]:8080", // Client2 accesses server directly + }, + } +} + func uniqNetName(t *testing.T) string { t.Helper() netNSName := "cdr_" @@ -522,8 +616,8 @@ func createNetNS(t *testing.T, name string) *os.File { }) // Open /run/netns/$name to get a file descriptor to the network namespace. - path := fmt.Sprintf("/run/netns/%s", name) - file, err := os.OpenFile(path, os.O_RDONLY, 0) + netnsPath := fmt.Sprintf("/run/netns/%s", name) + file, err := os.OpenFile(netnsPath, os.O_RDONLY, 0) require.NoError(t, err, "open network namespace file") t.Cleanup(func() { _ = file.Close() @@ -568,10 +662,22 @@ func setInterfaceBridge(netNS *os.File, ifaceName, bridgeName string) error { return nil } +type linkOption func(attrs netlink.LinkAttrs) netlink.LinkAttrs + +func withMTU(mtu int) linkOption { + return func(attrs netlink.LinkAttrs) netlink.LinkAttrs { + attrs.MTU = mtu + return attrs + } +} + // createVethPair creates a veth pair with the given names. -func createVethPair(parentVethName, peerVethName string) error { +func createVethPair(parentVethName, peerVethName string, options ...linkOption) error { linkAttrs := netlink.NewLinkAttrs() linkAttrs.Name = parentVethName + for _, option := range options { + linkAttrs = option(linkAttrs) + } veth := &netlink.Veth{ LinkAttrs: linkAttrs, PeerName: peerVethName, @@ -611,6 +717,17 @@ func setInterfaceIP(netNS *os.File, ifaceName, ip string) error { return nil } +// setInterfaceIP6 sets the IPv6 address on the given interface. It automatically +// adds a /64 subnet mask. +func setInterfaceIP6(netNS *os.File, ifaceName, ip string) error { + _, err := commandInNetNS(netNS, "ip", []string{"addr", "add", ip + "/64", "dev", ifaceName}).Output() + if err != nil { + return xerrors.Errorf("set IP %q on interface %q in netns: %w", ip, ifaceName, wrapExitErr(err)) + } + + return nil +} + // setInterfaceUp brings the given interface up. func setInterfaceUp(netNS *os.File, ifaceName string) error { _, err := commandInNetNS(netNS, "ip", []string{"link", "set", ifaceName, "up"}).Output() @@ -703,7 +820,9 @@ func iptablesMasqueradeNonUDP(t *testing.T, netNS *os.File) { // iptablesNAT sets up iptables rules for NAT forwarding. If destIP is // specified, the forwarding rule will only apply to traffic to/from that IP // (mapvarydest). -func iptablesNAT(t *testing.T, netNS *os.File, clientIP string, clientPort int, routerIP string, routerPort int, destIP string) { +func iptablesNAT( + t *testing.T, netNS *os.File, clientIP string, clientPort int, routerIP string, routerPort int, destIP string, +) { t.Helper() snatArgs := []string{ diff --git a/tailnet/test/integration/suite.go b/tailnet/test/integration/suite.go index eefba0eaf2ce0..9e04de03de53a 100644 --- a/tailnet/test/integration/suite.go +++ b/tailnet/test/integration/suite.go @@ -5,6 +5,7 @@ package integration import ( "net/http" + "net/netip" "net/url" "testing" "time" @@ -80,3 +81,40 @@ func TestSuite(t *testing.T, _ slog.Logger, serverURL *url.URL, conn *tailnet.Co require.NoError(t, err, "ping peer after restart") }) } + +func TestBigUDP(t *testing.T, logger slog.Logger, _ *url.URL, conn *tailnet.Conn, _, peer Client) { + t.Run("UDPEcho", func(t *testing.T) { + ctx := testutil.Context(t, testutil.WaitShort) + + peerIP := tailnet.TailscaleServicePrefix.AddrFromUUID(peer.ID) + udpConn, err := conn.DialContextUDP(ctx, netip.AddrPortFrom(peerIP, uint16(EchoPort))) + require.NoError(t, err) + defer udpConn.Close() + + // 1280 max tunnel packet size + // -40 + // -8 UDP header + // ---------------------------- + // 1232 data size + logger.Info(ctx, "sending UDP test packet") + packet := make([]byte, 1232) + for i := range packet { + packet[i] = byte(i % 256) + } + err = udpConn.SetWriteDeadline(time.Now().Add(5 * time.Second)) + require.NoError(t, err) + n, err := udpConn.Write(packet) + require.NoError(t, err) + require.Equal(t, len(packet), n) + + // read the echo + logger.Info(ctx, "attempting to read UDP reply") + buf := make([]byte, 1280) + err = udpConn.SetReadDeadline(time.Now().Add(5 * time.Second)) + require.NoError(t, err) + n, err = udpConn.Read(buf) + require.NoError(t, err) + require.Equal(t, len(packet), n) + require.Equal(t, packet, buf[:n]) + }) +} diff --git a/vpn/client.go b/vpn/client.go index da066bbcd62b3..e3f3e767fc477 100644 --- a/vpn/client.go +++ b/vpn/client.go @@ -5,11 +5,14 @@ import ( "net/http" "net/netip" "net/url" + "time" "golang.org/x/xerrors" + "tailscale.com/ipn/ipnstate" "tailscale.com/net/dns" "tailscale.com/net/netmon" + "tailscale.com/tailcfg" "tailscale.com/wgengine/router" "github.com/google/uuid" @@ -27,6 +30,9 @@ import ( type Conn interface { CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) GetPeerDiagnostics(peerID uuid.UUID) tailnet.PeerDiagnostics + Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) + Node() *tailnet.Node + DERPMap() *tailcfg.DERPMap Close() error } @@ -38,6 +44,10 @@ type vpnConn struct { updatesCtrl *tailnet.TunnelAllWorkspaceUpdatesController } +func (c *vpnConn) Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) { + return c.Conn.Ping(ctx, tailnet.TailscaleServicePrefix.AddrFromUUID(agentID)) +} + func (c *vpnConn) CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) { return c.updatesCtrl.CurrentState() } diff --git a/vpn/dylib/lib.go b/vpn/dylib/lib.go index de6f91042c7ef..3677aee369598 100644 --- a/vpn/dylib/lib.go +++ b/vpn/dylib/lib.go @@ -46,7 +46,10 @@ func OpenTunnel(cReadFD, cWriteFD int32) int32 { return ErrOpenPipe } - _, err = vpn.NewTunnel(ctx, slog.Make(), conn, vpn.NewClient(), + // We log everything, as filtering is done by whatever renders the OS + // logs. + _, err = vpn.NewTunnel(ctx, slog.Make().Leveled(slog.LevelDebug), conn, + vpn.NewClient(), vpn.UseOSNetworkingStack(), vpn.UseAsLogger(), ) diff --git a/vpn/speaker_internal_test.go b/vpn/speaker_internal_test.go index 2f3d131093382..433868851a5bc 100644 --- a/vpn/speaker_internal_test.go +++ b/vpn/speaker_internal_test.go @@ -23,6 +23,8 @@ func TestMain(m *testing.M) { goleak.VerifyTestMain(m, testutil.GoleakOptions...) } +const expectedHandshake = "codervpn tunnel 1.2\n" + // TestSpeaker_RawPeer tests the speaker with a peer that we simulate by directly making reads and // writes to the other end of the pipe. There should be at least one test that does this, rather // than use 2 speakers so that we don't have a bug where we don't adhere to the stated protocol, but @@ -48,8 +50,6 @@ func TestSpeaker_RawPeer(t *testing.T) { errCh <- err }() - expectedHandshake := "codervpn tunnel 1.1\n" - b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) @@ -157,8 +157,6 @@ func TestSpeaker_OversizeHandshake(t *testing.T) { errCh <- err }() - expectedHandshake := "codervpn tunnel 1.1\n" - b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) @@ -210,7 +208,6 @@ func TestSpeaker_HandshakeInvalid(t *testing.T) { _, err = mp.Write([]byte(tc.handshake)) require.NoError(t, err) - expectedHandshake := "codervpn tunnel 1.1\n" b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) @@ -248,8 +245,6 @@ func TestSpeaker_CorruptMessage(t *testing.T) { errCh <- err }() - expectedHandshake := "codervpn tunnel 1.1\n" - b := make([]byte, 256) n, err := mp.Read(b) require.NoError(t, err) diff --git a/vpn/tunnel.go b/vpn/tunnel.go index 6c71aecaa0965..e4624ac1822b0 100644 --- a/vpn/tunnel.go +++ b/vpn/tunnel.go @@ -19,6 +19,7 @@ import ( "github.com/google/uuid" "github.com/tailscale/wireguard-go/tun" "golang.org/x/xerrors" + "google.golang.org/protobuf/types/known/durationpb" "google.golang.org/protobuf/types/known/timestamppb" "tailscale.com/net/dns" "tailscale.com/net/netmon" @@ -32,9 +33,9 @@ import ( "github.com/coder/coder/v2/tailnet" ) -// netStatusInterval is the interval at which the tunnel sends network status updates to the manager. -// This is currently only used to keep `last_handshake` up to date. -const netStatusInterval = 10 * time.Second +// netStatusInterval is the interval at which the tunnel records latencies, +// and sends network status updates to the manager. +const netStatusInterval = 5 * time.Second type Tunnel struct { speaker[*TunnelMessage, *ManagerMessage, ManagerMessage] @@ -45,9 +46,6 @@ type Tunnel struct { logger slog.Logger - logMu sync.Mutex - logs []*TunnelMessage - client Client // clientLogger is a separate logger than `logger` when the `UseAsLogger` @@ -86,8 +84,9 @@ func NewTunnel( ctx: uCtx, cancel: uCancel, netLoopDone: make(chan struct{}), + logger: logger, uSendCh: s.sendCh, - agents: map[uuid.UUID]tailnet.Agent{}, + agents: map[uuid.UUID]agentWithPing{}, workspaces: map[uuid.UUID]tailnet.Workspace{}, clock: quartz.NewReal(), }, @@ -298,29 +297,22 @@ func (t *Tunnel) stop(*StopRequest) error { var _ slog.Sink = &Tunnel{} func (t *Tunnel) LogEntry(_ context.Context, e slog.SinkEntry) { - t.logMu.Lock() - defer t.logMu.Unlock() - t.logs = append(t.logs, &TunnelMessage{ + msg := &TunnelMessage{ Msg: &TunnelMessage_Log{ Log: sinkEntryToPb(e), }, - }) -} - -func (t *Tunnel) Sync() { - t.logMu.Lock() - logs := t.logs - t.logs = nil - t.logMu.Unlock() - for _, msg := range logs { - select { - case <-t.ctx.Done(): - return - case t.sendCh <- msg: - } + } + select { + case <-t.updater.ctx.Done(): + return + case <-t.ctx.Done(): + return + case t.sendCh <- msg: } } +func (*Tunnel) Sync() {} + func sinkEntryToPb(e slog.SinkEntry) *Log { l := &Log{ // #nosec G115 - Safe conversion for log levels which are small positive integers @@ -344,10 +336,12 @@ type updater struct { cancel context.CancelFunc netLoopDone chan struct{} + logger slog.Logger + mu sync.Mutex uSendCh chan<- *TunnelMessage // agents contains the agents that are currently connected to the tunnel. - agents map[uuid.UUID]tailnet.Agent + agents map[uuid.UUID]agentWithPing // workspaces contains the workspaces to which agents are currently connected via the tunnel. workspaces map[uuid.UUID]tailnet.Workspace conn Conn @@ -355,6 +349,26 @@ type updater struct { clock quartz.Clock } +type agentWithPing struct { + tailnet.Agent + // non-nil if a successful ping has been made + lastPing *lastPing +} + +func (a *agentWithPing) Clone() *agentWithPing { + return &agentWithPing{ + Agent: a.Agent.Clone(), + lastPing: a.lastPing, + } +} + +type lastPing struct { + pingDur time.Duration + didP2p bool + preferredDerp string + preferredDerpLatency *time.Duration +} + // Update pushes a workspace update to the manager func (u *updater) Update(update tailnet.WorkspaceUpdate) error { u.mu.Lock() @@ -412,10 +426,21 @@ func (u *updater) createPeerUpdateLocked(update tailnet.WorkspaceUpdate) *PeerUp DeletedAgents: make([]*Agent, len(update.DeletedAgents)), } + var upsertedAgentsWithPing []*agentWithPing + // save the workspace update to the tunnel's state, such that it can // be used to populate automated peer updates. for _, agent := range update.UpsertedAgents { - u.agents[agent.ID] = agent.Clone() + var lastPing *lastPing + if existing, ok := u.agents[agent.ID]; ok { + lastPing = existing.lastPing + } + upsertedAgent := agentWithPing{ + Agent: agent.Clone(), + lastPing: lastPing, + } + u.agents[agent.ID] = upsertedAgent + upsertedAgentsWithPing = append(upsertedAgentsWithPing, &upsertedAgent) } for _, agent := range update.DeletedAgents { delete(u.agents, agent.ID) @@ -435,7 +460,7 @@ func (u *updater) createPeerUpdateLocked(update tailnet.WorkspaceUpdate) *PeerUp } } - upsertedAgents := u.convertAgentsLocked(update.UpsertedAgents) + upsertedAgents := u.convertAgentsLocked(upsertedAgentsWithPing) out.UpsertedAgents = upsertedAgents for i, ws := range update.DeletedWorkspaces { out.DeletedWorkspaces[i] = &Workspace{ @@ -466,7 +491,7 @@ func (u *updater) createPeerUpdateLocked(update tailnet.WorkspaceUpdate) *PeerUp // convertAgentsLocked takes a list of `tailnet.Agent` and converts them to proto agents. // If there is an active connection, the last handshake time is populated. -func (u *updater) convertAgentsLocked(agents []*tailnet.Agent) []*Agent { +func (u *updater) convertAgentsLocked(agents []*agentWithPing) []*Agent { out := make([]*Agent, 0, len(agents)) for _, agent := range agents { @@ -477,12 +502,26 @@ func (u *updater) convertAgentsLocked(agents []*tailnet.Agent) []*Agent { sort.Slice(fqdn, func(i, j int) bool { return len(fqdn[i]) < len(fqdn[j]) }) + var lastPing *LastPing + if agent.lastPing != nil { + var preferredDerpLatency *durationpb.Duration + if agent.lastPing.preferredDerpLatency != nil { + preferredDerpLatency = durationpb.New(*agent.lastPing.preferredDerpLatency) + } + lastPing = &LastPing{ + Latency: durationpb.New(agent.lastPing.pingDur), + DidP2P: agent.lastPing.didP2p, + PreferredDerp: agent.lastPing.preferredDerp, + PreferredDerpLatency: preferredDerpLatency, + } + } protoAgent := &Agent{ Id: tailnet.UUIDToByteSlice(agent.ID), Name: agent.Name, WorkspaceId: tailnet.UUIDToByteSlice(agent.WorkspaceID), Fqdn: fqdn, IpAddrs: hostsToIPStrings(agent.Hosts), + LastPing: lastPing, } if u.conn != nil { diags := u.conn.GetPeerDiagnostics(agent.ID) @@ -514,8 +553,8 @@ func (u *updater) stop() error { return nil } err := u.conn.Close() - u.conn = nil u.cancel() + u.conn = nil return err } @@ -525,7 +564,7 @@ func (u *updater) sendAgentUpdate() { u.mu.Lock() defer u.mu.Unlock() - agents := make([]*tailnet.Agent, 0, len(u.agents)) + agents := make([]*agentWithPing, 0, len(u.agents)) for _, agent := range u.agents { agents = append(agents, &agent) } @@ -534,6 +573,8 @@ func (u *updater) sendAgentUpdate() { return } + u.logger.Debug(u.ctx, "sending agent update") + msg := &TunnelMessage{ Msg: &TunnelMessage_PeerUpdate{ PeerUpdate: &PeerUpdate{ @@ -558,17 +599,85 @@ func (u *updater) netStatusLoop() { case <-u.ctx.Done(): return case <-ticker.C: + u.recordLatencies() u.sendAgentUpdate() } } } +func (u *updater) recordLatencies() { + var agentsIDsToPing []uuid.UUID + u.mu.Lock() + for _, agent := range u.agents { + agentsIDsToPing = append(agentsIDsToPing, agent.ID) + } + conn := u.conn + u.mu.Unlock() + + if conn == nil { + u.logger.Debug(u.ctx, "skipping pings as tunnel is not connected") + return + } + + go func() { + // We need a waitgroup to cancel the context after all pings are done. + var wg sync.WaitGroup + pingCtx, cancelFunc := context.WithTimeout(u.ctx, netStatusInterval) + defer cancelFunc() + for _, agentID := range agentsIDsToPing { + wg.Add(1) + go func() { + defer wg.Done() + + pingDur, didP2p, pingResult, err := conn.Ping(pingCtx, agentID) + if err != nil { + u.logger.Warn(u.ctx, "failed to ping agent", slog.F("agent_id", agentID), slog.Error(err)) + return + } + + // We fetch the Node and DERPMap after each ping, as it may have + // changed. + node := conn.Node() + derpMap := conn.DERPMap() + if node == nil || derpMap == nil { + u.logger.Warn(u.ctx, "failed to get DERP map or node after ping") + return + } + derpLatencies := tailnet.ExtractDERPLatency(node, derpMap) + preferredDerp := tailnet.ExtractPreferredDERPName(pingResult, node, derpMap) + var preferredDerpLatency *time.Duration + if derpLatency, ok := derpLatencies[preferredDerp]; ok { + preferredDerpLatency = &derpLatency + } else { + u.logger.Debug(u.ctx, "preferred DERP not found in DERP latency map", slog.F("preferred_derp", preferredDerp)) + } + + // Write back results + u.mu.Lock() + defer u.mu.Unlock() + if agent, ok := u.agents[agentID]; ok { + agent.lastPing = &lastPing{ + pingDur: pingDur, + didP2p: didP2p, + preferredDerp: preferredDerp, + preferredDerpLatency: preferredDerpLatency, + } + u.agents[agentID] = agent + } else { + u.logger.Debug(u.ctx, "ignoring ping result for unknown agent", slog.F("agent_id", agentID)) + } + }() + } + wg.Wait() + }() +} + // processSnapshotUpdate handles the logic when a full state update is received. // When the tunnel is live, we only receive diffs, but the first packet on any given // reconnect to the tailnet API is a full state. // Without this logic we weren't processing deletes for any workspaces or agents deleted // while the client was disconnected while the computer was asleep. -func processSnapshotUpdate(update *tailnet.WorkspaceUpdate, agents map[uuid.UUID]tailnet.Agent, workspaces map[uuid.UUID]tailnet.Workspace) { +func processSnapshotUpdate(update *tailnet.WorkspaceUpdate, agents map[uuid.UUID]agentWithPing, workspaces map[uuid.UUID]tailnet.Workspace) { // ignoredWorkspaces is initially populated with the workspaces that are // in the current update. Later on we populate it with the deleted workspaces too // so that we don't send duplicate updates. Same applies to ignoredAgents. diff --git a/vpn/tunnel_internal_test.go b/vpn/tunnel_internal_test.go index 15eb9cf569f5e..5c4e6ec03d47f 100644 --- a/vpn/tunnel_internal_test.go +++ b/vpn/tunnel_internal_test.go @@ -15,10 +15,13 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" "google.golang.org/protobuf/types/known/timestamppb" + "tailscale.com/ipn/ipnstate" + "tailscale.com/tailcfg" "tailscale.com/util/dnsname" "github.com/coder/quartz" + maputil "github.com/coder/coder/v2/coderd/util/maps" "github.com/coder/coder/v2/tailnet" "github.com/coder/coder/v2/tailnet/proto" "github.com/coder/coder/v2/testutil" @@ -57,15 +60,59 @@ func newFakeConn(state tailnet.WorkspaceUpdate, hsTime time.Time) *fakeConn { } } +func (f *fakeConn) withManualPings() *fakeConn { + f.returnPing = make(chan struct{}) + return f +} + type fakeConn struct { - state tailnet.WorkspaceUpdate - hsTime time.Time - closed chan struct{} - doClose sync.Once + state tailnet.WorkspaceUpdate + returnPing chan struct{} + hsTime time.Time + closed chan struct{} + doClose sync.Once +} + +func (*fakeConn) DERPMap() *tailcfg.DERPMap { + return &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 999: { + RegionID: 999, + RegionCode: "zzz", + RegionName: "Coder Region", + }, + }, + } +} + +func (*fakeConn) Node() *tailnet.Node { + return &tailnet.Node{ + PreferredDERP: 999, + DERPLatency: map[string]float64{ + "999": 0.1, + }, + } } var _ Conn = (*fakeConn)(nil) +func (f *fakeConn) Ping(ctx context.Context, agentID uuid.UUID) (time.Duration, bool, *ipnstate.PingResult, error) { + if f.returnPing == nil { + return time.Millisecond * 100, true, &ipnstate.PingResult{ + DERPRegionID: 999, + }, nil + } + + select { + case <-ctx.Done(): + return 0, false, nil, ctx.Err() + case <-f.returnPing: + return time.Millisecond * 100, true, &ipnstate.PingResult{ + DERPRegionID: 999, + }, nil + } +} + func (f *fakeConn) CurrentWorkspaceState() (tailnet.WorkspaceUpdate, error) { return f.state, nil } @@ -292,7 +339,7 @@ func TestUpdater_createPeerUpdate(t *testing.T) { updater := updater{ ctx: ctx, netLoopDone: make(chan struct{}), - agents: map[uuid.UUID]tailnet.Agent{}, + agents: map[uuid.UUID]agentWithPing{}, workspaces: map[uuid.UUID]tailnet.Workspace{}, conn: newFakeConn(tailnet.WorkspaceUpdate{}, hsTime), } @@ -430,6 +477,22 @@ func TestTunnel_sendAgentUpdate(t *testing.T) { require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[0].LastHandshake.AsTime()) } + // Latency is gathered in the background, so it'll eventually be sent + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + if len(req.msg.GetPeerUpdate().UpsertedAgents) == 0 { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing == nil { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds() != 100 { + return false + } + return req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.PreferredDerp == "Coder Region" + }, testutil.IntervalFast) + // Upsert a new agent err = tun.Update(tailnet.WorkspaceUpdate{ UpsertedWorkspaces: []*tailnet.Workspace{}, @@ -459,6 +522,10 @@ func TestTunnel_sendAgentUpdate(t *testing.T) { require.Equal(t, aID1[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[0].LastHandshake.AsTime()) + // The latency of the first agent is still set + require.NotNil(t, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing) + require.EqualValues(t, 100, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds()) + require.Equal(t, aID2[:], req.msg.GetPeerUpdate().UpsertedAgents[1].Id) require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[1].LastHandshake.AsTime()) @@ -486,6 +553,22 @@ func TestTunnel_sendAgentUpdate(t *testing.T) { require.Len(t, req.msg.GetPeerUpdate().UpsertedAgents, 1) require.Equal(t, aID2[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) require.Equal(t, hsTime, req.msg.GetPeerUpdate().UpsertedAgents[0].LastHandshake.AsTime()) + + // Eventually the second agent's latency is set + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + if len(req.msg.GetPeerUpdate().UpsertedAgents) == 0 { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing == nil { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds() != 100 { + return false + } + return req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.PreferredDerp == "Coder Region" + }, testutil.IntervalFast) } func TestTunnel_sendAgentUpdateReconnect(t *testing.T) { @@ -693,6 +776,178 @@ func TestTunnel_sendAgentUpdateWorkspaceReconnect(t *testing.T) { require.Equal(t, wID1[:], peerUpdate.DeletedWorkspaces[0].Id) } +func TestTunnel_slowPing(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + mClock := quartz.NewMock(t) + + wID1 := uuid.UUID{1} + aID1 := uuid.UUID{2} + hsTime := time.Now().Add(-time.Minute).UTC() + + client := newFakeClient(ctx, t) + conn := newFakeConn(tailnet.WorkspaceUpdate{}, hsTime).withManualPings() + + tun, mgr := setupTunnel(t, ctx, client, mClock) + errCh := make(chan error, 1) + var resp *TunnelMessage + go func() { + r, err := mgr.unaryRPC(ctx, &ManagerMessage{ + Msg: &ManagerMessage_Start{ + Start: &StartRequest{ + TunnelFileDescriptor: 2, + CoderUrl: "https://coder.example.com", + ApiToken: "fakeToken", + }, + }, + }) + resp = r + errCh <- err + }() + testutil.RequireSend(ctx, t, client.ch, conn) + err := testutil.TryReceive(ctx, t, errCh) + require.NoError(t, err) + _, ok := resp.Msg.(*TunnelMessage_Start) + require.True(t, ok) + + // Inform the tunnel of the initial state + err = tun.Update(tailnet.WorkspaceUpdate{ + UpsertedWorkspaces: []*tailnet.Workspace{ + { + ID: wID1, Name: "w1", Status: proto.Workspace_STARTING, + }, + }, + UpsertedAgents: []*tailnet.Agent{ + { + ID: aID1, + Name: "agent1", + WorkspaceID: wID1, + Hosts: map[dnsname.FQDN][]netip.Addr{ + "agent1.coder.": {netip.MustParseAddr("fd60:627a:a42b:0101::")}, + }, + }, + }, + }) + require.NoError(t, err) + req := testutil.TryReceive(ctx, t, mgr.requests) + require.Nil(t, req.msg.Rpc) + require.NotNil(t, req.msg.GetPeerUpdate()) + require.Len(t, req.msg.GetPeerUpdate().UpsertedAgents, 1) + require.Equal(t, aID1[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) + + // We can't check that it *never* pings, so the best we can do is + // check it doesn't ping even with 5 goroutines attempting to, + // and that updates are received as normal + for range 5 { + mClock.AdvanceNext() + require.Nil(t, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing) + } + + // Provided that it hasn't been 5 seconds since the last AdvanceNext call, + // there'll be a ping in-flight that will return with this message + testutil.RequireSend(ctx, t, conn.returnPing, struct{}{}) + // Which will mean we'll eventually receive a PeerUpdate with the ping + testutil.Eventually(ctx, t, func(ctx context.Context) bool { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + if len(req.msg.GetPeerUpdate().UpsertedAgents) == 0 { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing == nil { + return false + } + if req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.Latency.AsDuration().Milliseconds() != 100 { + return false + } + return req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing.PreferredDerp == "Coder Region" + }, testutil.IntervalFast) +} + +func TestTunnel_stopMidPing(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitShort) + + mClock := quartz.NewMock(t) + + wID1 := uuid.UUID{1} + aID1 := uuid.UUID{2} + hsTime := time.Now().Add(-time.Minute).UTC() + + client := newFakeClient(ctx, t) + conn := newFakeConn(tailnet.WorkspaceUpdate{}, hsTime).withManualPings() + + tun, mgr := setupTunnel(t, ctx, client, mClock) + errCh := make(chan error, 1) + var resp *TunnelMessage + go func() { + r, err := mgr.unaryRPC(ctx, &ManagerMessage{ + Msg: &ManagerMessage_Start{ + Start: &StartRequest{ + TunnelFileDescriptor: 2, + CoderUrl: "https://coder.example.com", + ApiToken: "fakeToken", + }, + }, + }) + resp = r + errCh <- err + }() + testutil.RequireSend(ctx, t, client.ch, conn) + err := testutil.TryReceive(ctx, t, errCh) + require.NoError(t, err) + _, ok := resp.Msg.(*TunnelMessage_Start) + require.True(t, ok) + + // Inform the tunnel of the initial state + err = tun.Update(tailnet.WorkspaceUpdate{ + UpsertedWorkspaces: []*tailnet.Workspace{ + { + ID: wID1, Name: "w1", Status: proto.Workspace_STARTING, + }, + }, + UpsertedAgents: []*tailnet.Agent{ + { + ID: aID1, + Name: "agent1", + WorkspaceID: wID1, + Hosts: map[dnsname.FQDN][]netip.Addr{ + "agent1.coder.": {netip.MustParseAddr("fd60:627a:a42b:0101::")}, + }, + }, + }, + }) + require.NoError(t, err) + req := testutil.TryReceive(ctx, t, mgr.requests) + require.Nil(t, req.msg.Rpc) + require.NotNil(t, req.msg.GetPeerUpdate()) + require.Len(t, req.msg.GetPeerUpdate().UpsertedAgents, 1) + require.Equal(t, aID1[:], req.msg.GetPeerUpdate().UpsertedAgents[0].Id) + + // We'll have some pings in flight when we stop + for range 5 { + mClock.AdvanceNext() + req = testutil.TryReceive(ctx, t, mgr.requests) + require.Nil(t, req.msg.GetPeerUpdate().UpsertedAgents[0].LastPing) + } + + // Stop the tunnel + go func() { + r, err := mgr.unaryRPC(ctx, &ManagerMessage{ + Msg: &ManagerMessage_Stop{}, + }) + resp = r + errCh <- err + }() + testutil.TryReceive(ctx, t, conn.closed) + err = testutil.TryReceive(ctx, t, errCh) + require.NoError(t, err) + _, ok = resp.Msg.(*TunnelMessage_Stop) + require.True(t, ok) +} + //nolint:revive // t takes precedence func setupTunnel(t *testing.T, ctx context.Context, client *fakeClient, mClock *quartz.Mock) (*Tunnel, *speaker[*ManagerMessage, *TunnelMessage, TunnelMessage]) { mp, tp := net.Pipe() @@ -902,11 +1157,13 @@ func TestProcessFreshState(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - agentsCopy := make(map[uuid.UUID]tailnet.Agent) - maps.Copy(agentsCopy, tt.initialAgents) - - workspaceCopy := make(map[uuid.UUID]tailnet.Workspace) - maps.Copy(workspaceCopy, tt.initialWorkspaces) + agentsCopy := maputil.Map(tt.initialAgents, func(a tailnet.Agent) agentWithPing { + return agentWithPing{ + Agent: a.Clone(), + lastPing: nil, + } + }) + workspaceCopy := maps.Clone(tt.initialWorkspaces) processSnapshotUpdate(tt.update, agentsCopy, workspaceCopy) diff --git a/vpn/version.go b/vpn/version.go index 91aac9175f748..2bf815e903e29 100644 --- a/vpn/version.go +++ b/vpn/version.go @@ -16,7 +16,14 @@ var CurrentSupportedVersions = RPCVersionList{ // - device_id: Coder Desktop device ID // - device_os: Coder Desktop OS information // - coder_desktop_version: Coder Desktop version - {Major: 1, Minor: 1}, + // 1.2 adds network related information to Agent: + // - last_ping: + // - latency: RTT of the most recently sent ping + // - did_p2p: Whether the last ping was sent over P2P + // - preferred_derp: The server that DERP relayed connections are + // using, if they're not using P2P. + // - preferred_derp_latency: The latency to the preferred DERP + {Major: 1, Minor: 2}, }, } diff --git a/vpn/vpn.pb.go b/vpn/vpn.pb.go index c89d3e51e6c92..bc5829d763dfd 100644 --- a/vpn/vpn.pb.go +++ b/vpn/vpn.pb.go @@ -9,6 +9,7 @@ package vpn import ( protoreflect "google.golang.org/protobuf/reflect/protoreflect" protoimpl "google.golang.org/protobuf/runtime/protoimpl" + durationpb "google.golang.org/protobuf/types/known/durationpb" timestamppb "google.golang.org/protobuf/types/known/timestamppb" reflect "reflect" sync "sync" @@ -205,7 +206,7 @@ func (x Status_Lifecycle) Number() protoreflect.EnumNumber { // Deprecated: Use Status_Lifecycle.Descriptor instead. func (Status_Lifecycle) EnumDescriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{17, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{18, 0} } // RPC allows a very simple unary request/response RPC mechanism. The requester generates a unique @@ -986,6 +987,8 @@ type Agent struct { // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or // anything longer than 5 minutes ago means there is a problem. LastHandshake *timestamppb.Timestamp `protobuf:"bytes,6,opt,name=last_handshake,json=lastHandshake,proto3" json:"last_handshake,omitempty"` + // If unset, a successful ping has not yet been made. + LastPing *LastPing `protobuf:"bytes,7,opt,name=last_ping,json=lastPing,proto3,oneof" json:"last_ping,omitempty"` } func (x *Agent) Reset() { @@ -1062,6 +1065,90 @@ func (x *Agent) GetLastHandshake() *timestamppb.Timestamp { return nil } +func (x *Agent) GetLastPing() *LastPing { + if x != nil { + return x.LastPing + } + return nil +} + +type LastPing struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // latency is the RTT of the ping to the agent. + Latency *durationpb.Duration `protobuf:"bytes,1,opt,name=latency,proto3" json:"latency,omitempty"` + // did_p2p indicates whether the ping was sent P2P, or over DERP. + DidP2P bool `protobuf:"varint,2,opt,name=did_p2p,json=didP2p,proto3" json:"did_p2p,omitempty"` + // preferred_derp is the human readable name of the preferred DERP region, + // or the region used for the last ping, if it was sent over DERP. + PreferredDerp string `protobuf:"bytes,3,opt,name=preferred_derp,json=preferredDerp,proto3" json:"preferred_derp,omitempty"` + // preferred_derp_latency is the last known latency to the preferred DERP + // region. Unset if the region does not appear in the DERP map. + PreferredDerpLatency *durationpb.Duration `protobuf:"bytes,4,opt,name=preferred_derp_latency,json=preferredDerpLatency,proto3,oneof" json:"preferred_derp_latency,omitempty"` +} + +func (x *LastPing) Reset() { + *x = LastPing{} + if protoimpl.UnsafeEnabled { + mi := &file_vpn_vpn_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *LastPing) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LastPing) ProtoMessage() {} + +func (x *LastPing) ProtoReflect() protoreflect.Message { + mi := &file_vpn_vpn_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LastPing.ProtoReflect.Descriptor instead. +func (*LastPing) Descriptor() ([]byte, []int) { + return file_vpn_vpn_proto_rawDescGZIP(), []int{10} +} + +func (x *LastPing) GetLatency() *durationpb.Duration { + if x != nil { + return x.Latency + } + return nil +} + +func (x *LastPing) GetDidP2P() bool { + if x != nil { + return x.DidP2P + } + return false +} + +func (x *LastPing) GetPreferredDerp() string { + if x != nil { + return x.PreferredDerp + } + return "" +} + +func (x *LastPing) GetPreferredDerpLatency() *durationpb.Duration { + if x != nil { + return x.PreferredDerpLatency + } + return nil +} + // NetworkSettingsRequest is based on // https://developer.apple.com/documentation/networkextension/nepackettunnelnetworksettings for // macOS. It is a request/response message with response NetworkSettingsResponse @@ -1081,7 +1168,7 @@ type NetworkSettingsRequest struct { func (x *NetworkSettingsRequest) Reset() { *x = NetworkSettingsRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[10] + mi := &file_vpn_vpn_proto_msgTypes[11] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1094,7 +1181,7 @@ func (x *NetworkSettingsRequest) String() string { func (*NetworkSettingsRequest) ProtoMessage() {} func (x *NetworkSettingsRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[10] + mi := &file_vpn_vpn_proto_msgTypes[11] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1107,7 +1194,7 @@ func (x *NetworkSettingsRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkSettingsRequest.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11} } func (x *NetworkSettingsRequest) GetTunnelOverheadBytes() uint32 { @@ -1166,7 +1253,7 @@ type NetworkSettingsResponse struct { func (x *NetworkSettingsResponse) Reset() { *x = NetworkSettingsResponse{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[11] + mi := &file_vpn_vpn_proto_msgTypes[12] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1179,7 +1266,7 @@ func (x *NetworkSettingsResponse) String() string { func (*NetworkSettingsResponse) ProtoMessage() {} func (x *NetworkSettingsResponse) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[11] + mi := &file_vpn_vpn_proto_msgTypes[12] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1192,7 +1279,7 @@ func (x *NetworkSettingsResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use NetworkSettingsResponse.ProtoReflect.Descriptor instead. func (*NetworkSettingsResponse) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{11} + return file_vpn_vpn_proto_rawDescGZIP(), []int{12} } func (x *NetworkSettingsResponse) GetSuccess() bool { @@ -1231,7 +1318,7 @@ type StartRequest struct { func (x *StartRequest) Reset() { *x = StartRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[12] + mi := &file_vpn_vpn_proto_msgTypes[13] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1244,7 +1331,7 @@ func (x *StartRequest) String() string { func (*StartRequest) ProtoMessage() {} func (x *StartRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[12] + mi := &file_vpn_vpn_proto_msgTypes[13] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1257,7 +1344,7 @@ func (x *StartRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartRequest.ProtoReflect.Descriptor instead. func (*StartRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{12} + return file_vpn_vpn_proto_rawDescGZIP(), []int{13} } func (x *StartRequest) GetTunnelFileDescriptor() int32 { @@ -1321,7 +1408,7 @@ type StartResponse struct { func (x *StartResponse) Reset() { *x = StartResponse{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[13] + mi := &file_vpn_vpn_proto_msgTypes[14] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1334,7 +1421,7 @@ func (x *StartResponse) String() string { func (*StartResponse) ProtoMessage() {} func (x *StartResponse) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[13] + mi := &file_vpn_vpn_proto_msgTypes[14] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1347,7 +1434,7 @@ func (x *StartResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartResponse.ProtoReflect.Descriptor instead. func (*StartResponse) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{13} + return file_vpn_vpn_proto_rawDescGZIP(), []int{14} } func (x *StartResponse) GetSuccess() bool { @@ -1375,7 +1462,7 @@ type StopRequest struct { func (x *StopRequest) Reset() { *x = StopRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[14] + mi := &file_vpn_vpn_proto_msgTypes[15] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1388,7 +1475,7 @@ func (x *StopRequest) String() string { func (*StopRequest) ProtoMessage() {} func (x *StopRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[14] + mi := &file_vpn_vpn_proto_msgTypes[15] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1401,7 +1488,7 @@ func (x *StopRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopRequest.ProtoReflect.Descriptor instead. func (*StopRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{14} + return file_vpn_vpn_proto_rawDescGZIP(), []int{15} } // StopResponse is a response to stopping the tunnel. After sending this response, the tunnel closes @@ -1418,7 +1505,7 @@ type StopResponse struct { func (x *StopResponse) Reset() { *x = StopResponse{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[15] + mi := &file_vpn_vpn_proto_msgTypes[16] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1431,7 +1518,7 @@ func (x *StopResponse) String() string { func (*StopResponse) ProtoMessage() {} func (x *StopResponse) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[15] + mi := &file_vpn_vpn_proto_msgTypes[16] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1444,7 +1531,7 @@ func (x *StopResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopResponse.ProtoReflect.Descriptor instead. func (*StopResponse) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{15} + return file_vpn_vpn_proto_rawDescGZIP(), []int{16} } func (x *StopResponse) GetSuccess() bool { @@ -1472,7 +1559,7 @@ type StatusRequest struct { func (x *StatusRequest) Reset() { *x = StatusRequest{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[16] + mi := &file_vpn_vpn_proto_msgTypes[17] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1485,7 +1572,7 @@ func (x *StatusRequest) String() string { func (*StatusRequest) ProtoMessage() {} func (x *StatusRequest) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[16] + mi := &file_vpn_vpn_proto_msgTypes[17] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1498,7 +1585,7 @@ func (x *StatusRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StatusRequest.ProtoReflect.Descriptor instead. func (*StatusRequest) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{16} + return file_vpn_vpn_proto_rawDescGZIP(), []int{17} } // Status is sent in response to a StatusRequest or broadcasted to all clients @@ -1519,7 +1606,7 @@ type Status struct { func (x *Status) Reset() { *x = Status{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[17] + mi := &file_vpn_vpn_proto_msgTypes[18] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1532,7 +1619,7 @@ func (x *Status) String() string { func (*Status) ProtoMessage() {} func (x *Status) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[17] + mi := &file_vpn_vpn_proto_msgTypes[18] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1545,7 +1632,7 @@ func (x *Status) ProtoReflect() protoreflect.Message { // Deprecated: Use Status.ProtoReflect.Descriptor instead. func (*Status) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{17} + return file_vpn_vpn_proto_rawDescGZIP(), []int{18} } func (x *Status) GetLifecycle() Status_Lifecycle { @@ -1581,7 +1668,7 @@ type Log_Field struct { func (x *Log_Field) Reset() { *x = Log_Field{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[18] + mi := &file_vpn_vpn_proto_msgTypes[19] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1594,7 +1681,7 @@ func (x *Log_Field) String() string { func (*Log_Field) ProtoMessage() {} func (x *Log_Field) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[18] + mi := &file_vpn_vpn_proto_msgTypes[19] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1642,7 +1729,7 @@ type NetworkSettingsRequest_DNSSettings struct { func (x *NetworkSettingsRequest_DNSSettings) Reset() { *x = NetworkSettingsRequest_DNSSettings{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[19] + mi := &file_vpn_vpn_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1655,7 +1742,7 @@ func (x *NetworkSettingsRequest_DNSSettings) String() string { func (*NetworkSettingsRequest_DNSSettings) ProtoMessage() {} func (x *NetworkSettingsRequest_DNSSettings) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[19] + mi := &file_vpn_vpn_proto_msgTypes[20] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1668,7 +1755,7 @@ func (x *NetworkSettingsRequest_DNSSettings) ProtoReflect() protoreflect.Message // Deprecated: Use NetworkSettingsRequest_DNSSettings.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_DNSSettings) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 0} } func (x *NetworkSettingsRequest_DNSSettings) GetServers() []string { @@ -1722,7 +1809,7 @@ type NetworkSettingsRequest_IPv4Settings struct { func (x *NetworkSettingsRequest_IPv4Settings) Reset() { *x = NetworkSettingsRequest_IPv4Settings{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[20] + mi := &file_vpn_vpn_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1735,7 +1822,7 @@ func (x *NetworkSettingsRequest_IPv4Settings) String() string { func (*NetworkSettingsRequest_IPv4Settings) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv4Settings) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[20] + mi := &file_vpn_vpn_proto_msgTypes[21] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1748,7 +1835,7 @@ func (x *NetworkSettingsRequest_IPv4Settings) ProtoReflect() protoreflect.Messag // Deprecated: Use NetworkSettingsRequest_IPv4Settings.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv4Settings) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 1} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 1} } func (x *NetworkSettingsRequest_IPv4Settings) GetAddrs() []string { @@ -1800,7 +1887,7 @@ type NetworkSettingsRequest_IPv6Settings struct { func (x *NetworkSettingsRequest_IPv6Settings) Reset() { *x = NetworkSettingsRequest_IPv6Settings{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[21] + mi := &file_vpn_vpn_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1813,7 +1900,7 @@ func (x *NetworkSettingsRequest_IPv6Settings) String() string { func (*NetworkSettingsRequest_IPv6Settings) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv6Settings) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[21] + mi := &file_vpn_vpn_proto_msgTypes[22] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1826,7 +1913,7 @@ func (x *NetworkSettingsRequest_IPv6Settings) ProtoReflect() protoreflect.Messag // Deprecated: Use NetworkSettingsRequest_IPv6Settings.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv6Settings) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 2} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 2} } func (x *NetworkSettingsRequest_IPv6Settings) GetAddrs() []string { @@ -1871,7 +1958,7 @@ type NetworkSettingsRequest_IPv4Settings_IPv4Route struct { func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) Reset() { *x = NetworkSettingsRequest_IPv4Settings_IPv4Route{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[22] + mi := &file_vpn_vpn_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1884,7 +1971,7 @@ func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) String() string { func (*NetworkSettingsRequest_IPv4Settings_IPv4Route) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[22] + mi := &file_vpn_vpn_proto_msgTypes[23] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1897,7 +1984,7 @@ func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) ProtoReflect() protorefl // Deprecated: Use NetworkSettingsRequest_IPv4Settings_IPv4Route.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv4Settings_IPv4Route) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 1, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 1, 0} } func (x *NetworkSettingsRequest_IPv4Settings_IPv4Route) GetDestination() string { @@ -1935,7 +2022,7 @@ type NetworkSettingsRequest_IPv6Settings_IPv6Route struct { func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) Reset() { *x = NetworkSettingsRequest_IPv6Settings_IPv6Route{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[23] + mi := &file_vpn_vpn_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1948,7 +2035,7 @@ func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) String() string { func (*NetworkSettingsRequest_IPv6Settings_IPv6Route) ProtoMessage() {} func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[23] + mi := &file_vpn_vpn_proto_msgTypes[24] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1961,7 +2048,7 @@ func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) ProtoReflect() protorefl // Deprecated: Use NetworkSettingsRequest_IPv6Settings_IPv6Route.ProtoReflect.Descriptor instead. func (*NetworkSettingsRequest_IPv6Settings_IPv6Route) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{10, 2, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{11, 2, 0} } func (x *NetworkSettingsRequest_IPv6Settings_IPv6Route) GetDestination() string { @@ -1998,7 +2085,7 @@ type StartRequest_Header struct { func (x *StartRequest_Header) Reset() { *x = StartRequest_Header{} if protoimpl.UnsafeEnabled { - mi := &file_vpn_vpn_proto_msgTypes[24] + mi := &file_vpn_vpn_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -2011,7 +2098,7 @@ func (x *StartRequest_Header) String() string { func (*StartRequest_Header) ProtoMessage() {} func (x *StartRequest_Header) ProtoReflect() protoreflect.Message { - mi := &file_vpn_vpn_proto_msgTypes[24] + mi := &file_vpn_vpn_proto_msgTypes[25] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -2024,7 +2111,7 @@ func (x *StartRequest_Header) ProtoReflect() protoreflect.Message { // Deprecated: Use StartRequest_Header.ProtoReflect.Descriptor instead. func (*StartRequest_Header) Descriptor() ([]byte, []int) { - return file_vpn_vpn_proto_rawDescGZIP(), []int{12, 0} + return file_vpn_vpn_proto_rawDescGZIP(), []int{13, 0} } func (x *StartRequest_Header) GetName() string { @@ -2047,6 +2134,8 @@ var file_vpn_vpn_proto_rawDesc = []byte{ 0x0a, 0x0d, 0x76, 0x70, 0x6e, 0x2f, 0x76, 0x70, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x03, 0x76, 0x70, 0x6e, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x3d, 0x0a, 0x03, 0x52, 0x50, 0x43, 0x12, 0x15, 0x0a, 0x06, 0x6d, 0x73, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x04, 0x52, 0x05, 0x6d, 0x73, 0x67, 0x49, 0x64, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, @@ -2158,7 +2247,7 @@ var file_vpn_vpn_proto_rawDesc = []byte{ 0x49, 0x4c, 0x45, 0x44, 0x10, 0x06, 0x12, 0x0d, 0x0a, 0x09, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x49, 0x4e, 0x47, 0x10, 0x07, 0x12, 0x0c, 0x0a, 0x08, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, 0x10, 0x08, 0x12, 0x0c, 0x0a, 0x08, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x49, 0x4e, 0x47, 0x10, - 0x09, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, 0xc0, + 0x09, 0x12, 0x0b, 0x0a, 0x07, 0x44, 0x45, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x0a, 0x22, 0xff, 0x01, 0x0a, 0x05, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x02, 0x69, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x21, 0x0a, 0x0c, @@ -2171,148 +2260,167 @@ var file_vpn_vpn_proto_rawDesc = []byte{ 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0d, 0x6c, 0x61, 0x73, 0x74, 0x48, 0x61, 0x6e, 0x64, 0x73, 0x68, 0x61, 0x6b, - 0x65, 0x22, 0xb5, 0x0a, 0x0a, 0x16, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, - 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x15, - 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, 0x64, 0x5f, - 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x74, 0x75, 0x6e, - 0x6e, 0x65, 0x6c, 0x4f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, 0x64, 0x42, 0x79, 0x74, 0x65, 0x73, - 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x03, 0x6d, - 0x74, 0x75, 0x12, 0x4a, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, + 0x65, 0x12, 0x2f, 0x0a, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x07, + 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4c, 0x61, 0x73, 0x74, 0x50, + 0x69, 0x6e, 0x67, 0x48, 0x00, 0x52, 0x08, 0x6c, 0x61, 0x73, 0x74, 0x50, 0x69, 0x6e, 0x67, 0x88, + 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x70, 0x69, 0x6e, 0x67, + 0x22, 0xf0, 0x01, 0x0a, 0x08, 0x4c, 0x61, 0x73, 0x74, 0x50, 0x69, 0x6e, 0x67, 0x12, 0x33, 0x0a, + 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x07, 0x6c, 0x61, 0x74, 0x65, 0x6e, + 0x63, 0x79, 0x12, 0x17, 0x0a, 0x07, 0x64, 0x69, 0x64, 0x5f, 0x70, 0x32, 0x70, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x08, 0x52, 0x06, 0x64, 0x69, 0x64, 0x50, 0x32, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x70, + 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x64, 0x65, 0x72, 0x70, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x44, 0x65, + 0x72, 0x70, 0x12, 0x54, 0x0a, 0x16, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, + 0x64, 0x65, 0x72, 0x70, 0x5f, 0x6c, 0x61, 0x74, 0x65, 0x6e, 0x63, 0x79, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x44, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x48, 0x00, 0x52, + 0x14, 0x70, 0x72, 0x65, 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x44, 0x65, 0x72, 0x70, 0x4c, 0x61, + 0x74, 0x65, 0x6e, 0x63, 0x79, 0x88, 0x01, 0x01, 0x42, 0x19, 0x0a, 0x17, 0x5f, 0x70, 0x72, 0x65, + 0x66, 0x65, 0x72, 0x72, 0x65, 0x64, 0x5f, 0x64, 0x65, 0x72, 0x70, 0x5f, 0x6c, 0x61, 0x74, 0x65, + 0x6e, 0x63, 0x79, 0x22, 0xb5, 0x0a, 0x0a, 0x16, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, + 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, + 0x0a, 0x15, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x6f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, + 0x64, 0x5f, 0x62, 0x79, 0x74, 0x65, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x13, 0x74, + 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x4f, 0x76, 0x65, 0x72, 0x68, 0x65, 0x61, 0x64, 0x42, 0x79, 0x74, + 0x65, 0x73, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x74, 0x75, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, + 0x03, 0x6d, 0x74, 0x75, 0x12, 0x4a, 0x0a, 0x0c, 0x64, 0x6e, 0x73, 0x5f, 0x73, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x27, 0x2e, 0x76, 0x70, 0x6e, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x12, 0x32, 0x0a, 0x15, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, + 0x65, 0x5f, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x13, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x41, 0x64, 0x64, + 0x72, 0x65, 0x73, 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x73, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, + 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, + 0x6e, 0x67, 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x73, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, 0x6e, + 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x1a, 0xcb, 0x01, 0x0a, 0x0b, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, + 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, + 0x20, 0x03, 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x61, 0x74, + 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x74, + 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x6e, 0x6f, 0x5f, 0x73, 0x65, + 0x61, 0x72, 0x63, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x6d, 0x61, 0x74, 0x63, + 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x4e, 0x6f, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, + 0x1a, 0xf4, 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6e, 0x65, + 0x74, 0x5f, 0x6d, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x73, + 0x75, 0x62, 0x6e, 0x65, 0x74, 0x4d, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, + 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x72, 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, + 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, + 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, + 0x5b, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, 0x74, + 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, - 0x73, 0x52, 0x0b, 0x64, 0x6e, 0x73, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x32, - 0x0a, 0x15, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x5f, - 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x13, 0x74, - 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x41, 0x64, 0x64, 0x72, 0x65, - 0x73, 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x34, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, 0x6e, 0x2e, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, - 0x73, 0x12, 0x4d, 0x0a, 0x0d, 0x69, 0x70, 0x76, 0x36, 0x5f, 0x73, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, + 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x65, 0x78, + 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x59, 0x0a, 0x09, + 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, + 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6d, + 0x61, 0x73, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x61, 0x73, 0x6b, 0x12, + 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x1a, 0xf1, 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x36, + 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, + 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x25, + 0x0a, 0x0e, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, + 0x6e, 0x67, 0x74, 0x68, 0x73, 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, + 0x64, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, + 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, + 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, + 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, + 0x74, 0x65, 0x52, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, + 0x65, 0x73, 0x12, 0x5b, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, + 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, + 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, + 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, + 0x0e, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, + 0x6a, 0x0a, 0x09, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, + 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x23, + 0x0a, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, + 0x67, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x22, 0x58, 0x0a, 0x17, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x0c, 0x69, 0x70, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x1a, 0xcb, 0x01, 0x0a, 0x0b, 0x44, 0x4e, 0x53, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x12, 0x18, 0x0a, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x07, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, 0x73, 0x65, - 0x61, 0x72, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x0d, 0x73, 0x65, 0x61, 0x72, 0x63, 0x68, 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x5f, 0x6e, 0x61, 0x6d, 0x65, - 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x4e, 0x61, - 0x6d, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x5f, 0x64, 0x6f, 0x6d, 0x61, - 0x69, 0x6e, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x6d, 0x61, 0x74, 0x63, 0x68, - 0x44, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x74, 0x63, 0x68, - 0x5f, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x5f, 0x6e, 0x6f, 0x5f, 0x73, 0x65, 0x61, 0x72, - 0x63, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x14, 0x6d, 0x61, 0x74, 0x63, 0x68, 0x44, - 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x73, 0x4e, 0x6f, 0x53, 0x65, 0x61, 0x72, 0x63, 0x68, 0x1a, 0xf4, - 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, - 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, - 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74, 0x5f, - 0x6d, 0x61, 0x73, 0x6b, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x75, 0x62, - 0x6e, 0x65, 0x74, 0x4d, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, - 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, - 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x69, - 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x12, 0x5b, 0x0a, - 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, - 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x34, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, - 0x2e, 0x49, 0x50, 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x65, 0x78, 0x63, 0x6c, - 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x59, 0x0a, 0x09, 0x49, 0x50, - 0x76, 0x34, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, - 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x61, 0x73, - 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x61, 0x73, 0x6b, 0x12, 0x16, 0x0a, - 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x72, - 0x6f, 0x75, 0x74, 0x65, 0x72, 0x1a, 0xf1, 0x02, 0x0a, 0x0c, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x12, 0x14, 0x0a, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x18, - 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x05, 0x61, 0x64, 0x64, 0x72, 0x73, 0x12, 0x25, 0x0a, 0x0e, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x73, 0x18, 0x02, - 0x20, 0x03, 0x28, 0x0d, 0x52, 0x0d, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, 0x67, - 0x74, 0x68, 0x73, 0x12, 0x5b, 0x0a, 0x0f, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, - 0x72, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, - 0x70, 0x6e, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, - 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, - 0x52, 0x0e, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, - 0x12, 0x5b, 0x0a, 0x0f, 0x65, 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x5f, 0x72, 0x6f, 0x75, - 0x74, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x32, 0x2e, 0x76, 0x70, 0x6e, 0x2e, - 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x53, 0x65, 0x74, 0x74, 0x69, - 0x6e, 0x67, 0x73, 0x2e, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x52, 0x0e, 0x65, - 0x78, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x64, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x73, 0x1a, 0x6a, 0x0a, - 0x09, 0x49, 0x50, 0x76, 0x36, 0x52, 0x6f, 0x75, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, - 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0b, 0x64, 0x65, 0x73, 0x74, 0x69, 0x6e, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x0d, 0x52, 0x0c, 0x70, 0x72, 0x65, 0x66, 0x69, 0x78, 0x4c, 0x65, 0x6e, 0x67, 0x74, - 0x68, 0x12, 0x16, 0x0a, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x72, 0x6f, 0x75, 0x74, 0x65, 0x72, 0x22, 0x58, 0x0a, 0x17, 0x4e, 0x65, 0x74, - 0x77, 0x6f, 0x72, 0x6b, 0x53, 0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, - 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, - 0x61, 0x67, 0x65, 0x22, 0xd4, 0x02, 0x0a, 0x0c, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x16, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x66, - 0x69, 0x6c, 0x65, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x18, 0x01, - 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x46, 0x69, 0x6c, 0x65, - 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x63, 0x6f, - 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x70, 0x69, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, 0x69, 0x54, - 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x18, - 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, 0x61, 0x72, - 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, - 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, - 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, - 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, - 0x6f, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, - 0x4f, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x65, 0x73, 0x6b, - 0x74, 0x6f, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x13, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, 0x70, 0x56, - 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x32, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, - 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, - 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x4e, 0x0a, 0x0d, 0x53, 0x74, - 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0xd4, 0x02, 0x0a, 0x0c, 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x34, 0x0a, 0x16, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x5f, 0x66, 0x69, 0x6c, 0x65, 0x5f, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x05, 0x52, 0x14, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x46, 0x69, + 0x6c, 0x65, 0x44, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x6f, 0x72, 0x12, 0x1b, 0x0a, 0x09, + 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x55, 0x72, 0x6c, 0x12, 0x1b, 0x0a, 0x09, 0x61, 0x70, 0x69, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x61, 0x70, + 0x69, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x32, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, + 0x61, 0x72, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, + 0x72, 0x52, 0x07, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, + 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, + 0x65, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x76, 0x69, 0x63, + 0x65, 0x5f, 0x6f, 0x73, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x4f, 0x73, 0x12, 0x32, 0x0a, 0x15, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x5f, 0x64, 0x65, + 0x73, 0x6b, 0x74, 0x6f, 0x70, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x07, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x13, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, + 0x70, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x1a, 0x32, 0x0a, 0x06, 0x48, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x4e, 0x0a, 0x0d, + 0x53, 0x74, 0x61, 0x72, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, + 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, + 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0d, 0x0a, 0x0b, + 0x53, 0x74, 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x4d, 0x0a, 0x0c, 0x53, + 0x74, 0x6f, 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, - 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0d, 0x0a, 0x0b, 0x53, 0x74, - 0x6f, 0x70, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x4d, 0x0a, 0x0c, 0x53, 0x74, 0x6f, - 0x70, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x73, 0x75, 0x63, 0x63, - 0x65, 0x73, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, - 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0f, 0x0a, 0x0d, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe4, 0x01, 0x0a, 0x06, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, - 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x52, 0x09, - 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, 0x72, 0x72, - 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x30, - 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, - 0x22, 0x4e, 0x0a, 0x09, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x0b, 0x0a, - 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, - 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x41, 0x52, - 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x49, 0x4e, - 0x47, 0x10, 0x03, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, 0x10, 0x04, - 0x42, 0x39, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x63, - 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, 0x76, 0x70, - 0x6e, 0xaa, 0x02, 0x17, 0x43, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x73, 0x6b, 0x74, 0x6f, - 0x70, 0x2e, 0x56, 0x70, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x0f, 0x0a, 0x0d, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0xe4, 0x01, 0x0a, 0x06, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, + 0x63, 0x6c, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x15, 0x2e, 0x76, 0x70, 0x6e, 0x2e, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x2e, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, + 0x52, 0x09, 0x6c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x65, + 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x12, 0x30, 0x0a, 0x0b, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x76, 0x70, 0x6e, 0x2e, 0x50, 0x65, 0x65, 0x72, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x0a, 0x70, 0x65, 0x65, 0x72, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x22, 0x4e, 0x0a, 0x09, 0x4c, 0x69, 0x66, 0x65, 0x63, 0x79, 0x63, 0x6c, 0x65, 0x12, + 0x0b, 0x0a, 0x07, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x0c, 0x0a, 0x08, + 0x53, 0x54, 0x41, 0x52, 0x54, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, + 0x41, 0x52, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x0c, 0x0a, 0x08, 0x53, 0x54, 0x4f, 0x50, 0x50, + 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x0b, 0x0a, 0x07, 0x53, 0x54, 0x4f, 0x50, 0x50, 0x45, 0x44, + 0x10, 0x04, 0x42, 0x39, 0x5a, 0x1d, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x63, 0x6f, 0x64, 0x65, 0x72, 0x2f, 0x76, 0x32, 0x2f, + 0x76, 0x70, 0x6e, 0xaa, 0x02, 0x17, 0x43, 0x6f, 0x64, 0x65, 0x72, 0x2e, 0x44, 0x65, 0x73, 0x6b, + 0x74, 0x6f, 0x70, 0x2e, 0x56, 0x70, 0x6e, 0x2e, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -2328,7 +2436,7 @@ func file_vpn_vpn_proto_rawDescGZIP() []byte { } var file_vpn_vpn_proto_enumTypes = make([]protoimpl.EnumInfo, 3) -var file_vpn_vpn_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_vpn_vpn_proto_msgTypes = make([]protoimpl.MessageInfo, 26) var file_vpn_vpn_proto_goTypes = []interface{}{ (Log_Level)(0), // 0: vpn.Log.Level (Workspace_Status)(0), // 1: vpn.Workspace.Status @@ -2343,66 +2451,71 @@ var file_vpn_vpn_proto_goTypes = []interface{}{ (*PeerUpdate)(nil), // 10: vpn.PeerUpdate (*Workspace)(nil), // 11: vpn.Workspace (*Agent)(nil), // 12: vpn.Agent - (*NetworkSettingsRequest)(nil), // 13: vpn.NetworkSettingsRequest - (*NetworkSettingsResponse)(nil), // 14: vpn.NetworkSettingsResponse - (*StartRequest)(nil), // 15: vpn.StartRequest - (*StartResponse)(nil), // 16: vpn.StartResponse - (*StopRequest)(nil), // 17: vpn.StopRequest - (*StopResponse)(nil), // 18: vpn.StopResponse - (*StatusRequest)(nil), // 19: vpn.StatusRequest - (*Status)(nil), // 20: vpn.Status - (*Log_Field)(nil), // 21: vpn.Log.Field - (*NetworkSettingsRequest_DNSSettings)(nil), // 22: vpn.NetworkSettingsRequest.DNSSettings - (*NetworkSettingsRequest_IPv4Settings)(nil), // 23: vpn.NetworkSettingsRequest.IPv4Settings - (*NetworkSettingsRequest_IPv6Settings)(nil), // 24: vpn.NetworkSettingsRequest.IPv6Settings - (*NetworkSettingsRequest_IPv4Settings_IPv4Route)(nil), // 25: vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route - (*NetworkSettingsRequest_IPv6Settings_IPv6Route)(nil), // 26: vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route - (*StartRequest_Header)(nil), // 27: vpn.StartRequest.Header - (*timestamppb.Timestamp)(nil), // 28: google.protobuf.Timestamp + (*LastPing)(nil), // 13: vpn.LastPing + (*NetworkSettingsRequest)(nil), // 14: vpn.NetworkSettingsRequest + (*NetworkSettingsResponse)(nil), // 15: vpn.NetworkSettingsResponse + (*StartRequest)(nil), // 16: vpn.StartRequest + (*StartResponse)(nil), // 17: vpn.StartResponse + (*StopRequest)(nil), // 18: vpn.StopRequest + (*StopResponse)(nil), // 19: vpn.StopResponse + (*StatusRequest)(nil), // 20: vpn.StatusRequest + (*Status)(nil), // 21: vpn.Status + (*Log_Field)(nil), // 22: vpn.Log.Field + (*NetworkSettingsRequest_DNSSettings)(nil), // 23: vpn.NetworkSettingsRequest.DNSSettings + (*NetworkSettingsRequest_IPv4Settings)(nil), // 24: vpn.NetworkSettingsRequest.IPv4Settings + (*NetworkSettingsRequest_IPv6Settings)(nil), // 25: vpn.NetworkSettingsRequest.IPv6Settings + (*NetworkSettingsRequest_IPv4Settings_IPv4Route)(nil), // 26: vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route + (*NetworkSettingsRequest_IPv6Settings_IPv6Route)(nil), // 27: vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route + (*StartRequest_Header)(nil), // 28: vpn.StartRequest.Header + (*timestamppb.Timestamp)(nil), // 29: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 30: google.protobuf.Duration } var file_vpn_vpn_proto_depIdxs = []int32{ 3, // 0: vpn.ManagerMessage.rpc:type_name -> vpn.RPC 9, // 1: vpn.ManagerMessage.get_peer_update:type_name -> vpn.GetPeerUpdate - 14, // 2: vpn.ManagerMessage.network_settings:type_name -> vpn.NetworkSettingsResponse - 15, // 3: vpn.ManagerMessage.start:type_name -> vpn.StartRequest - 17, // 4: vpn.ManagerMessage.stop:type_name -> vpn.StopRequest + 15, // 2: vpn.ManagerMessage.network_settings:type_name -> vpn.NetworkSettingsResponse + 16, // 3: vpn.ManagerMessage.start:type_name -> vpn.StartRequest + 18, // 4: vpn.ManagerMessage.stop:type_name -> vpn.StopRequest 3, // 5: vpn.TunnelMessage.rpc:type_name -> vpn.RPC 8, // 6: vpn.TunnelMessage.log:type_name -> vpn.Log 10, // 7: vpn.TunnelMessage.peer_update:type_name -> vpn.PeerUpdate - 13, // 8: vpn.TunnelMessage.network_settings:type_name -> vpn.NetworkSettingsRequest - 16, // 9: vpn.TunnelMessage.start:type_name -> vpn.StartResponse - 18, // 10: vpn.TunnelMessage.stop:type_name -> vpn.StopResponse + 14, // 8: vpn.TunnelMessage.network_settings:type_name -> vpn.NetworkSettingsRequest + 17, // 9: vpn.TunnelMessage.start:type_name -> vpn.StartResponse + 19, // 10: vpn.TunnelMessage.stop:type_name -> vpn.StopResponse 3, // 11: vpn.ClientMessage.rpc:type_name -> vpn.RPC - 15, // 12: vpn.ClientMessage.start:type_name -> vpn.StartRequest - 17, // 13: vpn.ClientMessage.stop:type_name -> vpn.StopRequest - 19, // 14: vpn.ClientMessage.status:type_name -> vpn.StatusRequest + 16, // 12: vpn.ClientMessage.start:type_name -> vpn.StartRequest + 18, // 13: vpn.ClientMessage.stop:type_name -> vpn.StopRequest + 20, // 14: vpn.ClientMessage.status:type_name -> vpn.StatusRequest 3, // 15: vpn.ServiceMessage.rpc:type_name -> vpn.RPC - 16, // 16: vpn.ServiceMessage.start:type_name -> vpn.StartResponse - 18, // 17: vpn.ServiceMessage.stop:type_name -> vpn.StopResponse - 20, // 18: vpn.ServiceMessage.status:type_name -> vpn.Status + 17, // 16: vpn.ServiceMessage.start:type_name -> vpn.StartResponse + 19, // 17: vpn.ServiceMessage.stop:type_name -> vpn.StopResponse + 21, // 18: vpn.ServiceMessage.status:type_name -> vpn.Status 0, // 19: vpn.Log.level:type_name -> vpn.Log.Level - 21, // 20: vpn.Log.fields:type_name -> vpn.Log.Field + 22, // 20: vpn.Log.fields:type_name -> vpn.Log.Field 11, // 21: vpn.PeerUpdate.upserted_workspaces:type_name -> vpn.Workspace 12, // 22: vpn.PeerUpdate.upserted_agents:type_name -> vpn.Agent 11, // 23: vpn.PeerUpdate.deleted_workspaces:type_name -> vpn.Workspace 12, // 24: vpn.PeerUpdate.deleted_agents:type_name -> vpn.Agent 1, // 25: vpn.Workspace.status:type_name -> vpn.Workspace.Status - 28, // 26: vpn.Agent.last_handshake:type_name -> google.protobuf.Timestamp - 22, // 27: vpn.NetworkSettingsRequest.dns_settings:type_name -> vpn.NetworkSettingsRequest.DNSSettings - 23, // 28: vpn.NetworkSettingsRequest.ipv4_settings:type_name -> vpn.NetworkSettingsRequest.IPv4Settings - 24, // 29: vpn.NetworkSettingsRequest.ipv6_settings:type_name -> vpn.NetworkSettingsRequest.IPv6Settings - 27, // 30: vpn.StartRequest.headers:type_name -> vpn.StartRequest.Header - 2, // 31: vpn.Status.lifecycle:type_name -> vpn.Status.Lifecycle - 10, // 32: vpn.Status.peer_update:type_name -> vpn.PeerUpdate - 25, // 33: vpn.NetworkSettingsRequest.IPv4Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route - 25, // 34: vpn.NetworkSettingsRequest.IPv4Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route - 26, // 35: vpn.NetworkSettingsRequest.IPv6Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route - 26, // 36: vpn.NetworkSettingsRequest.IPv6Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route - 37, // [37:37] is the sub-list for method output_type - 37, // [37:37] is the sub-list for method input_type - 37, // [37:37] is the sub-list for extension type_name - 37, // [37:37] is the sub-list for extension extendee - 0, // [0:37] is the sub-list for field type_name + 29, // 26: vpn.Agent.last_handshake:type_name -> google.protobuf.Timestamp + 13, // 27: vpn.Agent.last_ping:type_name -> vpn.LastPing + 30, // 28: vpn.LastPing.latency:type_name -> google.protobuf.Duration + 30, // 29: vpn.LastPing.preferred_derp_latency:type_name -> google.protobuf.Duration + 23, // 30: vpn.NetworkSettingsRequest.dns_settings:type_name -> vpn.NetworkSettingsRequest.DNSSettings + 24, // 31: vpn.NetworkSettingsRequest.ipv4_settings:type_name -> vpn.NetworkSettingsRequest.IPv4Settings + 25, // 32: vpn.NetworkSettingsRequest.ipv6_settings:type_name -> vpn.NetworkSettingsRequest.IPv6Settings + 28, // 33: vpn.StartRequest.headers:type_name -> vpn.StartRequest.Header + 2, // 34: vpn.Status.lifecycle:type_name -> vpn.Status.Lifecycle + 10, // 35: vpn.Status.peer_update:type_name -> vpn.PeerUpdate + 26, // 36: vpn.NetworkSettingsRequest.IPv4Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route + 26, // 37: vpn.NetworkSettingsRequest.IPv4Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv4Settings.IPv4Route + 27, // 38: vpn.NetworkSettingsRequest.IPv6Settings.included_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route + 27, // 39: vpn.NetworkSettingsRequest.IPv6Settings.excluded_routes:type_name -> vpn.NetworkSettingsRequest.IPv6Settings.IPv6Route + 40, // [40:40] is the sub-list for method output_type + 40, // [40:40] is the sub-list for method input_type + 40, // [40:40] is the sub-list for extension type_name + 40, // [40:40] is the sub-list for extension extendee + 0, // [0:40] is the sub-list for field type_name } func init() { file_vpn_vpn_proto_init() } @@ -2532,7 +2645,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[10].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest); i { + switch v := v.(*LastPing); i { case 0: return &v.state case 1: @@ -2544,7 +2657,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[11].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsResponse); i { + switch v := v.(*NetworkSettingsRequest); i { case 0: return &v.state case 1: @@ -2556,7 +2669,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[12].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StartRequest); i { + switch v := v.(*NetworkSettingsResponse); i { case 0: return &v.state case 1: @@ -2568,7 +2681,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[13].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StartResponse); i { + switch v := v.(*StartRequest); i { case 0: return &v.state case 1: @@ -2580,7 +2693,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[14].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopRequest); i { + switch v := v.(*StartResponse); i { case 0: return &v.state case 1: @@ -2592,7 +2705,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[15].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StopResponse); i { + switch v := v.(*StopRequest); i { case 0: return &v.state case 1: @@ -2604,7 +2717,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[16].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*StatusRequest); i { + switch v := v.(*StopResponse); i { case 0: return &v.state case 1: @@ -2616,7 +2729,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[17].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Status); i { + switch v := v.(*StatusRequest); i { case 0: return &v.state case 1: @@ -2628,7 +2741,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[18].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*Log_Field); i { + switch v := v.(*Status); i { case 0: return &v.state case 1: @@ -2640,7 +2753,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[19].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_DNSSettings); i { + switch v := v.(*Log_Field); i { case 0: return &v.state case 1: @@ -2652,7 +2765,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[20].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv4Settings); i { + switch v := v.(*NetworkSettingsRequest_DNSSettings); i { case 0: return &v.state case 1: @@ -2664,7 +2777,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[21].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv6Settings); i { + switch v := v.(*NetworkSettingsRequest_IPv4Settings); i { case 0: return &v.state case 1: @@ -2676,7 +2789,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[22].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv4Settings_IPv4Route); i { + switch v := v.(*NetworkSettingsRequest_IPv6Settings); i { case 0: return &v.state case 1: @@ -2688,7 +2801,7 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[23].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*NetworkSettingsRequest_IPv6Settings_IPv6Route); i { + switch v := v.(*NetworkSettingsRequest_IPv4Settings_IPv4Route); i { case 0: return &v.state case 1: @@ -2700,6 +2813,18 @@ func file_vpn_vpn_proto_init() { } } file_vpn_vpn_proto_msgTypes[24].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*NetworkSettingsRequest_IPv6Settings_IPv6Route); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_vpn_vpn_proto_msgTypes[25].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*StartRequest_Header); i { case 0: return &v.state @@ -2735,13 +2860,15 @@ func file_vpn_vpn_proto_init() { (*ServiceMessage_Stop)(nil), (*ServiceMessage_Status)(nil), } + file_vpn_vpn_proto_msgTypes[9].OneofWrappers = []interface{}{} + file_vpn_vpn_proto_msgTypes[10].OneofWrappers = []interface{}{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_vpn_vpn_proto_rawDesc, NumEnums: 3, - NumMessages: 25, + NumMessages: 26, NumExtensions: 0, NumServices: 0, }, diff --git a/vpn/vpn.proto b/vpn/vpn.proto index 963098c60a648..44383fa80e0cb 100644 --- a/vpn/vpn.proto +++ b/vpn/vpn.proto @@ -3,6 +3,7 @@ option go_package = "github.com/coder/coder/v2/vpn"; option csharp_namespace = "Coder.Desktop.Vpn.Proto"; import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; package vpn; @@ -130,6 +131,21 @@ message Agent { // last_handshake is the primary indicator of whether we are connected to a peer. Zero value or // anything longer than 5 minutes ago means there is a problem. google.protobuf.Timestamp last_handshake = 6; + // If unset, a successful ping has not yet been made. + optional LastPing last_ping = 7; +} + +message LastPing { + // latency is the RTT of the ping to the agent. + google.protobuf.Duration latency = 1; + // did_p2p indicates whether the ping was sent P2P, or over DERP. + bool did_p2p = 2; + // preferred_derp is the human readable name of the preferred DERP region, + // or the region used for the last ping, if it was sent over DERP. + string preferred_derp = 3; + // preferred_derp_latency is the last known latency to the preferred DERP + // region. Unset if the region does not appear in the DERP map. + optional google.protobuf.Duration preferred_derp_latency = 4; } // NetworkSettingsRequest is based on