diff --git a/.github/workflows/reverse-proxy-e2e.yml b/.github/workflows/reverse-proxy-e2e.yml new file mode 100644 index 00000000000..934a6d4c97f --- /dev/null +++ b/.github/workflows/reverse-proxy-e2e.yml @@ -0,0 +1,53 @@ +name: Reverse Proxy E2E + +on: + pull_request: + paths: + - '.github/workflows/reverse-proxy-e2e.yml' + - '**/*.go' + - 'e2e/**' + - 'shared/management/**' + - 'combined/Dockerfile*' + - 'proxy/Dockerfile*' + - '.dockerignore' + - 'go.mod' + - 'go.sum' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + reverse-proxy: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Install Go + uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version-file: go.mod + + - name: Set up Buildx + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 + + - name: Cache Docker layers + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + path: /tmp/.reverse-proxy-buildx-cache + key: ${{ runner.os }}-reverse-proxy-e2e-${{ hashFiles('go.sum', 'combined/Dockerfile.multistage', 'proxy/Dockerfile.multistage') }} + restore-keys: | + ${{ runner.os }}-reverse-proxy-e2e- + + - name: Run reverse-proxy E2E + env: + NB_E2E_BUILDX_CACHE: /tmp/.reverse-proxy-buildx-cache + run: go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/... diff --git a/e2e/harness/cert.go b/e2e/harness/cert.go index c8a28e470df..b322ab6d20c 100644 --- a/e2e/harness/cert.go +++ b/e2e/harness/cert.go @@ -18,8 +18,8 @@ import ( // writeSelfSignedCert generates a self-signed TLS cert/key pair covering the // given DNS names and writes them as tls.crt / tls.key in dir. The proxy serves -// this for the agent-network endpoint; the client curls with -k, so validity -// chains don't matter — the proxy just needs a usable cert to present. +// this for the agent-network endpoint; test clients either trust the generated +// certificate explicitly or opt out of verification inside the test network. func writeSelfSignedCert(dir string, dnsNames []string) error { priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { diff --git a/e2e/harness/http_upstream.go b/e2e/harness/http_upstream.go new file mode 100644 index 00000000000..3b6fb7df6c5 --- /dev/null +++ b/e2e/harness/http_upstream.go @@ -0,0 +1,228 @@ +//go:build e2e + +package harness + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "strings" + "sync/atomic" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const ( + httpUpstreamImage = "nginx:alpine" + httpUpstreamAlias = "httpupstream" + httpUpstreamPort = "8080/tcp" + httpUpstreamLogPath = "/tmp/e2e-access.log" +) + +// httpUpstreamNginxConf returns request details from a plain HTTP upstream and +// writes every request URI to an unbuffered file. Tests use unique path markers +// to distinguish requests that reached the upstream from proxy-generated +// responses with the same status. +const httpUpstreamNginxConf = `pid /tmp/nginx.pid; +worker_processes 1; +events {} +http { + log_format e2e escape=json '{"uri":"$request_uri"}'; + access_log /tmp/e2e-access.log e2e; + + server { + listen 8080; + location / { + default_type application/json; + return 200 '{"source":"netbird-e2e-http-upstream","path":"$uri","request_uri":"$request_uri","configured_auth":"$http_x_e2e_proxy_key","authorization":"$http_authorization","cookie":"$http_cookie","netbird_user":"$http_x_netbird_user","netbird_groups":"$http_x_netbird_groups"}'; + } + } +} +` + +// HTTPUpstream is a plain HTTP server on the combined server's network. It +// reflects selected request properties and records request URIs for tests that +// need to distinguish proxy responses from upstream responses. +type HTTPUpstream struct { + container testcontainers.Container + workDir string + directAddress string + syncSequence atomic.Uint64 + URL string +} + +// StartHTTPUpstream runs a request-observing nginx server on the shared network. +func StartHTTPUpstream(ctx context.Context, c *Combined) (*HTTPUpstream, error) { + workDir, err := os.MkdirTemp("/tmp", "nb-e2e-http-upstream-*") + if err != nil { + return nil, fmt.Errorf("create HTTP upstream work dir: %w", err) + } + if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e config dir, must be traversable by the container uid + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("chmod HTTP upstream dir: %w", err) + } + if err := os.WriteFile(filepath.Join(workDir, "nginx.conf"), []byte(httpUpstreamNginxConf), 0o644); err != nil { //nolint:gosec // non-secret e2e config + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("write HTTP upstream config: %w", err) + } + + req := testcontainers.ContainerRequest{ + Image: httpUpstreamImage, + ExposedPorts: []string{httpUpstreamPort}, + Networks: []string{c.network.Name}, + NetworkAliases: map[string][]string{c.network.Name: {httpUpstreamAlias}}, + Cmd: []string{"nginx", "-c", "/conf/nginx.conf", "-g", "daemon off;"}, + HostConfigModifier: func(hc *container.HostConfig) { + hc.Binds = append(hc.Binds, workDir+":/conf:ro") + }, + WaitingFor: wait.ForListeningPort(httpUpstreamPort).WithStartupTimeout(60 * time.Second), + } + + ctr, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + cleanupHTTPUpstreamStart(ctr, workDir) + return nil, fmt.Errorf("start HTTP upstream container: %w", err) + } + host, err := ctr.Host(ctx) + if err != nil { + cleanupHTTPUpstreamStart(ctr, workDir) + return nil, fmt.Errorf("HTTP upstream container host: %w", err) + } + mapped, err := ctr.MappedPort(ctx, nat.Port(httpUpstreamPort)) + if err != nil { + cleanupHTTPUpstreamStart(ctr, workDir) + return nil, fmt.Errorf("HTTP upstream mapped port: %w", err) + } + + return &HTTPUpstream{ + container: ctr, + workDir: workDir, + directAddress: net.JoinHostPort(host, mapped.Port()), + URL: "http://" + httpUpstreamAlias + ":8080", + }, nil +} + +func cleanupHTTPUpstreamStart(ctr testcontainers.Container, workDir string) { + if ctr != nil { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + _ = ctr.Terminate(cleanupCtx) + cancel() + } + _ = os.RemoveAll(workDir) +} + +// Synchronize waits until the upstream has finalized all requests received +// before a direct barrier request. The fixture runs one nginx worker and writes +// access logs without buffering, so observing the barrier in the file proves +// any earlier proxied request has also been recorded. +func (u *HTTPUpstream) Synchronize(ctx context.Context) error { + marker := fmt.Sprintf("__e2e_upstream_barrier_%d__", u.syncSequence.Add(1)) + requestURL := (&url.URL{ + Scheme: "http", + Host: u.directAddress, + Path: "/" + marker, + }).String() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return fmt.Errorf("create HTTP upstream barrier request: %w", err) + } + transport := &http.Transport{} + defer transport.CloseIdleConnections() + client := &http.Client{Transport: transport, Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("send HTTP upstream barrier request: %w", err) + } + defer resp.Body.Close() + if _, err := io.Copy(io.Discard, resp.Body); err != nil { + return fmt.Errorf("read HTTP upstream barrier response: %w", err) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("HTTP upstream barrier returned status %d", resp.StatusCode) + } + + waitCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + ticker := time.NewTicker(25 * time.Millisecond) + defer ticker.Stop() + for { + count, err := u.RequestCount(waitCtx, marker) + if err != nil { + return err + } + switch { + case count == 1: + return nil + case count > 1: + return fmt.Errorf("HTTP upstream recorded barrier %q %d times", marker, count) + } + + select { + case <-waitCtx.Done(): + return fmt.Errorf("wait for HTTP upstream barrier: %w", waitCtx.Err()) + case <-ticker.C: + } + } +} + +// RequestCount returns the number of recorded request URIs containing marker. +func (u *HTTPUpstream) RequestCount(ctx context.Context, marker string) (int, error) { + if marker == "" { + return 0, fmt.Errorf("request marker is empty") + } + + reader, err := u.container.CopyFileFromContainer(ctx, httpUpstreamLogPath) + if err != nil { + return 0, fmt.Errorf("read HTTP upstream access log: %w", err) + } + defer reader.Close() + + count := 0 + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + var entry struct { + URI string `json:"uri"` + } + if err := json.Unmarshal(scanner.Bytes(), &entry); err != nil { + return 0, fmt.Errorf("decode HTTP upstream access log: %w", err) + } + if strings.Contains(entry.URI, marker) { + count++ + } + } + if err := scanner.Err(); err != nil { + return 0, fmt.Errorf("scan HTTP upstream access log: %w", err) + } + return count, nil +} + +// Logs returns the HTTP upstream container logs for failure diagnostics. +func (u *HTTPUpstream) Logs(ctx context.Context) string { + return containerLogs(ctx, u.container) +} + +// Terminate stops the HTTP upstream container and removes its work directory. +func (u *HTTPUpstream) Terminate(ctx context.Context) error { + var err error + if u.container != nil { + err = u.container.Terminate(ctx) + } + if u.workDir != "" { + _ = os.RemoveAll(u.workDir) + } + return err +} diff --git a/e2e/harness/proxy.go b/e2e/harness/proxy.go index ee458908b33..6a4251f5ec1 100644 --- a/e2e/harness/proxy.go +++ b/e2e/harness/proxy.go @@ -5,13 +5,20 @@ package harness import ( "bytes" "context" + "crypto/tls" + "crypto/x509" "encoding/json" "fmt" "io" + "net" + "net/http" + "net/url" "os" + "path/filepath" "time" "github.com/docker/docker/api/types/container" + "github.com/docker/go-connections/nat" "github.com/testcontainers/testcontainers-go" tcexec "github.com/testcontainers/testcontainers-go/exec" "github.com/testcontainers/testcontainers-go/wait" @@ -24,6 +31,7 @@ const ( // pulled as a published image; a bare tag is built under that name. defaultProxyImage = "netbird-reverse-proxy:e2e" proxyAlias = "proxy" + proxyHTTPSPort = "443/tcp" // AgentNetworkCluster is the proxy cluster the e2e provider bootstraps and // the proxy serves. It must equal the management's exposed domain @@ -34,8 +42,10 @@ const ( // Proxy is a running agent-network gateway (netbird proxy) container. type Proxy struct { - container testcontainers.Container - workDir string + container testcontainers.Container + workDir string + httpsAddress string + tlsRootCAs *x509.CertPool } // StartProxy builds the proxy image and runs it on the combined server's @@ -63,14 +73,27 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverride // MkdirTemp creates the dir 0700; widen it so the non-root proxy container // can traverse the bind-mounted cert dir on Linux CI runners. if err := os.Chmod(workDir, 0o755); err != nil { //nolint:gosec // throwaway e2e cert dir, must be traversable by the proxy container uid + _ = os.RemoveAll(workDir) return nil, fmt.Errorf("chmod proxy cert dir: %w", err) } if err := writeSelfSignedCert(workDir, []string{"*." + AgentNetworkCluster, AgentNetworkCluster}); err != nil { + _ = os.RemoveAll(workDir) return nil, err } + certPEM, err := os.ReadFile(filepath.Join(workDir, "tls.crt")) + if err != nil { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("read proxy certificate: %w", err) + } + tlsRootCAs := x509.NewCertPool() + if !tlsRootCAs.AppendCertsFromPEM(certPEM) { + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("parse proxy certificate") + } req := testcontainers.ContainerRequest{ Image: proxyImage, + ExposedPorts: []string{proxyHTTPSPort}, Networks: []string{c.network.Name}, NetworkAliases: map[string][]string{c.network.Name: {proxyAlias}}, Env: map[string]string{ @@ -112,10 +135,76 @@ func StartProxy(ctx context.Context, c *Combined, proxyToken string, envOverride Started: true, }) if err != nil { + _ = os.RemoveAll(workDir) return nil, fmt.Errorf("start proxy container: %w", err) } - return &Proxy{container: ctr, workDir: workDir}, nil + host, err := ctr.Host(ctx) + if err != nil { + _ = ctr.Terminate(ctx) + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("proxy container host: %w", err) + } + mapped, err := ctr.MappedPort(ctx, nat.Port(proxyHTTPSPort)) + if err != nil { + _ = ctr.Terminate(ctx) + _ = os.RemoveAll(workDir) + return nil, fmt.Errorf("proxy mapped HTTPS port: %w", err) + } + + return &Proxy{ + container: ctr, + workDir: workDir, + httpsAddress: net.JoinHostPort(host, mapped.Port()), + tlsRootCAs: tlsRootCAs, + }, nil +} + +// HTTPSGet reaches a public service through the proxy's host-mapped HTTPS +// listener while retaining domain as the request Host and TLS server name. +// Connections are not reused so callers can observe mapping removal without +// an existing connection outliving the route. +func (p *Proxy) HTTPSGet(ctx context.Context, domain, path string, headers http.Header) (int, string, error) { + requestURL := &url.URL{Scheme: "https", Host: domain, Path: path} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL.String(), nil) + if err != nil { + return 0, "", fmt.Errorf("create proxy request: %w", err) + } + req.Header = headers.Clone() + + dialer := &net.Dialer{Timeout: 5 * time.Second} + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, "tcp", p.httpsAddress) + }, + TLSClientConfig: &tls.Config{ + MinVersion: tls.VersionTLS12, + RootCAs: p.tlsRootCAs, + ServerName: domain, + }, + DisableKeepAlives: true, + TLSHandshakeTimeout: 5 * time.Second, + ResponseHeaderTimeout: 10 * time.Second, + } + defer transport.CloseIdleConnections() + + client := &http.Client{ + Transport: transport, + Timeout: 15 * time.Second, + CheckRedirect: func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return 0, "", fmt.Errorf("request public proxy service: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return 0, "", fmt.Errorf("read public proxy response: %w", err) + } + return resp.StatusCode, string(body), nil } // ProxyDebugClient is one per-account embedded client the proxy runs, as the diff --git a/e2e/reverseproxy/README.md b/e2e/reverseproxy/README.md new file mode 100644 index 00000000000..fdd5faca592 --- /dev/null +++ b/e2e/reverseproxy/README.md @@ -0,0 +1,36 @@ +# Reverse-proxy access E2E tests + +These tests run a real combined management server, reverse proxy, and HTTP +upstream in isolated Docker containers. Services are configured through the +management API, then requests go through the proxy's HTTPS listener. No external +identity provider, public DNS record, or API credential is needed. + +Run from the repository root with Docker and Go available: + +```bash +go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/... +``` + +The harness builds the combined server and proxy from the current checkout. +To test existing images, pass full image references: + +```bash +NB_E2E_COMBINED_IMAGE=local/netbird-combined:target-access \ +NB_E2E_PROXY_IMAGE=local/netbird-proxy:target-access \ +go test -tags e2e -timeout 25m -v ./e2e/reverseproxy/... +``` + +Rebuild locally tagged images after backend changes. Both components must +support target access actions; using an older management image does not test +the feature. + +The suite checks inherited service authentication, anonymous bypass, blocked +paths, longest-prefix precedence, credential stripping, live action updates, +service restrictions, deletion, and private-service bypass rejection. Upstream +request markers distinguish successful forwarding from proxy-generated +responses and verify that denied requests never reach the backend. + +The `Reverse Proxy E2E` workflow runs this package for relevant pull requests +and manual dispatches. It also remains part of the nightly `./e2e/...` suite. +Dashboard browser coverage lives in the dashboard repository: it creates and +edits services through the UI, then probes their real proxy behavior. diff --git a/e2e/reverseproxy/main_test.go b/e2e/reverseproxy/main_test.go new file mode 100644 index 00000000000..be211510d8a --- /dev/null +++ b/e2e/reverseproxy/main_test.go @@ -0,0 +1,42 @@ +//go:build e2e + +// Package reverseproxy contains container-based tests for public reverse-proxy +// services. A real combined management server is shared across package tests; +// each test owns and cleans up its proxy and service resources. +package reverseproxy + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/netbirdio/netbird/e2e/harness" +) + +var srv *harness.Combined + +func TestMain(m *testing.M) { + os.Exit(run(m)) +} + +func run(m *testing.M) int { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + + var err error + srv, err = harness.StartCombined(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err) + return 1 + } + defer func() { _ = srv.Terminate(context.Background()) }() + + if _, err := srv.Bootstrap(ctx); err != nil { + fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err) + return 1 + } + + return m.Run() +} diff --git a/e2e/reverseproxy/target_access_control_test.go b/e2e/reverseproxy/target_access_control_test.go new file mode 100644 index 00000000000..a74ec22bcc0 --- /dev/null +++ b/e2e/reverseproxy/target_access_control_test.go @@ -0,0 +1,336 @@ +//go:build e2e + +package reverseproxy + +import ( + "context" + "encoding/json" + "net" + "net/http" + "net/url" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/e2e/harness" + "github.com/netbirdio/netbird/shared/management/http/api" +) + +const ( + targetAccessDomain = "target-access." + harness.AgentNetworkCluster + targetAccessHeader = "X-E2E-Proxy-Key" + targetAccessSecret = "e2e-target-access-secret" + targetAccessEchoPrefix = "/e2e/proxy-echo/" + targetAccessApplicationAuth = "Bearer e2e-application-token" + targetAccessApplicationCookie = "application_session=e2e-visible" +) + +type targetAccessEcho struct { + Source string `json:"source"` + Path string `json:"path"` + RequestURI string `json:"request_uri"` + ConfiguredAuth string `json:"configured_auth"` + Authorization string `json:"authorization"` + Cookie string `json:"cookie"` + NetBirdUser string `json:"netbird_user"` + NetBirdGroups string `json:"netbird_groups"` +} + +func TestTargetAccessControl_PrivateServiceRejectsBypass(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second) + defer cancel() + + group, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-private-target-access"}) + require.NoError(t, err, "create private-service access group") + t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), group.Id) }) + + request := targetAccessServiceRequest(t, "http://upstream.invalid:80", + api.ServiceTargetAccessActionBypass, + api.ServiceTargetAccessActionBlock, + nil, + ) + private := true + accessGroups := []string{group.Id} + request.Domain = "private-target-access." + harness.AgentNetworkCluster + request.Name = "E2E private target access rejection" + request.Private = &private + request.AccessGroups = &accessGroups + + created, err := srv.API().ReverseProxyServices.Create(ctx, request) + if created != nil { + t.Cleanup(func() { _ = srv.API().ReverseProxyServices.Delete(context.Background(), created.Id) }) + } + assert.Nil(t, created) + assert.ErrorContains(t, err, "bypass access_action is not supported for private services") +} + +func TestTargetAccessControl_PublicServiceLifecycle(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Minute) + defer cancel() + + upstream, err := harness.StartHTTPUpstream(ctx, srv) + require.NoError(t, err, "start HTTP upstream") + t.Cleanup(func() { _ = upstream.Terminate(context.Background()) }) + + proxyToken, err := srv.CreateProxyTokenCLI(ctx, "e2e-target-access-proxy") + require.NoError(t, err, "mint global proxy token") + // Cluster direct-upstream targets require a private-capable proxy, while the + // service itself remains public through request.Private below. + px, err := harness.StartProxy(ctx, srv, proxyToken) + require.NoError(t, err, "start reverse proxy") + t.Cleanup(func() { _ = px.Terminate(context.Background()) }) + + request := targetAccessServiceRequest(t, upstream.URL, + api.ServiceTargetAccessActionBypass, + api.ServiceTargetAccessActionBlock, + nil, + ) + created, err := srv.API().ReverseProxyServices.Create(ctx, request) + require.NoError(t, err, "create service with per-target access actions") + removed := false + t.Cleanup(func() { + if !removed { + _ = srv.API().ReverseProxyServices.Delete(context.Background(), created.Id) + } + }) + + validAuth := targetAccessForwardedHeaders(targetAccessSecret) + wrongAuth := http.Header{targetAccessHeader: []string{"wrong-secret"}} + spoofedBypass := targetAccessForwardedHeaders("wrong-secret") + + // Wait for the service snapshot before issuing unique one-shot markers. A + // marker used while polling could reach an older mapping and make an updated + // block look as though it forwarded after convergence. + requireProxyStatus(t, ctx, px, targetAccessPath("", "warm-created"), nil, + http.StatusUnauthorized, "wait for created service") + requireProxyAttempt(t, ctx, px, upstream, "", "inherit-missing", nil, + http.StatusUnauthorized, 0, "inherit target without service credentials") + requireProxyAttempt(t, ctx, px, upstream, "", "inherit-wrong", wrongAuth, + http.StatusUnauthorized, 0, "inherit target with invalid service credentials") + requireProxyEcho(t, ctx, px, upstream, "", "inherit-valid", validAuth, + "inherit target with valid service credentials") + requireProxyEcho(t, ctx, px, upstream, "/public", "bypass-initial", spoofedBypass, + "bypass target without valid service credentials") + requireProxyAttempt(t, ctx, px, upstream, "/public/private", "nested-missing", nil, + http.StatusUnauthorized, 0, "nested inherit target overrides bypass parent") + requireProxyEcho(t, ctx, px, upstream, "/public/private", "nested-valid", validAuth, + "nested inherit target accepts valid service credentials") + requireProxyAttempt(t, ctx, px, upstream, "/blocked", "block-initial", validAuth, + http.StatusForbidden, 0, "block target even with valid service credentials") + + request = targetAccessServiceRequest(t, upstream.URL, + api.ServiceTargetAccessActionBlock, + api.ServiceTargetAccessActionBypass, + nil, + ) + _, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request) + require.NoError(t, err, "swap target access actions") + requireProxyStatus(t, ctx, px, targetAccessPath("/public", "warm-actions-updated"), validAuth, + http.StatusForbidden, "wait for target action update") + requireProxyAttempt(t, ctx, px, upstream, "/public", "public-updated-block", validAuth, + http.StatusForbidden, 0, "updated public target is blocked") + requireProxyEcho(t, ctx, px, upstream, "/blocked", "blocked-updated-bypass", spoofedBypass, + "updated blocked target bypasses authentication") + + blockedCIDRs := []string{"0.0.0.0/0", "::/0"} + request = targetAccessServiceRequest(t, upstream.URL, + api.ServiceTargetAccessActionBlock, + api.ServiceTargetAccessActionBypass, + &api.AccessRestrictions{BlockedCidrs: &blockedCIDRs}, + ) + _, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request) + require.NoError(t, err, "add connection restriction") + requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-restricted"), nil, + http.StatusForbidden, "wait for connection restriction") + requireProxyAttempt(t, ctx, px, upstream, "/blocked", "blocked-restricted", spoofedBypass, + http.StatusForbidden, 0, "connection restriction runs before bypass") + + // Clear the restriction and prove the route is live immediately before + // deleting it, so a stale 403 cannot make the removal check pass. + request = targetAccessServiceRequest(t, upstream.URL, + api.ServiceTargetAccessActionBlock, + api.ServiceTargetAccessActionBypass, + &api.AccessRestrictions{}, + ) + _, err = srv.API().ReverseProxyServices.Update(ctx, created.Id, request) + require.NoError(t, err, "clear connection restriction") + requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-restriction-cleared"), nil, + http.StatusOK, "wait for cleared connection restriction") + requireProxyEcho(t, ctx, px, upstream, "/blocked", "blocked-restriction-cleared", spoofedBypass, + "bypass route is live before removal") + + require.NoError(t, srv.API().ReverseProxyServices.Delete(ctx, created.Id), "delete service") + removed = true + requireProxyStatus(t, ctx, px, targetAccessPath("/blocked", "warm-deleted"), nil, + http.StatusNotFound, "wait for service deletion") + requireProxyAttempt(t, ctx, px, upstream, "/blocked", "blocked-deleted", nil, + http.StatusNotFound, 0, "deleted service is removed from the proxy") +} + +func targetAccessServiceRequest( + t *testing.T, + upstreamURL string, + publicAction, blockedAction api.ServiceTargetAccessAction, + restrictions *api.AccessRestrictions, +) api.ServiceRequest { + t.Helper() + + u, err := url.Parse(upstreamURL) + require.NoError(t, err) + host, portText, err := net.SplitHostPort(u.Host) + require.NoError(t, err) + port, err := strconv.Atoi(portText) + require.NoError(t, err) + + rootPath := "/" + publicPath := "/public" + nestedPath := "/public/private" + blockedPath := "/blocked" + directUpstream := true + mode := api.ServiceRequestModeHttp + private := false + inherit := api.ServiceTargetAccessActionInherit + + target := func(path *string, action *api.ServiceTargetAccessAction) api.ServiceTarget { + return api.ServiceTarget{ + AccessAction: action, + Enabled: true, + Host: &host, + Options: &api.ServiceTargetOptions{DirectUpstream: &directUpstream}, + Path: path, + Port: port, + Protocol: api.ServiceTargetProtocolHttp, + TargetId: harness.AgentNetworkCluster, + TargetType: api.ServiceTargetTargetTypeCluster, + } + } + targets := []api.ServiceTarget{ + target(&rootPath, &inherit), + target(&publicPath, &publicAction), + target(&nestedPath, &inherit), + target(&blockedPath, &blockedAction), + } + headerAuths := []api.HeaderAuthConfig{{ + Enabled: true, + Header: targetAccessHeader, + Value: targetAccessSecret, + }} + + return api.ServiceRequest{ + AccessRestrictions: restrictions, + Auth: &api.ServiceAuthConfig{HeaderAuths: &headerAuths}, + Domain: targetAccessDomain, + Enabled: true, + Mode: &mode, + Name: "E2E target access control", + Private: &private, + Targets: &targets, + } +} + +func targetAccessForwardedHeaders(authValue string) http.Header { + return http.Header{ + targetAccessHeader: []string{authValue}, + "Authorization": []string{targetAccessApplicationAuth}, + "Cookie": []string{"nb_session=e2e-proxy-session; " + targetAccessApplicationCookie}, + "X-NetBird-User": []string{"spoofed@example.com"}, + "X-NetBird-Groups": []string{"administrators"}, + } +} + +func targetAccessPath(targetPrefix, marker string) string { + return targetPrefix + targetAccessEchoPrefix + marker +} + +func requireProxyAttempt( + t *testing.T, + ctx context.Context, + px *harness.Proxy, + upstream *harness.HTTPUpstream, + targetPrefix, marker string, + headers http.Header, + wantStatus, wantUpstreamRequests int, + description string, +) string { + t.Helper() + + path := targetAccessPath(targetPrefix, marker) + status, body, requestErr := px.HTTPSGet(ctx, targetAccessDomain, path, headers) + syncErr := upstream.Synchronize(ctx) + count, countErr := upstream.RequestCount(ctx, marker) + if requestErr != nil || syncErr != nil || countErr != nil || status != wantStatus || count != wantUpstreamRequests { + t.Fatalf("%s: wanted status=%d upstream_requests=%d, got status=%d upstream_requests=%d body=%q request_error=%v sync_error=%v count_error=%v\n=== proxy logs ===\n%s\n=== upstream logs ===\n%s", + description, wantStatus, wantUpstreamRequests, status, count, body, requestErr, syncErr, countErr, + px.Logs(context.Background()), upstream.Logs(context.Background())) + } + return body +} + +func requireProxyEcho( + t *testing.T, + ctx context.Context, + px *harness.Proxy, + upstream *harness.HTTPUpstream, + targetPrefix, marker string, + headers http.Header, + description string, +) { + t.Helper() + + body := requireProxyAttempt(t, ctx, px, upstream, targetPrefix, marker, headers, + http.StatusOK, 1, description) + var echo targetAccessEcho + require.NoError(t, json.Unmarshal([]byte(body), &echo), "decode HTTP upstream observation") + wantPath := targetAccessEchoPrefix + marker + assert.Equal(t, "netbird-e2e-http-upstream", echo.Source, "response must come from the test upstream") + assert.Equal(t, wantPath, echo.Path, "upstream must receive the routed subpath") + assert.Equal(t, wantPath, echo.RequestURI, "upstream request URI must not contain the matched target prefix") + assert.Empty(t, echo.ConfiguredAuth, "configured proxy authentication header must be stripped") + assert.Equal(t, targetAccessApplicationAuth, echo.Authorization, "application authorization must be preserved") + assert.NotContains(t, echo.Cookie, "nb_session=", "proxy session cookie must be stripped") + assert.Contains(t, echo.Cookie, targetAccessApplicationCookie, "application cookie must be preserved") + assert.Empty(t, echo.NetBirdUser, "client-supplied NetBird identity must be stripped") + assert.Empty(t, echo.NetBirdGroups, "client-supplied NetBird groups must be stripped") +} + +func requireProxyStatus( + t *testing.T, + ctx context.Context, + px *harness.Proxy, + path string, + headers http.Header, + want int, + description string, +) { + t.Helper() + + deadline := time.Now().Add(90 * time.Second) + var lastCode int + var lastBody string + var lastErr error + for time.Now().Before(deadline) { + lastCode, lastBody, lastErr = px.HTTPSGet(ctx, targetAccessDomain, path, headers) + if lastErr == nil && lastCode == want { + return + } + if !waitBeforeRetry(ctx, time.Second) { + break + } + } + t.Fatalf("%s: wanted status %d, last status=%d body=%q error=%v\n=== proxy logs ===\n%s", + description, want, lastCode, lastBody, lastErr, px.Logs(context.Background())) +} + +func waitBeforeRetry(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index 900b7759f7e..7aff73d00fd 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -338,6 +338,9 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * } return m.store.ExecuteInTransaction(ctx, func(transaction store.Store) error { + if err := validateTargetAccessControl(svc); err != nil { + return err + } if err := m.validateServiceDomain(ctx, transaction, accountID, svc, svc.ProxyCluster); err != nil { return err } @@ -707,6 +710,12 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St } m.preserveExistingAuthSecrets(service, existingService) + if err := preserveTargetAccessActions(service, existingService); err != nil { + return err + } + if err := validateTargetAccessControl(service); err != nil { + return err + } if err := validateHeaderAuthValues(service.Auth.HeaderAuths); err != nil { return err } @@ -744,6 +753,68 @@ func (m *Manager) validateServiceDomain(ctx context.Context, tx store.Store, acc return m.clusterDeriver.ValidateServiceDomain(ctx, tx, accountID, svc.Domain, cluster) } +func validateTargetAccessControl(svc *service.Service) error { + if !svc.HasTargetAccessControl() { + return nil + } + // Legacy requests may acquire an existing action during the update merge. + if err := svc.Validate(); err != nil { + return status.Errorf(status.InvalidArgument, "%s", err) + } + return nil +} + +func preserveTargetAccessActions(updated, existing *service.Service) error { + if !existing.HasTargetAccessControl() { + return nil + } + if slices.ContainsFunc(updated.Targets, func(target *service.Target) bool { + return target == nil + }) { + return status.Errorf(status.InvalidArgument, "target must not be nil") + } + if len(updated.Targets) > 0 && !slices.ContainsFunc(updated.Targets, func(target *service.Target) bool { + return !target.AccessActionProvided + }) { + return nil + } + byPath := make(map[string]*service.Target, len(existing.Targets)) + for _, target := range existing.Targets { + location := effectiveTargetPath(target) + if _, duplicate := byPath[location]; duplicate { + return status.Errorf(status.InvalidArgument, + "cannot preserve target access control for duplicate location %q", location) + } + byPath[location] = target + } + actionsProvided := false + for _, target := range updated.Targets { + actionsProvided = actionsProvided || target.AccessActionProvided + location := effectiveTargetPath(target) + if previous := byPath[location]; previous != nil && !target.AccessActionProvided { + target.AccessAction = previous.AccessAction + } + delete(byPath, location) + } + if actionsProvided { + return nil + } + for location, target := range byPath { + if target.AccessAction != "" && target.AccessAction != service.TargetAccessActionInherit { + return status.Errorf(status.InvalidArgument, + "include access_action when removing or changing controlled target location %q", location) + } + } + return nil +} + +func effectiveTargetPath(target *service.Target) string { + if target.Path == nil || *target.Path == "" { + return "/" + } + return *target.Path +} + // validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters. // It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode. // Returns an error if validation fails, otherwise returns nil. diff --git a/management/internals/modules/reverseproxy/service/manager/target_access_test.go b/management/internals/modules/reverseproxy/service/manager/target_access_test.go new file mode 100644 index 00000000000..e87d0c64c53 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/manager/target_access_test.go @@ -0,0 +1,196 @@ +package manager + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" +) + +func TestCreateServiceTargetAccessWithoutActiveProxies(t *testing.T) { + for _, action := range []rpservice.TargetAccessAction{ + rpservice.TargetAccessActionBlock, + rpservice.TargetAccessActionBypass, + } { + t.Run(string(action), func(t *testing.T) { + ctx := context.Background() + mgr, testStore, _ := setupL4Test(t, nil) + svc := &rpservice.Service{ + Name: "target access", Domain: "target.test.netbird.io", Mode: rpservice.ModeHTTP, + Enabled: true, + Targets: []*rpservice.Target{{ + AccountID: testAccountID, TargetId: testPeerID, + TargetType: rpservice.TargetTypePeer, Protocol: "http", Port: 8080, + Enabled: true, AccessAction: action, + }}, + } + created, err := mgr.CreateService(ctx, testAccountID, testUserID, svc) + require.NoError(t, err) + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, created.ID) + require.NoError(t, err) + require.Len(t, stored.Targets, 1) + assert.Equal(t, action, stored.Targets[0].AccessAction, + "accepted target actions must survive persistence") + }) + } +} + +func TestUpdateServicePreservesOmittedTargetAccessAction(t *testing.T) { + ctx := context.Background() + mgr, testStore, _ := setupL4Test(t, nil) + existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0) + existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock + require.NoError(t, testStore.UpdateService(ctx, existing)) + + updated := existing.Copy() + updated.Name = "renamed by an older client" + updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit + updated.Targets[0].AccessActionProvided = false + rootPath := "/" + updated.Targets[0].Path = &rootPath + _, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + assert.Equal(t, updated.Name, stored.Name, "legacy updates must still apply unrelated edits") + assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction, + "an omitted action must preserve the stored block across equivalent root locations") + + emptyPath := "" + updated.Targets[0].Path = &emptyPath + updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction, + "an older client's empty root location must preserve the stored block") + + updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit + updated.Targets[0].AccessActionProvided = true + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + assert.Equal(t, rpservice.TargetAccessActionInherit, stored.Targets[0].AccessAction, + "explicit inherit must clear the stored target block") +} + +func TestUpdateServiceActivatesPreservedTargetAccess(t *testing.T) { + ctx := context.Background() + mgr, testStore, _ := setupL4Test(t, nil) + existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0) + existing.Enabled = false + existing.Targets[0].AccessAction = rpservice.TargetAccessActionBlock + require.NoError(t, testStore.UpdateService(ctx, existing)) + + updated := existing.Copy() + updated.Enabled = true + updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit + updated.Targets[0].AccessActionProvided = false + _, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + assert.True(t, stored.Enabled, "activating target access must not require an active proxy") + assert.Equal(t, rpservice.TargetAccessActionBlock, stored.Targets[0].AccessAction, + "activation must retain an omitted stored access action") +} + +func TestUpdateServiceValidatesPreservedTargetAccess(t *testing.T) { + ctx := context.Background() + mgr, testStore, _ := setupL4Test(t, nil) + existing := seedService(t, testStore, "public", "http", "target.test.netbird.io", testCluster, 0) + existing.Targets[0].AccessAction = rpservice.TargetAccessActionBypass + require.NoError(t, testStore.UpdateService(ctx, existing)) + + updated := existing.Copy() + updated.Private = true + updated.AccessGroups = []string{testGroupID} + updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit + updated.Targets[0].AccessActionProvided = false + _, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.ErrorContains(t, err, "bypass access_action is not supported for private services") + + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + assert.False(t, stored.Private, "an invalid preserved bypass must prevent the private-service update") + assert.Equal(t, rpservice.TargetAccessActionBypass, stored.Targets[0].AccessAction, + "a rejected update must retain the existing target action") +} + +func TestUpdateServiceLegacyClientCannotRemoveControlledPath(t *testing.T) { + for _, operation := range []string{"rename", "delete"} { + t.Run(operation, func(t *testing.T) { + ctx := context.Background() + mgr, testStore, _ := setupL4Test(t, nil) + existing := seedService(t, testStore, "protected", "http", "target.test.netbird.io", testCluster, 0) + blockedPath := "/blocked" + existing.Targets = append(existing.Targets, &rpservice.Target{ + AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, + Protocol: "http", Port: 8080, Enabled: true, Path: &blockedPath, + AccessAction: rpservice.TargetAccessActionBlock, + }) + require.NoError(t, testStore.UpdateService(ctx, existing)) + + updated := existing.Copy() + updated.Targets[1].AccessAction = rpservice.TargetAccessActionInherit + if operation == "rename" { + renamedPath := "/renamed" + updated.Targets[1].Path = &renamedPath + } else { + updated.Targets = updated.Targets[:1] + } + _, err := mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.Error(t, err) + stored, err := testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + require.Len(t, stored.Targets, 2, "a legacy structural edit must retain the controlled target") + var blocked *rpservice.Target + for _, target := range stored.Targets { + if effectiveTargetPath(target) == blockedPath { + blocked = target + } + } + require.NotNil(t, blocked, "the original controlled location must still exist") + assert.Equal(t, rpservice.TargetAccessActionBlock, blocked.AccessAction, + "a rejected legacy edit must not erase the block action") + + updated.Targets[0].AccessAction = rpservice.TargetAccessActionInherit + updated.Targets[0].AccessActionProvided = true + _, err = mgr.UpdateService(ctx, testAccountID, testUserID, updated) + require.NoError(t, err) + stored, err = testStore.GetServiceByID(ctx, store.LockingStrengthNone, testAccountID, existing.ID) + require.NoError(t, err) + assert.False(t, stored.HasTargetAccessControl(), + "a feature-aware client must be able to intentionally remove or rename the controlled path") + }) + } +} + +func TestPreserveTargetAccessActionsExplicitUpdate(t *testing.T) { + existing := &rpservice.Service{Targets: []*rpservice.Target{ + {Enabled: true, AccessAction: rpservice.TargetAccessActionBlock}, + {Enabled: false, AccessAction: rpservice.TargetAccessActionInherit}, + }} + updated := &rpservice.Service{Targets: []*rpservice.Target{ + {Enabled: true, AccessAction: rpservice.TargetAccessActionBypass, AccessActionProvided: true}, + {Enabled: false, AccessAction: rpservice.TargetAccessActionInherit, AccessActionProvided: true}, + }} + require.NoError(t, preserveTargetAccessActions(updated, existing)) + assert.Equal(t, rpservice.TargetAccessActionBypass, updated.Targets[0].AccessAction, + "explicit actions must not need an ambiguous location-based legacy merge") + + updated.Targets[0].AccessActionProvided = false + assert.Error(t, preserveTargetAccessActions(updated, existing), + "an omitted action must be rejected when its stored location is ambiguous") + + existing.Targets = existing.Targets[:1] + updated.Targets = nil + assert.Error(t, preserveTargetAccessActions(updated, existing), + "an empty target list must not count as an explicit removal of the stored block") +} diff --git a/management/internals/modules/reverseproxy/service/service.go b/management/internals/modules/reverseproxy/service/service.go index ef92fbbf498..de47f123975 100644 --- a/management/internals/modules/reverseproxy/service/service.go +++ b/management/internals/modules/reverseproxy/service/service.go @@ -116,18 +116,20 @@ type MiddlewareConfig struct { } type Target struct { - ID uint `gorm:"primaryKey" json:"-"` - AccountID string `gorm:"index:idx_target_account;not null" json:"-"` - ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"` - Path *string `json:"path,omitempty"` - Host string `json:"host"` - Port uint16 `gorm:"index:idx_target_port" json:"port"` - Protocol string `gorm:"index:idx_target_protocol" json:"protocol"` - TargetId string `gorm:"index:idx_target_id" json:"target_id"` - TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"` - Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"` - Options TargetOptions `gorm:"embedded" json:"options"` - ProxyProtocol bool `json:"proxy_protocol"` + ID uint `gorm:"primaryKey" json:"-"` + AccountID string `gorm:"index:idx_target_account;not null" json:"-"` + ServiceID string `gorm:"index:idx_service_targets;not null" json:"-"` + Path *string `json:"path,omitempty"` + Host string `json:"host"` + Port uint16 `gorm:"index:idx_target_port" json:"port"` + Protocol string `gorm:"index:idx_target_protocol" json:"protocol"` + TargetId string `gorm:"index:idx_target_id" json:"target_id"` + TargetType TargetType `gorm:"index:idx_target_type" json:"target_type"` + Enabled bool `gorm:"index:idx_target_enabled" json:"enabled"` + AccessAction TargetAccessAction `gorm:"type:varchar(16);not null;default:'inherit'" json:"access_action"` + AccessActionProvided bool `gorm:"-" json:"-"` + Options TargetOptions `gorm:"embedded" json:"options"` + ProxyProtocol bool `json:"proxy_protocol"` } type PasswordAuthConfig struct { @@ -309,14 +311,16 @@ func (s *Service) ToAPIResponse() *api.Service { // Convert internal targets to API targets apiTargets := make([]api.ServiceTarget, 0, len(s.Targets)) for _, target := range s.Targets { + accessAction := api.ServiceTargetAccessAction(target.effectiveAccessAction()) st := api.ServiceTarget{ - Path: target.Path, - Host: &target.Host, - Port: int(target.Port), - Protocol: api.ServiceTargetProtocol(target.Protocol), - TargetId: target.TargetId, - TargetType: api.ServiceTargetTargetType(target.TargetType), - Enabled: target.Enabled && !s.Terminated, + Path: target.Path, + Host: &target.Host, + Port: int(target.Port), + Protocol: api.ServiceTargetProtocol(target.Protocol), + TargetId: target.TargetId, + TargetType: api.ServiceTargetTargetType(target.TargetType), + Enabled: target.Enabled && !s.Terminated, + AccessAction: &accessAction, } opts := targetOptionsToAPI(target.Options) if opts == nil { @@ -463,8 +467,9 @@ func (s *Service) buildPathMappings() []*proto.PathMapping { } pm := &proto.PathMapping{ - Path: path, - Target: targetURL.String(), + Path: path, + Target: targetURL.String(), + AccessAction: targetAccessActionToProto(target.effectiveAccessAction()), } pm.Options = targetOptionsToProto(target.Options) pathMappings = append(pathMappings, pm) @@ -727,13 +732,18 @@ func targetsFromAPI(accountID string, apiTargetsPtr *[]api.ServiceTarget) ([]*Ta targets := make([]*Target, 0, len(apiTargets)) for i, apiTarget := range apiTargets { target := &Target{ - AccountID: accountID, - Path: apiTarget.Path, - Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer - Protocol: string(apiTarget.Protocol), - TargetId: apiTarget.TargetId, - TargetType: TargetType(apiTarget.TargetType), - Enabled: apiTarget.Enabled, + AccountID: accountID, + Path: apiTarget.Path, + Port: uint16(apiTarget.Port), //nolint:gosec // validated by API layer + Protocol: string(apiTarget.Protocol), + TargetId: apiTarget.TargetId, + TargetType: TargetType(apiTarget.TargetType), + Enabled: apiTarget.Enabled, + AccessAction: TargetAccessActionInherit, + } + if apiTarget.AccessAction != nil { + target.AccessAction = TargetAccessAction(*apiTarget.AccessAction) + target.AccessActionProvided = true } if apiTarget.Host != nil { target.Host = *apiTarget.Host @@ -953,41 +963,70 @@ func (s *Service) validateTLSMode() error { } func (s *Service) validateHTTPTargets() error { + strictPaths := false for i, target := range s.Targets { - switch target.TargetType { - case TargetTypePeer, TargetTypeHost, TargetTypeDomain: - // Host is normally overwritten by replaceHostByLookup with the - // resolved peer IP / resource address; operator-supplied values - // are honored only when DirectUpstream is set. Validate the - // override here so misconfigured hosts fail fast at API time. - if err := validateDirectUpstreamHost(i, target); err != nil { - return err - } - case TargetTypeSubnet: - if err := validateSubnetTarget(i, target); err != nil { - return err - } - case TargetTypeCluster: - if err := validateClusterTarget(i, target); err != nil { - return err - } - default: - return fmt.Errorf("target %d has invalid target_type %q", i, target.TargetType) + if err := s.validateHTTPTarget(i, target); err != nil { + return err } - if target.TargetId == "" { - return fmt.Errorf("target %d has empty target_id", i) + if target.Enabled && target.effectiveAccessAction() != TargetAccessActionInherit { + strictPaths = true } - if target.ProxyProtocol { - return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", i) + } + if !strictPaths { + return nil + } + + paths := make(map[string]int, len(s.Targets)) + for i, target := range s.Targets { + if !target.Enabled { + continue } - if err := validateTargetOptions(i, &target.Options); err != nil { - return err + normalizedPath := normalizedTargetPath(target.Path) + if previous, ok := paths[normalizedPath]; ok { + return fmt.Errorf("targets %d and %d have duplicate path %q", previous, i, normalizedPath) } + paths[normalizedPath] = i } return nil } +func (s *Service) validateHTTPTarget(idx int, target *Target) error { + if target == nil { + return fmt.Errorf("target %d is nil", idx) + } + if err := validateTargetAccessAction(idx, target, s.Private); err != nil { + return err + } + switch target.TargetType { + case TargetTypePeer, TargetTypeHost, TargetTypeDomain: + // Host is normally overwritten by replaceHostByLookup with the + // resolved peer IP / resource address; operator-supplied values + // are honored only when DirectUpstream is set. Validate the + // override here so misconfigured hosts fail fast at API time. + if err := validateDirectUpstreamHost(idx, target); err != nil { + return err + } + case TargetTypeSubnet: + if err := validateSubnetTarget(idx, target); err != nil { + return err + } + case TargetTypeCluster: + if err := validateClusterTarget(idx, target); err != nil { + return err + } + default: + return fmt.Errorf("target %d has invalid target_type %q", idx, target.TargetType) + } + if target.TargetId == "" { + return fmt.Errorf("target %d has empty target_id", idx) + } + if target.ProxyProtocol { + return fmt.Errorf("target %d: proxy_protocol is not supported for HTTP services", idx) + } + return validateTargetOptions(idx, &target.Options) +} + func validateSubnetTarget(idx int, target *Target) error { host := strings.TrimSpace(target.Host) if host == "" { @@ -1063,10 +1102,20 @@ func validateDirectUpstreamHost(idx int, target *Target) error { } func (s *Service) validateL4Target(target *Target) error { + if target == nil { + return errors.New("target 0 is nil") + } // L4 services have a single target; per-target disable is meaningless // (use the service-level Enabled flag instead). Force it on so that // buildPathMappings always includes the target in the proto. target.Enabled = true + action, err := validatedTargetAccessAction(0, target) + if err != nil { + return err + } + if action != TargetAccessActionInherit { + return errors.New("access_action is only supported for HTTP services") + } if target.TargetId == "" { return errors.New("target_id is required for L4 services") diff --git a/management/internals/modules/reverseproxy/service/service_test.go b/management/internals/modules/reverseproxy/service/service_test.go index 84343bcdd73..756a5a91505 100644 --- a/management/internals/modules/reverseproxy/service/service_test.go +++ b/management/internals/modules/reverseproxy/service/service_test.go @@ -62,9 +62,11 @@ func TestValidate_InvalidTargetType(t *testing.T) { func TestValidate_ResourceTarget(t *testing.T) { rp := validProxy() + resourcePath := "/resource" rp.Targets = append(rp.Targets, &Target{ TargetId: "resource-1", TargetType: TargetTypeHost, + Path: &resourcePath, Host: "example.org", Port: 443, Protocol: "https", @@ -73,6 +75,232 @@ func TestValidate_ResourceTarget(t *testing.T) { require.NoError(t, rp.Validate()) } +func TestValidateTargetAccessAction(t *testing.T) { + t.Run("valid actions", func(t *testing.T) { + for _, action := range []TargetAccessAction{ + TargetAccessActionInherit, + TargetAccessActionBypass, + TargetAccessActionBlock, + } { + rp := validProxy() + rp.Targets[0].AccessAction = action + require.NoError(t, rp.Validate(), "action %q should be valid", action) + } + }) + + t.Run("unknown action", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].AccessAction = "allow" + assert.ErrorContains(t, rp.Validate(), `unknown access_action "allow"`) + }) + + t.Run("explicit empty action", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].AccessActionProvided = true + assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`) + }) + + t.Run("private bypass", func(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.AccessGroups = []string{"group-1"} + rp.Targets[0].AccessAction = TargetAccessActionBypass + assert.ErrorContains(t, rp.Validate(), "not supported for private services") + }) + + t.Run("Agent Network bypass", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].AccessAction = TargetAccessActionBypass + rp.Targets[0].Options.AgentNetwork = true + assert.ErrorContains(t, rp.Validate(), "not supported for Agent Network targets") + }) + + t.Run("block remains available to private services", func(t *testing.T) { + rp := validProxy() + rp.Private = true + rp.AccessGroups = []string{"group-1"} + rp.Targets[0].AccessAction = TargetAccessActionBlock + require.NoError(t, rp.Validate()) + }) + + t.Run("non HTTP service", func(t *testing.T) { + rp := validProxy() + rp.Mode = ModeTCP + rp.ListenPort = 8080 + rp.Targets[0].Protocol = TargetProtoTCP + rp.Targets[0].AccessAction = TargetAccessActionBlock + assert.ErrorContains(t, rp.Validate(), "only supported for HTTP services") + }) + + t.Run("nil non HTTP target", func(t *testing.T) { + rp := validProxy() + rp.Mode = ModeTCP + rp.ListenPort = 8080 + rp.Targets[0] = nil + assert.ErrorContains(t, rp.Validate(), "target 0 is nil") + }) +} + +func TestValidateTargetAccessActionPath(t *testing.T) { + for _, value := range []string{"api", "/api/../admin", "/api/./admin", "/api//admin", "/api%2Fadmin", `/api\admin`, "/api?x=1", "/api#fragment", "/api;admin", "/api/\x00"} { + t.Run(fmt.Sprintf("path_%q", value), func(t *testing.T) { + rp := validProxy() + rp.Targets[0].Path = &value + rp.Targets[0].AccessAction = TargetAccessActionBypass + assert.ErrorContains(t, rp.Validate(), "access_action path") + }) + } + + t.Run("nil path is canonical root", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].AccessAction = TargetAccessActionBlock + require.NoError(t, rp.Validate()) + }) + + t.Run("empty path is legacy root", func(t *testing.T) { + value := "" + rp := validProxy() + rp.Targets[0].Path = &value + rp.Targets[0].AccessAction = TargetAccessActionBlock + require.NoError(t, rp.Validate()) + }) + + t.Run("trailing slash is a distinct valid prefix", func(t *testing.T) { + value := "/api/" + rp := validProxy() + rp.Targets[0].Path = &value + rp.Targets[0].AccessAction = TargetAccessActionBypass + require.NoError(t, rp.Validate()) + }) +} + +func TestValidateDuplicateEnabledTargetPaths(t *testing.T) { + emptyPath := "" + rootPath := "/" + rootAliases := []struct { + name string + path *string + }{ + {name: "nil", path: nil}, + {name: "empty", path: &emptyPath}, + {name: "slash", path: &rootPath}, + } + + for _, alias := range rootAliases { + t.Run("legacy all-inherit "+alias.name, func(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "peer-2", + TargetType: TargetTypePeer, + Path: alias.path, + Host: "10.0.0.2", + Port: 443, + Protocol: TargetProtoHTTPS, + Enabled: true, + }) + require.NoError(t, rp.Validate(), "legacy all-inherit path and protocol twins must remain valid") + }) + + t.Run("access-controlled "+alias.name, func(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "peer-2", + TargetType: TargetTypePeer, + Path: alias.path, + Host: "10.0.0.2", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: true, + AccessAction: TargetAccessActionBlock, + }) + assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`) + }) + } + + t.Run("access action elsewhere enables strict paths", func(t *testing.T) { + publicPath := "/public" + rp := validProxy() + rp.Targets = append(rp.Targets, + &Target{ + TargetId: "peer-2", + TargetType: TargetTypePeer, + Host: "10.0.0.2", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: true, + }, + &Target{ + TargetId: "peer-3", + TargetType: TargetTypePeer, + Path: &publicPath, + Host: "10.0.0.3", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: true, + AccessAction: TargetAccessActionBypass, + }, + ) + assert.ErrorContains(t, rp.Validate(), `duplicate path "/"`) + }) + + t.Run("disabled action preserves legacy validation", func(t *testing.T) { + publicPath := "/public" + rp := validProxy() + rp.Targets = append(rp.Targets, + &Target{ + TargetId: "peer-2", + TargetType: TargetTypePeer, + Host: "10.0.0.2", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: true, + }, + &Target{ + TargetId: "peer-3", + TargetType: TargetTypePeer, + Path: &publicPath, + Host: "10.0.0.3", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: false, + AccessAction: TargetAccessActionBlock, + }, + ) + require.NoError(t, rp.Validate()) + }) + + t.Run("disabled target does not collide", func(t *testing.T) { + rp := validProxy() + rp.Targets = append(rp.Targets, &Target{ + TargetId: "peer-2", + TargetType: TargetTypePeer, + Host: "10.0.0.2", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: false, + }) + require.NoError(t, rp.Validate()) + }) + + t.Run("trailing slash remains distinct", func(t *testing.T) { + apiPath := "/api" + apiSlashPath := "/api/" + rp := validProxy() + rp.Targets[0].Path = &apiPath + rp.Targets = append(rp.Targets, &Target{ + TargetId: "peer-2", + TargetType: TargetTypePeer, + Path: &apiSlashPath, + Host: "10.0.0.2", + Port: 80, + Protocol: TargetProtoHTTP, + Enabled: true, + AccessAction: TargetAccessActionBlock, + }) + require.NoError(t, rp.Validate()) + }) +} + func TestValidate_MultipleTargetsOneInvalid(t *testing.T) { rp := validProxy() rp.Targets = append(rp.Targets, &Target{ @@ -308,6 +536,90 @@ func TestToProtoMapping_TargetOptions(t *testing.T) { assert.Equal(t, int64(30), opts.RequestTimeout.Seconds) } +func TestTargetAccessActionConversions(t *testing.T) { + t.Run("API presence", func(t *testing.T) { + bypass := api.ServiceTargetAccessActionBypass + empty := api.ServiceTargetAccessAction("") + apiTargets := []api.ServiceTarget{ + { + TargetId: "peer-1", + TargetType: api.ServiceTargetTargetTypePeer, + Protocol: api.ServiceTargetProtocolHttp, + Port: 80, + Enabled: true, + }, + { + TargetId: "peer-2", + TargetType: api.ServiceTargetTargetTypePeer, + Protocol: api.ServiceTargetProtocolHttp, + Port: 80, + Enabled: true, + AccessAction: &bypass, + }, + { + TargetId: "peer-3", + TargetType: api.ServiceTargetTargetTypePeer, + Protocol: api.ServiceTargetProtocolHttp, + Port: 80, + Enabled: true, + AccessAction: &empty, + }, + } + + targets, err := targetsFromAPI("account-1", &apiTargets) + require.NoError(t, err) + require.Len(t, targets, 3) + assert.Equal(t, TargetAccessActionInherit, targets[0].AccessAction) + assert.False(t, targets[0].AccessActionProvided) + assert.Equal(t, TargetAccessActionBypass, targets[1].AccessAction) + assert.True(t, targets[1].AccessActionProvided) + assert.Empty(t, targets[2].AccessAction) + assert.True(t, targets[2].AccessActionProvided) + + rp := &Service{Name: "test", Domain: "example.com", Targets: []*Target{targets[2]}} + assert.ErrorContains(t, rp.Validate(), `unknown access_action ""`) + }) + + t.Run("API response", func(t *testing.T) { + rp := validProxy() + rp.Targets[0].AccessAction = TargetAccessActionBlock + response := rp.ToAPIResponse() + require.Len(t, response.Targets, 1) + require.NotNil(t, response.Targets[0].AccessAction) + assert.Equal(t, api.ServiceTargetAccessActionBlock, *response.Targets[0].AccessAction) + }) + + t.Run("proto mapping", func(t *testing.T) { + root := "/" + bypassPath := "/bypass" + blockPath := "/block" + rp := &Service{ + ID: "svc-1", + AccountID: "acc-1", + Domain: "example.com", + Targets: []*Target{ + {Path: &root, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true}, + {Path: &bypassPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBypass}, + {Path: &blockPath, Host: "10.0.0.1", Port: 80, Protocol: TargetProtoHTTP, Enabled: true, AccessAction: TargetAccessActionBlock}, + }, + } + + mapping := rp.ToProtoMapping(Create, "token", proxy.OIDCValidationConfig{}) + require.Len(t, mapping.Path, 3) + assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, mapping.Path[0].GetAccessAction()) + assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, mapping.Path[1].GetAccessAction()) + assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, mapping.Path[2].GetAccessAction()) + }) +} + +func TestHasTargetAccessControl(t *testing.T) { + rp := validProxy() + assert.False(t, rp.HasTargetAccessControl()) + + rp.Targets[0].AccessAction = TargetAccessActionBypass + assert.True(t, rp.HasTargetAccessControl()) +} + // TestToProtoMapping_AllowedGroupIds covers the list the proxy gates session // cookies on: without it the proxy can only check a cookie's signature, which // makes a token minted for a user outside the groups a bearer credential. diff --git a/management/internals/modules/reverseproxy/service/target_access.go b/management/internals/modules/reverseproxy/service/target_access.go new file mode 100644 index 00000000000..bd2d717fe65 --- /dev/null +++ b/management/internals/modules/reverseproxy/service/target_access.go @@ -0,0 +1,115 @@ +package service + +import ( + "fmt" + "strings" + "unicode" + "unicode/utf8" + + "github.com/netbirdio/netbird/shared/management/proto" +) + +// TargetAccessAction controls authentication for a selected HTTP target. +type TargetAccessAction string + +const ( + TargetAccessActionInherit TargetAccessAction = "inherit" + TargetAccessActionBypass TargetAccessAction = "bypass" + TargetAccessActionBlock TargetAccessAction = "block" +) + +func (t *Target) effectiveAccessAction() TargetAccessAction { + if t.AccessAction == "" { + return TargetAccessActionInherit + } + return t.AccessAction +} + +func targetAccessActionToProto(action TargetAccessAction) proto.TargetAccessAction { + switch action { + case TargetAccessActionInherit: + return proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT + case TargetAccessActionBypass: + return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS + case TargetAccessActionBlock: + return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK + default: + return proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK + } +} + +// HasTargetAccessControl reports whether any target overrides service authentication. +func (s *Service) HasTargetAccessControl() bool { + for _, target := range s.Targets { + if target != nil && target.effectiveAccessAction() != TargetAccessActionInherit { + return true + } + } + return false +} + +func validateTargetAccessAction(idx int, target *Target, private bool) error { + action, err := validatedTargetAccessAction(idx, target) + if err != nil { + return err + } + if action == TargetAccessActionBypass { + if private { + return fmt.Errorf("target %d: bypass access_action is not supported for private services", idx) + } + if target.Options.AgentNetwork { + return fmt.Errorf("target %d: bypass access_action is not supported for Agent Network targets", idx) + } + } + + if action == TargetAccessActionInherit { + return nil + } + return validateAccessActionPath(idx, target.Path) +} + +func validatedTargetAccessAction(idx int, target *Target) (TargetAccessAction, error) { + if target.AccessActionProvided && target.AccessAction == "" { + return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction) + } + action := target.effectiveAccessAction() + switch action { + case TargetAccessActionInherit, TargetAccessActionBypass, TargetAccessActionBlock: + return action, nil + default: + return "", fmt.Errorf("target %d: unknown access_action %q", idx, target.AccessAction) + } +} + +func normalizedTargetPath(configured *string) string { + if configured == nil || *configured == "" { + return "/" + } + return *configured +} + +func validateAccessActionPath(idx int, configured *string) error { + if configured == nil { + return nil + } + value := *configured + if value == "" { + return nil + } + if !strings.HasPrefix(value, "/") { + return fmt.Errorf("target %d: access_action path %q must start with /", idx, value) + } + if !utf8.ValidString(value) || strings.ContainsAny(value, "%\\?#;") || strings.IndexFunc(value, unicode.IsControl) >= 0 { + return fmt.Errorf("target %d: access_action path %q contains invalid characters", idx, value) + } + segments := strings.Split(strings.TrimPrefix(value, "/"), "/") + for i, segment := range segments { + if segment == "." || segment == ".." { + return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value) + } + if segment == "" && value != "/" && i != len(segments)-1 { + return fmt.Errorf("target %d: access_action path %q is not canonical", idx, value) + } + } + return nil +} diff --git a/management/server/store/sql_store_service_target.go b/management/server/store/sql_store_service_target.go index 5c2b9951092..0f74298802d 100644 --- a/management/server/store/sql_store_service_target.go +++ b/management/server/store/sql_store_service_target.go @@ -18,7 +18,7 @@ import ( ) const targetSelectColumns = `id, account_id, service_id, path, host, port, protocol, - target_id, target_type, enabled, proxy_protocol, + target_id, target_type, enabled, access_action, proxy_protocol, skip_tls_verify, request_timeout, session_idle_timeout, path_rewrite, custom_headers, direct_upstream, middlewares, capture_max_request_bytes, capture_max_response_bytes, capture_content_types, agent_network, disable_access_log` @@ -37,6 +37,7 @@ func (s *SqlStore) getServiceTargets(ctx context.Context, serviceIDs []string) ( func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { var t rpservice.Target var path sql.NullString + var accessAction sql.NullString var pathRewrite sql.NullString var proxyProtocol, skipTLSVerify, directUpstream, agentNetwork, disableAccessLog sql.NullBool var requestTimeout, sessionIdleTimeout, captureMaxRequestBytes, captureMaxResponseBytes sql.NullInt64 @@ -52,6 +53,7 @@ func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { &t.TargetId, &t.TargetType, &t.Enabled, + &accessAction, &proxyProtocol, &skipTLSVerify, &requestTimeout, @@ -72,6 +74,11 @@ func scanTarget(row pgx.CollectableRow) (*rpservice.Target, error) { if path.Valid { t.Path = &path.String } + if accessAction.Valid && accessAction.String != "" { + t.AccessAction = rpservice.TargetAccessAction(accessAction.String) + } else { + t.AccessAction = rpservice.TargetAccessActionInherit + } t.ProxyProtocol = proxyProtocol.Bool t.Options.SkipTLSVerify = skipTLSVerify.Bool diff --git a/management/server/store/sql_store_service_test.go b/management/server/store/sql_store_service_test.go index 0e14fbdabc0..f71b8c97a67 100644 --- a/management/server/store/sql_store_service_test.go +++ b/management/server/store/sql_store_service_test.go @@ -87,6 +87,7 @@ func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) { Protocol: "http", TargetId: "tgt-1", Enabled: true, + AccessAction: rpservice.TargetAccessActionBlock, ProxyProtocol: true, Options: rpservice.TargetOptions{ SkipTLSVerify: true, @@ -119,6 +120,7 @@ func TestSqlStore_GetAccount_ServiceTargetOptionsRoundtrip(t *testing.T) { require.Len(t, got.Targets, 1) tg := got.Targets[0] + assert.Equal(t, rpservice.TargetAccessActionBlock, tg.AccessAction, "target access action") assert.True(t, tg.ProxyProtocol, "target proxy protocol") assert.True(t, tg.Options.SkipTLSVerify, "options skip TLS verify") assert.Equal(t, 30*time.Second, tg.Options.RequestTimeout, "options request timeout") diff --git a/proxy/README.md b/proxy/README.md index 6af7cadd272..47079ba0a61 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -33,6 +33,43 @@ If any authentication methods are registered for the Host domain, then the Proxy If the user is successfully authenticated, their request will be forwarded through to the Proxy to be proxied to the relevant Peer. Successful authentication does not guarantee a successful forwarding of the request as there may be failures behind the Proxy, such as with Peer connectivity or the underlying resource. +### Target access control + +HTTP targets have an optional `access_action` setting, configured under +**Edit Target → Optional Settings → Access** in the dashboard: + +| Value | Behavior | +| --- | --- | +| `inherit` | Use the service's authentication settings. This is the default. A service without authentication remains public. | +| `bypass` | Forward anonymously without prompting for service authentication. | +| `block` | Return HTTP 403, including for authenticated users. | + +The longest matching Location prefix selects both the target and its access +action. Prefixes are literal and case-sensitive: `/api` also matches `/apiv2`, +while `/api/` only matches requests beginning with `/api/` and does not match +`/api` itself. Wildcards and regular expressions are not supported. A blocked +request or failed authentication never falls through to another target. Disabling +or deleting a target removes its rule, so a less-specific target may then match. + +Service IP, country, and reputation restrictions still apply to bypassed paths. +Bypass is unavailable for private services and Agent Network targets. Bypassed +requests have no NetBird user identity; configured header-auth credentials, +NetBird session cookies, and client-supplied NetBird identity headers are still +removed before forwarding. Custom upstream headers remain target settings, so +consider their effect when making a target public. + +Services using target access overrides reject ambiguous request paths, including +encoded separators and traversal segments, before forwarding. Path rewriting +continues to use the selected Location prefix after access checks. + +Upgrade all proxies before using target access actions. Older proxies ignore +these actions and continue applying the service's authentication settings. +API updates that omit an existing Location's +`access_action` preserve its setting; send `"access_action": "inherit"` explicitly +to restore service authentication. Requests omitting all access actions cannot +remove or rename a controlled Location; use an updated client and explicitly +include the intended actions when changing those targets. + ## TLS Due to the authentication provided, the Proxy uses HTTPS for its endpoint, even if the underlying service is HTTP. diff --git a/proxy/internal/auth/middleware.go b/proxy/internal/auth/middleware.go index 647741139ba..735fcbac38e 100644 --- a/proxy/internal/auth/middleware.go +++ b/proxy/internal/auth/middleware.go @@ -64,6 +64,22 @@ type DomainConfig struct { // groups claim intersects this set. Empty means group membership does not // restrict access. AllowedGroups map[string]struct{} + // TargetResolver pins the target and access action used by this auth snapshot. + TargetResolver *proxy.TargetResolver +} + +// DomainOption configures a domain authentication snapshot. +type DomainOption func(*DomainConfig) error + +// WithTargetResolver attaches an immutable target-routing snapshot to a domain. +func WithTargetResolver(resolver *proxy.TargetResolver) DomainOption { + return func(config *DomainConfig) error { + if resolver == nil { + return errors.New("target resolver is nil") + } + config.TargetResolver = resolver + return nil + } } type validationResult struct { @@ -132,6 +148,12 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { return } + resolvedRequest, handled := mw.handleTargetAccess(w, r, config, next) + if handled { + return + } + r = resolvedRequest + // Private services bypass operator schemes and gate on tunnel peer. if config.Private { if mw.forwardWithTunnelPeer(w, r, host, config, next) { @@ -171,6 +193,66 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler { }) } +func (mw *Middleware) handleTargetAccess(w http.ResponseWriter, r *http.Request, config DomainConfig, next http.Handler) (*http.Request, bool) { + if config.TargetResolver == nil { + return r, false + } + resolvedRequest, action, err := config.TargetResolver.ResolveRequest(r) + if err != nil { + mw.rejectUnsafePath(w, r, err) + return r, true + } + r = resolvedRequest + switch action { + case proxy.AccessActionInherit: + return r, false + case proxy.AccessActionBypass: + if config.Private { + mw.logger.Error("private domain reached a bypass target; denying") + denyPrivate(w) + return r, true + } + markAccessAction(r, action) + next.ServeHTTP(w, r) + case proxy.AccessActionBlock: + markAccessAction(r, action) + w.Header().Set("Cache-Control", "no-store") + denyForbidden(w, config) + default: + mw.logger.Errorf("resolved unknown access action %q; denying", action) + markAccessAction(r, action) + w.Header().Set("Cache-Control", "no-store") + denyForbidden(w, config) + } + return r, true +} + +func (mw *Middleware) rejectUnsafePath(w http.ResponseWriter, r *http.Request, err error) { + mw.logger.WithFields(log.Fields{ + "host": r.Host, + "path": r.URL.EscapedPath(), + }).Debugf("rejecting request path: %v", err) + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(proxy.OriginAuth) + cd.SetMetadata("access_action", "invalid_path") + } + w.Header().Set("Cache-Control", "no-store") + http.Error(w, "Bad Request", http.StatusBadRequest) +} + +func markAccessAction(r *http.Request, action proxy.AccessAction) { + if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil { + cd.SetMetadata("access_action", string(action)) + switch action { + case proxy.AccessActionBypass: + cd.SetAuthMethod("path_bypass") + case proxy.AccessActionBlock: + cd.SetAuthMethod("path_block") + cd.SetOrigin(proxy.OriginAuth) + } + } +} + // requestIsPlainHTTP reports whether the request arrived without TLS. // Used to gate cookie-on-plain warnings and the OIDC plain-HTTP block. func requestIsPlainHTTP(r *http.Request) bool { @@ -666,46 +748,74 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool { return false } -// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required. -// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list. -// allowedGroups restricts OIDC sessions to the given group ids; empty means unrestricted. -func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string) error { - if len(schemes) == 0 { - mw.domainsMux.Lock() - defer mw.domainsMux.Unlock() - mw.domains[domain] = DomainConfig{ - AccountID: accountID, - ServiceID: serviceID, - IPRestrictions: ipRestrictions, - Private: private, - AllowedGroups: groupSet(allowedGroups), +// NewDomainConfig validates and builds an immutable domain configuration. +func NewDomainConfig(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string, opts ...DomainOption) (DomainConfig, error) { + config := DomainConfig{ + Schemes: append([]Scheme(nil), schemes...), + SessionExpiration: expiration, + AccountID: accountID, + ServiceID: serviceID, + IPRestrictions: ipRestrictions, + Private: private, + AllowedGroups: groupSet(allowedGroups), + } + if len(schemes) > 0 { + pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) + if err != nil { + return DomainConfig{}, fmt.Errorf("decode session public key for domain %s: %w", domain, err) } - return nil + if len(pubKeyBytes) != ed25519.PublicKeySize { + return DomainConfig{}, fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize) + } + config.SessionPublicKey = append(ed25519.PublicKey(nil), pubKeyBytes...) } - - pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64) - if err != nil { - return fmt.Errorf("decode session public key for domain %s: %w", domain, err) + for _, option := range opts { + if option == nil { + return DomainConfig{}, fmt.Errorf("nil domain option for domain %s", domain) + } + if err := option(&config); err != nil { + return DomainConfig{}, fmt.Errorf("configure domain %s: %w", domain, err) + } } - if len(pubKeyBytes) != ed25519.PublicKeySize { - return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize) + if config.Private && config.TargetResolver != nil && config.TargetResolver.HasBypass() { + return DomainConfig{}, fmt.Errorf("authentication bypass is not allowed for private domain %s", domain) } + return config, nil +} +// AddDomainConfig atomically publishes a validated domain configuration. +func (mw *Middleware) AddDomainConfig(domain string, config DomainConfig) { + config.Schemes = append([]Scheme(nil), config.Schemes...) + config.SessionPublicKey = append(ed25519.PublicKey(nil), config.SessionPublicKey...) + config.AllowedGroups = cloneGroupSet(config.AllowedGroups) mw.domainsMux.Lock() defer mw.domainsMux.Unlock() - mw.domains[domain] = DomainConfig{ - Schemes: schemes, - SessionPublicKey: pubKeyBytes, - SessionExpiration: expiration, - AccountID: accountID, - ServiceID: serviceID, - IPRestrictions: ipRestrictions, - Private: private, - AllowedGroups: groupSet(allowedGroups), + mw.domains[domain] = config +} + +// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required. +// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list. +// allowedGroups restricts OIDC sessions to the given group ids; empty means unrestricted. +func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string, opts ...DomainOption) error { + config, err := NewDomainConfig(domain, schemes, publicKeyB64, expiration, accountID, serviceID, ipRestrictions, private, allowedGroups, opts...) + if err != nil { + return err } + mw.AddDomainConfig(domain, config) return nil } +func cloneGroupSet(groups map[string]struct{}) map[string]struct{} { + if groups == nil { + return nil + } + cloned := make(map[string]struct{}, len(groups)) + for group := range groups { + cloned[group] = struct{}{} + } + return cloned +} + // RemoveDomain unregisters authentication for the given domain. func (mw *Middleware) RemoveDomain(domain string) { mw.domainsMux.Lock() diff --git a/proxy/internal/auth/target_access_test.go b/proxy/internal/auth/target_access_test.go new file mode 100644 index 00000000000..56f924b602c --- /dev/null +++ b/proxy/internal/auth/target_access_test.go @@ -0,0 +1,278 @@ +package auth + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + proxyauth "github.com/netbirdio/netbird/proxy/auth" + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/restrict" +) + +type targetAccessRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f targetAccessRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func targetAccessResolver(t *testing.T, action proxy.AccessAction) *proxy.TargetResolver { + t.Helper() + targetURL, err := url.Parse("http://backend.internal") + require.NoError(t, err) + resolver, err := proxy.NewTargetResolver(proxy.Mapping{ + ID: "service-1", + AccountID: "account-1", + Host: "example.com", + Paths: map[string]*proxy.PathTarget{ + "/selected": { + URL: targetURL, + AccessAction: action, + }, + }, + }) + require.NoError(t, err) + return resolver +} + +func TestProtect_TargetAccessActions(t *testing.T) { + tests := []struct { + name string + action proxy.AccessAction + wantStatus int + wantBackendCalls int + wantSchemeCalls int + wantCacheControl string + }{ + { + name: "bypass forwards without authentication", + action: proxy.AccessActionBypass, + wantStatus: http.StatusNoContent, + wantBackendCalls: 1, + }, + { + name: "block rejects before authentication", + action: proxy.AccessActionBlock, + wantStatus: http.StatusForbidden, + wantSchemeCalls: 0, + wantCacheControl: "no-store", + }, + { + name: "inherit runs configured authentication", + action: proxy.AccessActionInherit, + wantStatus: http.StatusUnauthorized, + wantSchemeCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + schemeCalls := 0 + scheme := &stubScheme{ + method: proxyauth.MethodPIN, + authFn: func(*http.Request) (string, string, error) { + schemeCalls++ + return "", "pin", nil + }, + } + err := mw.AddDomain( + "example.com", + []Scheme{scheme}, + kp.PublicKey, + time.Hour, + "account-1", + "service-1", + nil, + false, + nil, + WithTargetResolver(targetAccessResolver(t, tt.action)), + ) + require.NoError(t, err) + + backendCalls := 0 + handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + backendCalls++ + w.WriteHeader(http.StatusNoContent) + })) + req := httptest.NewRequest(http.MethodGet, "http://example.com/selected/status", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, tt.wantStatus, rec.Code) + assert.Equal(t, tt.wantBackendCalls, backendCalls, "backend call count") + assert.Equal(t, tt.wantSchemeCalls, schemeCalls, "authentication scheme call count") + assert.Equal(t, tt.wantCacheControl, rec.Header().Get("Cache-Control"), "cache policy") + }) + } +} + +func TestProtect_BypassRecordsActionWithoutIdentity(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + require.NoError(t, mw.AddDomain( + "example.com", nil, "", 0, "account-1", "service-1", nil, false, nil, + WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBypass)), + )) + + handler := mw.Protect(newPassthroughHandler()) + req := httptest.NewRequest(http.MethodGet, "http://example.com/selected", nil) + captured := proxy.NewCapturedData("request-1") + req = req.WithContext(proxy.WithCapturedData(req.Context(), captured)) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Empty(t, captured.GetUserID()) + assert.Empty(t, captured.GetUserEmail()) + assert.Empty(t, captured.GetUserGroups()) + assert.Equal(t, "path_bypass", captured.GetAuthMethod()) + assert.Equal(t, "bypass", captured.GetMetadata()["access_action"]) +} + +func TestProtect_TargetResolverPinsMissThroughAuthentication(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + resolver := targetAccessResolver(t, proxy.AccessActionBypass) + require.NoError(t, mw.AddDomain( + "example.com", nil, "", 0, "account-1", "service-1", nil, false, nil, + WithTargetResolver(resolver), + )) + + forwarded := false + reverseProxy := proxy.NewReverseProxy(targetAccessRoundTripFunc(func(*http.Request) (*http.Response, error) { + forwarded = true + return nil, errors.New("unexpected forwarding") + }), "auto", nil, nil) + replacementURL, err := url.Parse("http://replacement.internal") + require.NoError(t, err) + reverseProxy.AddMapping(proxy.Mapping{ + ID: "service-1", + AccountID: "account-1", + Host: "example.com", + Paths: map[string]*proxy.PathTarget{ + "/": {URL: replacementURL}, + }, + }) + handler := mw.Protect(reverseProxy) + req := httptest.NewRequest(http.MethodGet, "http://example.com/not-selected", nil) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusNotFound, rec.Code, "a miss pinned before auth must reach the proxy unchanged") + assert.False(t, forwarded, "the proxy must not fall back to a newly published mapping after a pinned miss") +} + +func TestProtect_UnsafePathFailsBeforeAuthentication(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + kp := generateTestKeyPair(t) + schemeCalls := 0 + scheme := &stubScheme{ + method: proxyauth.MethodPIN, + authFn: func(*http.Request) (string, string, error) { + schemeCalls++ + return "", "pin", nil + }, + } + require.NoError(t, mw.AddDomain( + "example.com", + []Scheme{scheme}, + kp.PublicKey, + time.Hour, + "account-1", + "service-1", + nil, + false, + nil, + WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBlock)), + )) + + backendCalled := false + handler := mw.Protect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + backendCalled = true + })) + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.URL.Path = "/selected/../other" + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.Zero(t, schemeCalls, "unsafe paths must be rejected before credentials are evaluated") + assert.False(t, backendCalled) +} + +func TestProtect_IPRestrictionsRunBeforeTargetAccessAction(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}}) + require.NoError(t, mw.AddDomain( + "example.com", nil, "", 0, "account-1", "service-1", filter, false, nil, + WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBypass)), + )) + + backendCalled := false + handler := mw.Protect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + backendCalled = true + })) + req := httptest.NewRequest(http.MethodGet, "http://example.com/selected", nil) + req.RemoteAddr = "192.0.2.10:1234" + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusForbidden, rec.Code) + assert.False(t, backendCalled, "bypass must not bypass service IP restrictions") +} + +func TestNewDomainConfig_RejectsPrivateBypass(t *testing.T) { + _, err := NewDomainConfig( + "private.example.com", + nil, + "", + 0, + "account-1", + "service-1", + nil, + true, + nil, + WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBypass)), + ) + + require.Error(t, err) + assert.Contains(t, err.Error(), "not allowed for private domain") +} + +func TestAddDomainConfig_PublishesPreparedSnapshot(t *testing.T) { + mw := NewMiddleware(log.StandardLogger(), nil, nil) + config, err := NewDomainConfig( + "example.com", + nil, + "", + 0, + "account-1", + "service-1", + nil, + false, + []string{"group-1"}, + WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBlock)), + ) + require.NoError(t, err) + + mw.AddDomainConfig("example.com", config) + delete(config.AllowedGroups, "group-1") + + published, exists := mw.getDomainConfig("example.com") + require.True(t, exists) + assert.Contains(t, published.AllowedGroups, "group-1", "published mutable fields should be copied") + assert.NotNil(t, published.TargetResolver) +} diff --git a/proxy/internal/middleware/manager.go b/proxy/internal/middleware/manager.go index 9b22edeffc3..14ed0b8941d 100644 --- a/proxy/internal/middleware/manager.go +++ b/proxy/internal/middleware/manager.go @@ -29,6 +29,10 @@ type PathTargetBinding struct { Specs []Spec } +// Revision identifies a service's middleware binding snapshot. Zero is +// reserved for routes that are not coupled to a manager snapshot. +type Revision uint64 + // LiveServiceCheck reports whether the given service ID is still // present in the proxy's live mapping cache. The Manager calls it // during InvalidateMiddleware so a chain whose service has been @@ -42,14 +46,16 @@ type LiveServiceCheck func(serviceID string) bool // InvalidateMiddleware find the chain keys that reference a given // middleware without scanning the whole table. type chainTable struct { - byTarget map[string]*Chain - byMiddleware map[string]map[string]struct{} + byTarget map[string]*Chain + byMiddleware map[string]map[string]struct{} + serviceRevision map[string]Revision } func newChainTable() *chainTable { return &chainTable{ - byTarget: make(map[string]*Chain), - byMiddleware: make(map[string]map[string]struct{}), + byTarget: make(map[string]*Chain), + byMiddleware: make(map[string]map[string]struct{}), + serviceRevision: make(map[string]Revision), } } @@ -65,6 +71,9 @@ func (c *chainTable) clone() *chainTable { } out.byMiddleware[id] = set } + for serviceID, revision := range c.serviceRevision { + out.serviceRevision[serviceID] = revision + } return out } @@ -121,6 +130,7 @@ type Manager struct { resolver *Resolver lastBindings map[string]PathTargetBinding liveServiceCheck atomic.Pointer[LiveServiceCheck] + nextRevision Revision } // NewManager constructs a Manager with the given capture budget size. @@ -179,14 +189,29 @@ func (m *Manager) Dispatcher() *Dispatcher { return m.dispatcher } -// Rebuild replaces every chain keyed by serviceID with the provided -// bindings. Entries for other services are preserved. Replaced chains -// are closed asynchronously after the atomic swap so in-flight -// requests against the previous chain finish before middleware -// resources are released. +// Rebuild replaces every chain keyed by serviceID with the provided bindings. +// It is the compatibility wrapper for callers that do not consume revisions. func (m *Manager) Rebuild(serviceID string, bindings []PathTargetBinding) error { + _, err := m.RebuildSnapshot(serviceID, bindings) + return err +} + +// RebuildSnapshot atomically replaces a service's chains and assigns a nonzero +// revision, including for empty bindings. Retired chains close asynchronously. +func (m *Manager) RebuildSnapshot(serviceID string, bindings []PathTargetBinding) (Revision, error) { + for _, b := range bindings { + if b.ServiceID != serviceID { + return 0, fmt.Errorf("binding service %q does not match rebuild service %q", b.ServiceID, serviceID) + } + } + m.writeMu.Lock() defer m.writeMu.Unlock() + if m.nextRevision == ^Revision(0) { + return 0, fmt.Errorf("middleware snapshot revision exhausted") + } + m.nextRevision++ + revision := m.nextRevision cur := m.chains.Load() next := cur.clone() @@ -205,9 +230,6 @@ func (m *Manager) Rebuild(serviceID string, bindings []PathTargetBinding) error } for _, b := range bindings { - if b.ServiceID != serviceID { - return fmt.Errorf("binding service %q does not match rebuild service %q", b.ServiceID, serviceID) - } key := chainKey(b.ServiceID, b.PathID) m.lastBindings[key] = cloneBinding(b) chain := m.buildChain(b) @@ -217,10 +239,11 @@ func (m *Manager) Rebuild(serviceID string, bindings []PathTargetBinding) error } next.addChain(key, chain) } + next.serviceRevision[serviceID] = revision m.chains.Store(next) m.closeChainsAsync(retired) - return nil + return revision, nil } // Invalidate drops every chain for the given service ID. @@ -246,6 +269,7 @@ func (m *Manager) Invalidate(serviceID string) { delete(m.lastBindings, k) } } + delete(next.serviceRevision, serviceID) m.chains.Store(next) m.closeChainsAsync(retired) } @@ -359,6 +383,19 @@ func (m *Manager) ChainFor(serviceID, pathID string) *Chain { return c } +// ChainForRevision returns a chain only if the service revision matches. +// A matching revision with a nil chain is a coherent chainless target. Zero never matches. +func (m *Manager) ChainForRevision(serviceID, pathID string, revision Revision) (*Chain, bool) { + if revision == 0 { + return nil, false + } + tbl := m.chains.Load() + if tbl == nil || tbl.serviceRevision[serviceID] != revision { + return nil, false + } + return tbl.byTarget[chainKey(serviceID, pathID)], true +} + // buildChain resolves each enabled spec and returns the assembled // chain. Returns a nil chain when no middlewares are bound; resolver // errors per middleware are logged and counted but do not abort the diff --git a/proxy/internal/middleware/manager_revision_test.go b/proxy/internal/middleware/manager_revision_test.go new file mode 100644 index 00000000000..491b550c71c --- /dev/null +++ b/proxy/internal/middleware/manager_revision_test.go @@ -0,0 +1,230 @@ +package middleware + +import ( + "context" + "sync" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const revisionTestMiddlewareID = "revision_test" + +type revisionTestFactory struct { + builds atomic.Int64 +} + +func (f *revisionTestFactory) ID() string { + return revisionTestMiddlewareID +} + +func (f *revisionTestFactory) New(_ []byte) (Middleware, error) { + f.builds.Add(1) + return revisionTestMiddleware{}, nil +} + +type revisionTestMiddleware struct{} + +func (revisionTestMiddleware) ID() string { return revisionTestMiddlewareID } +func (revisionTestMiddleware) Version() string { return "test" } +func (revisionTestMiddleware) Slot() Slot { return SlotOnRequest } +func (revisionTestMiddleware) AcceptedContentTypes() []string { return nil } +func (revisionTestMiddleware) MetadataKeys() []string { return nil } +func (revisionTestMiddleware) MutationsSupported() bool { return false } +func (revisionTestMiddleware) Invoke(context.Context, *Input) (*Output, error) { + return &Output{Decision: DecisionAllow}, nil +} +func (revisionTestMiddleware) Close() error { return nil } + +func newRevisionTestManager(t *testing.T) (*Manager, *revisionTestFactory) { + t.Helper() + factory := &revisionTestFactory{} + registry := NewRegistry() + require.NoError(t, registry.Register(factory)) + manager := NewManager(1, nil, nil) + manager.SetResolver(NewResolver(registry)) + return manager, factory +} + +func revisionTestBinding(serviceID, pathID string) PathTargetBinding { + return PathTargetBinding{ + ServiceID: serviceID, + PathID: pathID, + Specs: []Spec{{ + ID: revisionTestMiddlewareID, + Slot: SlotOnRequest, + Enabled: true, + }}, + } +} + +func TestManagerRebuildSnapshotRevisionLookup(t *testing.T) { + manager, _ := newRevisionTestManager(t) + binding := revisionTestBinding("service", "/") + + revision1, err := manager.RebuildSnapshot("service", []PathTargetBinding{binding}) + require.NoError(t, err) + require.NotZero(t, revision1) + chain1, match := manager.ChainForRevision("service", "/", revision1) + require.True(t, match) + require.NotNil(t, chain1) + + revision2, err := manager.RebuildSnapshot("service", []PathTargetBinding{binding}) + require.NoError(t, err) + assert.NotEqual(t, revision1, revision2) + chain2, match := manager.ChainForRevision("service", "/", revision2) + require.True(t, match) + require.NotNil(t, chain2) + assert.NotSame(t, chain1, chain2) + + stale, match := manager.ChainForRevision("service", "/", revision1) + assert.False(t, match) + assert.Nil(t, stale) + zero, match := manager.ChainForRevision("service", "/", 0) + assert.False(t, match) + assert.Nil(t, zero) +} + +func TestManagerRebuildSnapshotTracksChainlessTransitions(t *testing.T) { + manager, _ := newRevisionTestManager(t) + + chainlessRevision, err := manager.RebuildSnapshot("service", nil) + require.NoError(t, err) + chain, match := manager.ChainForRevision("service", "/", chainlessRevision) + assert.True(t, match) + assert.Nil(t, chain) + + chainRevision, err := manager.RebuildSnapshot("service", []PathTargetBinding{ + revisionTestBinding("service", "/"), + }) + require.NoError(t, err) + _, match = manager.ChainForRevision("service", "/", chainlessRevision) + assert.False(t, match) + chain, match = manager.ChainForRevision("service", "/", chainRevision) + assert.True(t, match) + assert.NotNil(t, chain) + + chainlessRevision2, err := manager.RebuildSnapshot("service", nil) + require.NoError(t, err) + _, match = manager.ChainForRevision("service", "/", chainRevision) + assert.False(t, match) + chain, match = manager.ChainForRevision("service", "/", chainlessRevision2) + assert.True(t, match) + assert.Nil(t, chain) +} + +func TestManagerInvalidateMiddlewarePreservesServiceRevision(t *testing.T) { + manager, factory := newRevisionTestManager(t) + revision, err := manager.RebuildSnapshot("service", []PathTargetBinding{ + revisionTestBinding("service", "/"), + }) + require.NoError(t, err) + chain1, match := manager.ChainForRevision("service", "/", revision) + require.True(t, match) + require.NotNil(t, chain1) + + manager.InvalidateMiddleware(revisionTestMiddlewareID) + + chain2, match := manager.ChainForRevision("service", "/", revision) + require.True(t, match) + require.NotNil(t, chain2) + assert.NotSame(t, chain1, chain2) + assert.EqualValues(t, 2, factory.builds.Load()) +} + +func TestManagerInvalidateRemovesRevisionAndReaddDoesNotReuseIt(t *testing.T) { + manager, _ := newRevisionTestManager(t) + revision1, err := manager.RebuildSnapshot("service", nil) + require.NoError(t, err) + + manager.Invalidate("service") + _, match := manager.ChainForRevision("service", "/", revision1) + assert.False(t, match) + + revision2, err := manager.RebuildSnapshot("service", nil) + require.NoError(t, err) + assert.NotEqual(t, revision1, revision2) + _, match = manager.ChainForRevision("service", "/", revision2) + assert.True(t, match) + + manager.InvalidateAll() + _, match = manager.ChainForRevision("service", "/", revision2) + assert.False(t, match) + + revision3, err := manager.RebuildSnapshot("service", nil) + require.NoError(t, err) + assert.NotEqual(t, revision1, revision3) + assert.NotEqual(t, revision2, revision3) +} + +func TestManagerRebuildSnapshotRejectsMismatchedBindingBeforeMutation(t *testing.T) { + manager, _ := newRevisionTestManager(t) + binding := revisionTestBinding("service", "/") + revision1, err := manager.RebuildSnapshot("service", []PathTargetBinding{binding}) + require.NoError(t, err) + chain1, match := manager.ChainForRevision("service", "/", revision1) + require.True(t, match) + + invalid := revisionTestBinding("other-service", "/replacement") + revision, err := manager.RebuildSnapshot("service", []PathTargetBinding{invalid}) + require.Error(t, err) + assert.Zero(t, revision) + chainAfter, match := manager.ChainForRevision("service", "/", revision1) + assert.True(t, match) + assert.Same(t, chain1, chainAfter) + + revision2, err := manager.RebuildSnapshot("service", nil) + require.NoError(t, err) + assert.Equal(t, revision1+1, revision2, "a rejected rebuild must not consume a revision") +} + +func TestManagerConcurrentRebuildSnapshotRevisionsAreUnique(t *testing.T) { + manager, _ := newRevisionTestManager(t) + const rebuilds = 64 + + start := make(chan struct{}) + revisions := make(chan Revision, rebuilds) + errors := make(chan error, rebuilds) + var wg sync.WaitGroup + for range rebuilds { + wg.Add(1) + go func() { + defer wg.Done() + <-start + revision, err := manager.RebuildSnapshot("service", nil) + if err != nil { + errors <- err + return + } + revisions <- revision + }() + } + close(start) + wg.Wait() + close(errors) + close(revisions) + + for err := range errors { + require.NoError(t, err) + } + seen := make(map[Revision]struct{}, rebuilds) + for revision := range revisions { + require.NotZero(t, revision) + _, duplicate := seen[revision] + assert.False(t, duplicate, "each successful rebuild must receive a fresh revision") + seen[revision] = struct{}{} + } + require.Len(t, seen, rebuilds) + + matches := 0 + for revision := range seen { + chain, match := manager.ChainForRevision("service", "/", revision) + assert.Nil(t, chain) + if match { + matches++ + } + } + assert.Equal(t, 1, matches, "only the current immutable table revision may match") +} diff --git a/proxy/internal/proxy/reverseproxy.go b/proxy/internal/proxy/reverseproxy.go index a3987fe5a85..bf3ccaeb150 100644 --- a/proxy/internal/proxy/reverseproxy.go +++ b/proxy/internal/proxy/reverseproxy.go @@ -82,7 +82,15 @@ func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trusted } func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { - result, exists := p.findTargetForRequest(r) + resolution, pinned := targetResolutionFromContext(r.Context()) + result, exists := resolution.result, resolution.matched + if !pinned { + result, exists = p.findTargetForRequest(r) + if result.requirePinned { + p.serveProxyUnavailable(w, r) + return + } + } if !exists { p.serveRouteError(w, r, http.StatusNotFound, "Service Not Found", "The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.") @@ -119,7 +127,11 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { rewriteMatchedPath = "" } - chain := p.resolveChain(result) + chain, current := p.resolveChain(result) + if !current || (targetRequiresMiddleware(pt) && (chain == nil || chain.Empty())) { + p.serveProxyUnavailable(w, r) + return + } if chain == nil || chain.Empty() { p.serveDirect(w, r, ctx, result, rewriteMatchedPath) return @@ -127,6 +139,28 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { p.serveWithChain(w, r, ctx, result, chain, rewriteMatchedPath, capturedData) } +func (p *ReverseProxy) serveProxyUnavailable(w http.ResponseWriter, r *http.Request) { + if cd := CapturedDataFromContext(r.Context()); cd != nil { + cd.SetOrigin(OriginProxyError) + } + w.Header().Set("Cache-Control", "no-store") + web.ServeErrorPage(w, r, http.StatusServiceUnavailable, "Service Unavailable", + "The requested service is being updated. Please try again shortly.", getRequestID(r), + web.ErrorStatus{Proxy: true, Destination: false}) +} + +func targetRequiresMiddleware(target *PathTarget) bool { + if target == nil { + return false + } + for _, spec := range target.Middlewares { + if spec.Enabled { + return true + } + } + return false +} + // serveRouteError marks the request as un-routed on any captured-data // context and renders the proxy error page. func (p *ReverseProxy) serveRouteError(w http.ResponseWriter, r *http.Request, status int, title, message string) { @@ -455,14 +489,15 @@ func applyUpstreamHeaders(r *http.Request, rewrite *middleware.UpstreamRewrite) } } -// resolveChain returns the middleware chain registered for the -// resolved target, or nil when middleware is disabled for the proxy -// or the target. -func (p *ReverseProxy) resolveChain(result targetResult) *middleware.Chain { +// resolveChain returns a chain only from the target's routing revision. +func (p *ReverseProxy) resolveChain(result targetResult) (*middleware.Chain, bool) { if p.middlewareManager == nil { - return nil + return nil, result.middlewareRevision == 0 + } + if result.middlewareRevision == 0 { + return p.middlewareManager.ChainFor(string(result.serviceID), result.matchedPath), true } - return p.middlewareManager.ChainFor(string(result.serviceID), result.matchedPath) + return p.middlewareManager.ChainForRevision(string(result.serviceID), result.matchedPath, result.middlewareRevision) } // buildRequestInput gathers the per-request fields the middleware diff --git a/proxy/internal/proxy/servicemapping.go b/proxy/internal/proxy/servicemapping.go index 64fccc42aa4..40115fd9d47 100644 --- a/proxy/internal/proxy/servicemapping.go +++ b/proxy/internal/proxy/servicemapping.go @@ -23,9 +23,22 @@ const ( PathRewritePreserve ) +// AccessAction controls how authentication handles requests for a target. +type AccessAction string + +const ( + // AccessActionInherit applies the service's configured authentication. + AccessActionInherit AccessAction = "inherit" + // AccessActionBypass skips the service's configured authentication. + AccessActionBypass AccessAction = "bypass" + // AccessActionBlock rejects the request before authentication. + AccessActionBlock AccessAction = "block" +) + // PathTarget holds a backend URL and per-target behavioral options. type PathTarget struct { URL *url.URL + AccessAction AccessAction SkipTLSVerify bool RequestTimeout time.Duration PathRewrite PathRewriteMode @@ -61,18 +74,25 @@ type Mapping struct { // StripAuthHeaders are header names used for header-based auth. // These headers are stripped from requests before forwarding. StripAuthHeaders []string + // MiddlewareRevision binds this routing snapshot to its middleware policies. + MiddlewareRevision middleware.Revision // sortedPaths caches the paths sorted by length (longest first). sortedPaths []string + // requirePinnedResolution prevents a mapping with target access actions + // from being forwarded outside the auth-owned resolver snapshot. + requirePinnedResolution bool } type targetResult struct { - target *PathTarget - matchedPath string - serviceID types.ServiceID - accountID types.AccountID - passHostHeader bool - rewriteRedirects bool - stripAuthHeaders []string + target *PathTarget + matchedPath string + serviceID types.ServiceID + accountID types.AccountID + passHostHeader bool + rewriteRedirects bool + stripAuthHeaders []string + middlewareRevision middleware.Revision + requirePinned bool } func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bool) { @@ -90,6 +110,7 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo p.logger.Debugf("no mapping found for host: %s", host) return targetResult{}, false } + baseResult := targetResult{requirePinned: m.requirePinnedResolution} for _, path := range m.sortedPaths { if strings.HasPrefix(req.URL.Path, path) { @@ -100,18 +121,20 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo } p.logger.Debugf("matched host: %s, path: %s -> %s", host, path, pt.URL) return targetResult{ - target: pt, - matchedPath: path, - serviceID: m.ID, - accountID: m.AccountID, - passHostHeader: m.PassHostHeader, - rewriteRedirects: m.RewriteRedirects, - stripAuthHeaders: m.StripAuthHeaders, + target: pt, + matchedPath: path, + serviceID: m.ID, + accountID: m.AccountID, + passHostHeader: m.PassHostHeader, + rewriteRedirects: m.RewriteRedirects, + stripAuthHeaders: m.StripAuthHeaders, + middlewareRevision: m.MiddlewareRevision, + requirePinned: m.requirePinnedResolution, }, true } } p.logger.Debugf("no path match for host: %s, path: %s", host, req.URL.Path) - return targetResult{}, false + return baseResult, false } // AddMapping registers a host-to-backend mapping for the reverse proxy. @@ -125,6 +148,7 @@ func (p *ReverseProxy) AddMapping(m Mapping) { return len(paths[i]) > len(paths[j]) }) m.sortedPaths = paths + m.requirePinnedResolution = mappingHasNonDefaultAccessAction(m) p.mappingsMux.Lock() defer p.mappingsMux.Unlock() diff --git a/proxy/internal/proxy/target_middleware_snapshot_test.go b/proxy/internal/proxy/target_middleware_snapshot_test.go new file mode 100644 index 00000000000..dbbff55c747 --- /dev/null +++ b/proxy/internal/proxy/target_middleware_snapshot_test.go @@ -0,0 +1,150 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +func TestReverseProxy_PinnedTargetFailsClosedAcrossMiddlewareRevisions(t *testing.T) { + restrictiveGuardrail := []middleware.Spec{{ + ID: guardrail.ID, + Slot: middleware.SlotOnRequest, + Enabled: true, + RawConfig: []byte(`{"provider_allowlists":{"provider-1":["allowed-model"]}}`), + }} + unrestrictedGuardrail := []middleware.Spec{{ + ID: guardrail.ID, + Slot: middleware.SlotOnRequest, + Enabled: true, + RawConfig: []byte(`{}`), + }} + + tests := []struct { + name string + oldMiddlewares []middleware.Spec + newMiddlewares []middleware.Spec + oldStatus int + }{ + { + name: "replacement policy cannot authorize old target", + oldMiddlewares: restrictiveGuardrail, + newMiddlewares: unrestrictedGuardrail, + oldStatus: http.StatusForbidden, + }, + { + name: "new policy cannot run on old no-policy target", + newMiddlewares: unrestrictedGuardrail, + oldStatus: http.StatusNoContent, + }, + { + name: "removed policy cannot disappear from old target", + oldMiddlewares: unrestrictedGuardrail, + oldStatus: http.StatusNoContent, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + registry := middleware.NewRegistry() + require.NoError(t, registry.Register(guardrail.Factory{})) + manager := middleware.NewManager(0, nil, nil) + manager.SetResolver(middleware.NewResolver(registry)) + + oldTarget := &PathTarget{ + URL: mustTargetURL(t, "http://old-agent-backend.internal"), + AgentNetwork: true, + Middlewares: tt.oldMiddlewares, + } + oldMapping := resolverMapping(map[string]*PathTarget{"/": oldTarget}) + oldRevision := rebuildMiddlewareSnapshot(t, manager, &oldMapping) + oldResolver, err := NewTargetResolver(oldMapping) + require.NoError(t, err) + + // Auth resolves and pins the old route and middleware revision before + // the replacement snapshot is installed. + req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", nil) + oldPinnedReq, _, err := oldResolver.ResolveRequest(req) + require.NoError(t, err) + + var upstreamHosts []string + rp := NewReverseProxy(resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) { + upstreamHosts = append(upstreamHosts, req.URL.Host) + return &http.Response{ + StatusCode: http.StatusNoContent, + Header: make(http.Header), + Body: http.NoBody, + }, nil + }), "auto", nil, nil, WithMiddlewareManager(manager)) + + before := httptest.NewRecorder() + rp.ServeHTTP(before, oldPinnedReq.Clone(oldPinnedReq.Context())) + require.Equal(t, tt.oldStatus, before.Code, "the original snapshot must work before replacement") + if tt.oldStatus == http.StatusNoContent { + require.Equal(t, []string{"old-agent-backend.internal"}, upstreamHosts) + } else { + require.Empty(t, upstreamHosts) + } + upstreamHosts = nil + + newTarget := &PathTarget{ + URL: mustTargetURL(t, "http://new-agent-backend.internal"), + AgentNetwork: true, + Middlewares: tt.newMiddlewares, + } + newMapping := resolverMapping(map[string]*PathTarget{"/": newTarget}) + newRevision := rebuildMiddlewareSnapshot(t, manager, &newMapping) + require.NotEqual(t, oldRevision, newRevision) + + stale := httptest.NewRecorder() + rp.ServeHTTP(stale, oldPinnedReq) + + assert.Equal(t, http.StatusServiceUnavailable, stale.Code, + "a pinned target must not use a replacement middleware revision") + assert.Equal(t, "no-store", stale.Header().Get("Cache-Control")) + assert.Empty(t, upstreamHosts, + "a mixed target/policy generation must never reach an upstream") + + // A request resolved from the replacement route snapshot is coherent + // with the replacement chain and reaches only the new backend. + newResolver, err := NewTargetResolver(newMapping) + require.NoError(t, err) + freshReq, _, err := newResolver.ResolveRequest( + httptest.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", nil), + ) + require.NoError(t, err) + + fresh := httptest.NewRecorder() + rp.ServeHTTP(fresh, freshReq) + + assert.Equal(t, http.StatusNoContent, fresh.Code) + assert.Equal(t, []string{"new-agent-backend.internal"}, upstreamHosts) + }) + } +} + +func rebuildMiddlewareSnapshot(t *testing.T, manager *middleware.Manager, mapping *Mapping) middleware.Revision { + t.Helper() + bindings := make([]middleware.PathTargetBinding, 0, len(mapping.Paths)) + for path, target := range mapping.Paths { + if target == nil || len(target.Middlewares) == 0 { + continue + } + bindings = append(bindings, middleware.PathTargetBinding{ + ServiceID: string(mapping.ID), + PathID: path, + Specs: target.Middlewares, + }) + } + revision, err := manager.RebuildSnapshot(string(mapping.ID), bindings) + require.NoError(t, err) + require.NotZero(t, revision) + mapping.MiddlewareRevision = revision + return revision +} diff --git a/proxy/internal/proxy/target_resolver.go b/proxy/internal/proxy/target_resolver.go new file mode 100644 index 00000000000..9b8e838a58c --- /dev/null +++ b/proxy/internal/proxy/target_resolver.go @@ -0,0 +1,310 @@ +package proxy + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "sort" + "strings" + "unicode/utf8" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + "github.com/netbirdio/netbird/proxy/internal/middleware/bodytap" +) + +// ErrUnsafeRequestPath indicates that a request path cannot be matched safely. +var ErrUnsafeRequestPath = errors.New("unsafe request path") + +// TargetResolver is an immutable routing snapshot shared by auth and forwarding. +type TargetResolver struct { + mapping Mapping + strictPaths bool + hasBypass bool +} + +type targetResolution struct { + result targetResult + matched bool +} + +type targetResolutionContextKey struct{} + +// NewTargetResolver builds an immutable target-routing snapshot. +func NewTargetResolver(mapping Mapping) (*TargetResolver, error) { + snapshot, err := cloneResolverMapping(mapping) + if err != nil { + return nil, err + } + + resolver := &TargetResolver{mapping: snapshot} + for _, target := range snapshot.Paths { + action := effectiveAccessAction(target.AccessAction) + if action != AccessActionInherit { + resolver.strictPaths = true + } + if action == AccessActionBypass { + resolver.hasBypass = true + } + } + return resolver, nil +} + +// ResolveRequest selects and pins a target for both auth and forwarding. +func (r *TargetResolver) ResolveRequest(req *http.Request) (*http.Request, AccessAction, error) { + if r == nil { + return req, AccessActionInherit, nil + } + if req == nil || req.URL == nil { + return req, AccessActionInherit, fmt.Errorf("%w: missing URL", ErrUnsafeRequestPath) + } + if r.strictPaths { + if err := validateAccessPath(req.URL); err != nil { + return req, AccessActionInherit, err + } + } + + result, matched := findTargetInMapping(req.URL.Path, r.mapping) + resolution := targetResolution{result: result, matched: matched} + ctx := context.WithValue(req.Context(), targetResolutionContextKey{}, resolution) + resolvedReq := req.WithContext(ctx) + if !matched { + return resolvedReq, AccessActionInherit, nil + } + + if cd := CapturedDataFromContext(ctx); cd != nil { + cd.SetServiceID(result.serviceID) + cd.SetAccountID(result.accountID) + cd.SetAgentNetwork(result.target.AgentNetwork) + cd.SetSuppressAccessLog(result.target.DisableAccessLog) + } + return resolvedReq, effectiveAccessAction(result.target.AccessAction), nil +} + +// HasBypass reports whether the snapshot contains an authentication bypass. +func (r *TargetResolver) HasBypass() bool { + return r != nil && r.hasBypass +} + +// WithMiddlewareRevision returns a routing snapshot bound to the given policy revision. +func (r *TargetResolver) WithMiddlewareRevision(revision middleware.Revision) *TargetResolver { + bound := *r + bound.mapping.MiddlewareRevision = revision + return &bound +} + +func targetResolutionFromContext(ctx context.Context) (targetResolution, bool) { + resolution, ok := ctx.Value(targetResolutionContextKey{}).(targetResolution) + return resolution, ok +} + +func effectiveAccessAction(action AccessAction) AccessAction { + if action == "" { + return AccessActionInherit + } + return action +} + +func mappingHasNonDefaultAccessAction(mapping Mapping) bool { + for _, target := range mapping.Paths { + if target != nil && effectiveAccessAction(target.AccessAction) != AccessActionInherit { + return true + } + } + return false +} + +func cloneResolverMapping(mapping Mapping) (Mapping, error) { + snapshot := mapping + snapshot.Paths = make(map[string]*PathTarget, len(mapping.Paths)) + snapshot.StripAuthHeaders = append([]string(nil), mapping.StripAuthHeaders...) + snapshot.sortedPaths = make([]string, 0, len(mapping.Paths)) + + for path, target := range mapping.Paths { + if target == nil || target.URL == nil { + return Mapping{}, fmt.Errorf("target for path %q is incomplete", path) + } + action := effectiveAccessAction(target.AccessAction) + switch action { + case AccessActionInherit, AccessActionBypass, AccessActionBlock: + default: + return Mapping{}, fmt.Errorf("unknown access action %q for path %q", target.AccessAction, path) + } + if action == AccessActionBypass && target.AgentNetwork { + return Mapping{}, fmt.Errorf("authentication bypass is not allowed for Agent Network target %q", path) + } + if action != AccessActionInherit { + if err := validateAccessPrefix(path); err != nil { + return Mapping{}, fmt.Errorf("invalid access-controlled path %q: %w", path, err) + } + } + + cloned := clonePathTarget(target) + cloned.AccessAction = action + snapshot.Paths[path] = cloned + snapshot.sortedPaths = append(snapshot.sortedPaths, path) + } + sort.Slice(snapshot.sortedPaths, func(i, j int) bool { + return len(snapshot.sortedPaths[i]) > len(snapshot.sortedPaths[j]) + }) + snapshot.requirePinnedResolution = mappingHasNonDefaultAccessAction(snapshot) + return snapshot, nil +} + +func validateAccessPrefix(prefix string) error { + if strings.ContainsAny(prefix, "?#") { + return fmt.Errorf("%w: query or fragment delimiter", ErrUnsafeRequestPath) + } + return validateAccessPath(&url.URL{Path: prefix}) +} + +func clonePathTarget(target *PathTarget) *PathTarget { + cloned := *target + clonedURL := *target.URL + cloned.URL = &clonedURL + cloned.CustomHeaders = cloneStringMap(target.CustomHeaders) + cloned.Middlewares = cloneMiddlewareSpecs(target.Middlewares) + cloned.CaptureConfig = cloneCaptureConfig(target.CaptureConfig) + return &cloned +} + +func cloneStringMap(values map[string]string) map[string]string { + if values == nil { + return nil + } + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func cloneMiddlewareSpecs(specs []middleware.Spec) []middleware.Spec { + if specs == nil { + return nil + } + cloned := make([]middleware.Spec, len(specs)) + for i, spec := range specs { + cloned[i] = spec.Clone() + } + return cloned +} + +func cloneCaptureConfig(config *bodytap.Config) *bodytap.Config { + if config == nil { + return nil + } + cloned := *config + cloned.ContentTypes = append([]string(nil), config.ContentTypes...) + return &cloned +} + +func findTargetInMapping(path string, mapping Mapping) (targetResult, bool) { + for _, prefix := range mapping.sortedPaths { + if !strings.HasPrefix(path, prefix) { + continue + } + target := mapping.Paths[prefix] + if target == nil || target.URL == nil { + continue + } + return targetResult{ + target: target, + matchedPath: prefix, + serviceID: mapping.ID, + accountID: mapping.AccountID, + passHostHeader: mapping.PassHostHeader, + rewriteRedirects: mapping.RewriteRedirects, + stripAuthHeaders: mapping.StripAuthHeaders, + middlewareRevision: mapping.MiddlewareRevision, + requirePinned: mapping.requirePinnedResolution, + }, true + } + return targetResult{requirePinned: mapping.requirePinnedResolution}, false +} + +func validateAccessPath(requestURL *url.URL) error { + if requestURL.Opaque != "" || requestURL.Path == "" || requestURL.Path[0] != '/' { + return fmt.Errorf("%w: path must be absolute", ErrUnsafeRequestPath) + } + if !utf8.ValidString(requestURL.Path) { + return fmt.Errorf("%w: path is not valid UTF-8", ErrUnsafeRequestPath) + } + if requestURL.RawPath != "" { + decoded, err := url.PathUnescape(requestURL.RawPath) + if err != nil || decoded != requestURL.Path { + return fmt.Errorf("%w: invalid escaped path", ErrUnsafeRequestPath) + } + } + if err := validateDecodedAccessPath(requestURL.Path); err != nil { + return err + } + return validateEscapedAccessPath(requestURL.EscapedPath()) +} + +func validateDecodedAccessPath(path string) error { + if strings.Contains(path, "//") || strings.ContainsAny(path, "\\;?#") { + return fmt.Errorf("%w: ambiguous path separator", ErrUnsafeRequestPath) + } + for _, segment := range strings.Split(path, "/") { + if segment == "." || segment == ".." { + return fmt.Errorf("%w: dot segment", ErrUnsafeRequestPath) + } + } + for _, char := range path { + if char < 0x20 || char == 0x7f { + return fmt.Errorf("%w: control character", ErrUnsafeRequestPath) + } + } + return nil +} + +func validateEscapedAccessPath(escapedPath string) error { + for i := 0; i < len(escapedPath); i++ { + if escapedPath[i] != '%' { + continue + } + if i+2 >= len(escapedPath) { + return fmt.Errorf("%w: truncated escape", ErrUnsafeRequestPath) + } + value, ok := decodeHexByte(escapedPath[i+1], escapedPath[i+2]) + if !ok { + return fmt.Errorf("%w: invalid escape", ErrUnsafeRequestPath) + } + switch value { + case '/', '\\', '.', '%', 0: + return fmt.Errorf("%w: ambiguous escaped character", ErrUnsafeRequestPath) + } + if value < 0x20 || value == 0x7f { + return fmt.Errorf("%w: escaped control character", ErrUnsafeRequestPath) + } + i += 2 + } + return nil +} + +func decodeHexByte(high, low byte) (byte, bool) { + hi, ok := hexNibble(high) + if !ok { + return 0, false + } + lo, ok := hexNibble(low) + if !ok { + return 0, false + } + return hi<<4 | lo, true +} + +func hexNibble(value byte) (byte, bool) { + switch { + case value >= '0' && value <= '9': + return value - '0', true + case value >= 'a' && value <= 'f': + return value - 'a' + 10, true + case value >= 'A' && value <= 'F': + return value - 'A' + 10, true + default: + return 0, false + } +} diff --git a/proxy/internal/proxy/target_resolver_test.go b/proxy/internal/proxy/target_resolver_test.go new file mode 100644 index 00000000000..bf48d812c5f --- /dev/null +++ b/proxy/internal/proxy/target_resolver_test.go @@ -0,0 +1,336 @@ +package proxy + +import ( + "errors" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" +) + +type resolverRoundTripFunc func(*http.Request) (*http.Response, error) + +func (f resolverRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func resolverMapping(targets map[string]*PathTarget) Mapping { + return Mapping{ + ID: "service-1", + AccountID: "account-1", + Host: "example.com", + Paths: targets, + } +} + +func mustTargetURL(t *testing.T, rawURL string) *url.URL { + t.Helper() + targetURL, err := url.Parse(rawURL) + require.NoError(t, err) + return targetURL +} + +func TestTargetResolver_LongestPrefixPinsTargetAndAction(t *testing.T) { + mapping := resolverMapping(map[string]*PathTarget{ + "/": { + URL: mustTargetURL(t, "http://root.internal"), + AccessAction: AccessActionInherit, + }, + "/public": { + URL: mustTargetURL(t, "http://public.internal"), + AccessAction: AccessActionBypass, + CustomHeaders: map[string]string{"X-Target": "original"}, + }, + }) + resolver, err := NewTargetResolver(mapping) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/public/status", nil) + resolvedReq, action, err := resolver.ResolveRequest(req) + require.NoError(t, err) + assert.Equal(t, AccessActionBypass, action, "the longest matching target action should be selected") + + // Mutating both the source mapping and the live proxy mapping must not + // change the target already selected by the auth snapshot. + mapping.Paths["/public"].URL.Host = "mutated.internal" + mapping.Paths["/public"].CustomHeaders["X-Target"] = "mutated" + liveMapping := resolverMapping(map[string]*PathTarget{ + "/": {URL: mustTargetURL(t, "http://replacement.internal")}, + }) + + var gotHost, gotHeader string + rp := NewReverseProxy(resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) { + gotHost = req.URL.Host + gotHeader = req.Header.Get("X-Target") + return &http.Response{ + StatusCode: http.StatusNoContent, + Header: make(http.Header), + Body: http.NoBody, + }, nil + }), "auto", nil, nil) + rp.AddMapping(liveMapping) + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, resolvedReq) + + assert.Equal(t, http.StatusNoContent, rec.Code) + assert.Equal(t, "public.internal", gotHost, "forwarding must reuse the target pinned before auth") + assert.Equal(t, "original", gotHeader, "resolver-owned target options must be immutable") +} + +func TestTargetResolver_PinnedMissDoesNotFallBackToNewMapping(t *testing.T) { + resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{ + "/api": {URL: mustTargetURL(t, "http://api.internal")}, + })) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil) + resolvedReq, action, err := resolver.ResolveRequest(req) + require.NoError(t, err) + assert.Equal(t, AccessActionInherit, action) + + called := false + rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("unexpected forwarding") + }), "auto", nil, nil) + rp.AddMapping(resolverMapping(map[string]*PathTarget{ + "/": {URL: mustTargetURL(t, "http://new.internal")}, + })) + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, resolvedReq) + + assert.Equal(t, http.StatusNotFound, rec.Code) + assert.False(t, called, "a miss pinned before auth must not be resolved again after an update") +} + +func TestReverseProxy_UnpinnedNonDefaultMappingFailsClosed(t *testing.T) { + for _, requestPath := range []string{"/public", "/missing"} { + t.Run(requestPath, func(t *testing.T) { + called := false + rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("unexpected forwarding") + }), "auto", nil, nil) + rp.AddMapping(resolverMapping(map[string]*PathTarget{ + "/public": { + URL: mustTargetURL(t, "http://public.internal"), + AccessAction: AccessActionBypass, + }, + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com"+requestPath, nil) + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code, + "both target hits and misses must require an auth-owned resolution") + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), + "an unavailable routing snapshot must not be cached") + assert.False(t, called, "a target access action must never forward without an auth-owned resolution") + }) + } +} + +func TestReverseProxy_ConfiguredMiddlewareMissingChainFailsClosed(t *testing.T) { + called := false + manager := middleware.NewManager(0, nil, nil) + rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) { + called = true + return nil, errors.New("unexpected forwarding") + }), "auto", nil, nil, WithMiddlewareManager(manager)) + rp.AddMapping(resolverMapping(map[string]*PathTarget{ + "/": { + URL: mustTargetURL(t, "http://protected.internal"), + Middlewares: []middleware.Spec{{ + ID: "required-policy", + Slot: middleware.SlotOnRequest, + Enabled: true, + }}, + }, + })) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code) + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control")) + assert.False(t, called, "a missing configured policy chain must never become pass-through") +} + +func TestReverseProxy_PinnedTargetFailsClosedAfterMiddlewareRemoval(t *testing.T) { + target := &PathTarget{ + URL: mustTargetURL(t, "http://protected.internal"), + AccessAction: AccessActionInherit, + Middlewares: []middleware.Spec{{ + ID: guardrail.ID, + Slot: middleware.SlotOnRequest, + Enabled: true, + }}, + } + mapping := resolverMapping(map[string]*PathTarget{"/": target}) + resolver, err := NewTargetResolver(mapping) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + resolvedReq, _, err := resolver.ResolveRequest(req) + require.NoError(t, err) + + upstreamCalls := 0 + registry := middleware.NewRegistry() + require.NoError(t, registry.Register(guardrail.Factory{})) + manager := middleware.NewManager(0, nil, nil) + manager.SetResolver(middleware.NewResolver(registry)) + require.NoError(t, manager.Rebuild(string(mapping.ID), []middleware.PathTargetBinding{{ + ServiceID: string(mapping.ID), + PathID: "/", + Specs: target.Middlewares, + }})) + require.NotNil(t, manager.ChainFor(string(mapping.ID), "/"), "the policy chain must exist before invalidation") + rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) { + upstreamCalls++ + return &http.Response{ + StatusCode: http.StatusNoContent, + Header: make(http.Header), + Body: http.NoBody, + }, nil + }), "auto", nil, nil, WithMiddlewareManager(manager)) + before := httptest.NewRecorder() + rp.ServeHTTP(before, resolvedReq.Clone(resolvedReq.Context())) + require.Equal(t, http.StatusNoContent, before.Code, "the installed policy chain must allow forwarding") + require.Equal(t, 1, upstreamCalls, "the upstream must receive the request before invalidation") + + manager.Invalidate(string(mapping.ID)) + require.Nil(t, manager.ChainFor(string(mapping.ID), "/"), "invalidation must remove the installed chain") + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, resolvedReq) + + assert.Equal(t, http.StatusServiceUnavailable, rec.Code, "a removed policy chain must fail closed") + assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "a removed policy chain must not be cached") + assert.Equal(t, 1, upstreamCalls, "a stale pinned target must not bypass a removed policy chain") +} + +func TestTargetResolver_RejectsUnsafePathsWhenActionsAreConfigured(t *testing.T) { + resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{ + "/public": { + URL: mustTargetURL(t, "http://public.internal"), + AccessAction: AccessActionBypass, + }, + "/": {URL: mustTargetURL(t, "http://root.internal")}, + })) + require.NoError(t, err) + + tests := []struct { + name string + path string + rawPath string + }{ + {name: "dot segment", path: "/public/../private"}, + {name: "duplicate separator", path: "/public//private"}, + {name: "backslash", path: `/public\private`}, + {name: "path parameter", path: "/public;ignored/private"}, + {name: "encoded query delimiter", path: "/public?ignored", rawPath: "/public%3fignored"}, + {name: "encoded fragment delimiter", path: "/public#ignored", rawPath: "/public%23ignored"}, + {name: "encoded slash", path: "/public/private", rawPath: "/public%2fprivate"}, + {name: "encoded dot", path: "/public/../private", rawPath: "/public/%2e%2e/private"}, + {name: "double encoding", path: "/public/%2e%2e/private", rawPath: "/public/%252e%252e/private"}, + {name: "escaped NUL", path: "/public/\x00private", rawPath: "/public/%00private"}, + {name: "mismatched raw path", path: "/public/safe", rawPath: "/different"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.URL.Path = tt.path + req.URL.RawPath = tt.rawPath + + _, _, err := resolver.ResolveRequest(req) + + require.ErrorIs(t, err, ErrUnsafeRequestPath) + }) + } + + req := httptest.NewRequest(http.MethodGet, "http://example.com/public/file%20name", nil) + _, action, err := resolver.ResolveRequest(req) + require.NoError(t, err) + assert.Equal(t, AccessActionBypass, action, "ordinary percent-encoded path data should remain supported") +} + +func TestTargetResolver_InheritOnlyPreservesLegacyPathHandling(t *testing.T) { + resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{ + "/": {URL: mustTargetURL(t, "http://root.internal")}, + })) + require.NoError(t, err) + + req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil) + req.URL.Path = "/legacy/../path" + resolvedReq, action, err := resolver.ResolveRequest(req) + + require.NoError(t, err) + assert.Equal(t, AccessActionInherit, action) + _, pinned := targetResolutionFromContext(resolvedReq.Context()) + assert.True(t, pinned, "inherit-only mappings should still pin their target snapshot") +} + +func TestNewTargetResolver_RejectsInvalidAccessActions(t *testing.T) { + tests := []struct { + name string + target *PathTarget + }{ + { + name: "unknown action", + target: &PathTarget{ + URL: mustTargetURL(t, "http://target.internal"), + AccessAction: AccessAction("unknown"), + }, + }, + { + name: "Agent Network bypass", + target: &PathTarget{ + URL: mustTargetURL(t, "http://target.internal"), + AccessAction: AccessActionBypass, + AgentNetwork: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{"/": tt.target})) + require.Error(t, err) + }) + } +} + +func TestNewTargetResolver_RejectsNoncanonicalAccessPrefixes(t *testing.T) { + prefixes := []string{ + "public", + "/public/../private", + "/public//nested", + `/public\nested`, + "/public;parameter", + "/public%2fprivate", + "/public?query", + "/public#fragment", + "/public\x00nested", + } + + for _, prefix := range prefixes { + t.Run(prefix, func(t *testing.T) { + _, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{ + prefix: { + URL: mustTargetURL(t, "http://target.internal"), + AccessAction: AccessActionBlock, + }, + "/": {URL: mustTargetURL(t, "http://root.internal")}, + })) + + require.ErrorIs(t, err, ErrUnsafeRequestPath) + }) + } +} diff --git a/proxy/server.go b/proxy/server.go index 762ead9b8a3..c4c33e9caa5 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -1644,6 +1644,22 @@ func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) er // state and re-applies them from the new mapping. func (s *Server) modifyMapping(ctx context.Context, mapping *proto.ProxyMapping) error { if old := s.loadMapping(types.ServiceID(mapping.GetId())); old != nil { + if !types.ServiceMode(old.GetMode()).IsL4() && + !types.ServiceMode(mapping.GetMode()).IsL4() && + old.GetDomain() == mapping.GetDomain() && + len(old.GetPath()) > 0 && len(mapping.GetPath()) > 0 { + if err := s.updateMapping(ctx, mapping); err != nil { + // A rejected policy must not leave an older, potentially more + // permissive route serving requests. Keep the peer, but remove + // the snapshot so a later valid update installs routes again. + s.cleanupMappingRoutes(old) + s.deleteMapping(types.ServiceID(mapping.GetId())) + return err + } + s.storeMapping(mapping) + s.hijackTracker.CloseByHost(mapping.GetDomain()) + return nil + } s.cleanupMappingRoutes(old) if mode := types.ServiceMode(old.GetMode()); mode.IsL4() { s.meter.L4ServiceRemoved(mode) @@ -2060,13 +2076,16 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t } func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) error { - // Very simple implementation here, we don't touch the existing peer - // connection or any existing TLS configuration, we simply overwrite - // the auth and proxy mappings. - // Note: this does require the management server to always send a - // full mapping rather than deltas during a modification. accountID := types.AccountID(mapping.GetAccountId()) svcID := types.ServiceID(mapping.GetId()) + m, err := s.protoToMapping(ctx, mapping) + if err != nil { + return err + } + resolver, err := proxy.NewTargetResolver(m) + if err != nil { + return fmt.Errorf("prepare target routes: %w", err) + } var schemes []auth.Scheme if mapping.GetAuth().GetPassword() { @@ -2084,19 +2103,19 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions()) maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second - if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetAuth().GetAllowedGroupIds()); err != nil { + config, err := auth.NewDomainConfig(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetAuth().GetAllowedGroupIds(), auth.WithTargetResolver(resolver)) + if err != nil { return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err) } - m := s.protoToMapping(ctx, mapping) - // The chain is published before the route that leads to it. A request - // arriving at a target whose chain has not been rebuilt yet is served - // straight through, so a provider update that added the route first left a - // window in which an inference could complete unrouted and unmetered. - // Rebuilding first inverts that: the worst a request in the window meets is - // the new chain in front of the previous target, which is still counted. - if err := s.rebuildMiddlewareChains(svcID, m); err != nil { + revision, err := s.rebuildMiddlewareChains(svcID, m) + if err != nil { return err } + m.MiddlewareRevision = revision + config.TargetResolver = resolver.WithMiddlewareRevision(revision) + // The auth snapshot owns its resolver, so an in-flight request cannot + // authorize an old target and then forward to a replacement target. + s.auth.AddDomainConfig(mapping.GetDomain(), config) s.meter.AddMapping(m) s.proxy.AddMapping(m) return nil @@ -2161,22 +2180,17 @@ func (s *Server) initMiddlewareManager(ctx context.Context) error { return nil } -// rebuildMiddlewareChains converts m into per-path bindings and calls -// Manager.Rebuild. Short-circuits when the middleware manager is unset, which -// is a deployment without middleware rather than a failure to install it. -// -// A rebuild that fails is reported rather than logged: the caller publishes -// the route once this returns, and a route published over chains that were -// not installed serves requests with no policy enforcement and no metering. -func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) error { +// rebuildMiddlewareChains installs the service's policies before its routing snapshot. +func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) (middleware.Revision, error) { if s.middlewareManager == nil { - return nil + return 0, nil } bindings := buildMiddlewareBindings(svcID, m) - if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil { - return fmt.Errorf("rebuild middleware chains for service %s: %w", svcID, err) + revision, err := s.middlewareManager.RebuildSnapshot(string(svcID), bindings) + if err != nil { + return 0, fmt.Errorf("rebuild middleware chains for service %s: %w", svcID, err) } - return nil + return revision, nil } // isLiveService reports whether svcID is currently present in the live @@ -2261,10 +2275,10 @@ func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) { if s.acme != nil { s.acme.RemoveDomain(d) } - s.auth.RemoveDomain(host) if s.proxy.RemoveMapping(proxy.Mapping{Host: host}) { s.meter.RemoveMapping(proxy.Mapping{Host: host}) } + s.auth.RemoveDomain(host) // Close hijacked connections (WebSocket) for this domain. if n := s.hijackTracker.CloseByHost(host); n > 0 { s.Logger.Debugf("closed %d hijacked connection(s) for %s", n, host) @@ -2337,23 +2351,33 @@ func (s *Server) deleteMapping(svcID types.ServiceID) *proto.ProxyMapping { return m } -func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping) proxy.Mapping { +func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping) (proxy.Mapping, error) { + pathMappings := mapping.GetPath() + actions, strictPaths, err := parseTargetAccessActions(pathMappings) + if err != nil { + return proxy.Mapping{}, err + } + paths := make(map[string]*proxy.PathTarget) - for _, pathMapping := range mapping.GetPath() { + for i, pathMapping := range pathMappings { + path := pathMapping.GetPath() + if strictPaths && path == "" { + path = "/" + } + if strictPaths { + if _, exists := paths[path]; exists { + return proxy.Mapping{}, fmt.Errorf("duplicate target location %q", path) + } + } targetURL, err := url.Parse(pathMapping.GetTarget()) if err != nil { - s.Logger.WithFields(log.Fields{ - "service_id": mapping.GetId(), - "account_id": mapping.GetAccountId(), - "domain": mapping.GetDomain(), - "path": pathMapping.GetPath(), - "target": pathMapping.GetTarget(), - }).WithError(err).Error("failed to parse target URL for path, skipping") - s.notifyError(ctx, mapping, fmt.Errorf("invalid target URL %q for path %q: %w", pathMapping.GetTarget(), pathMapping.GetPath(), err)) - continue + return proxy.Mapping{}, fmt.Errorf("parse target URL for location %q: %w", path, err) + } + if targetURL.Host == "" || (targetURL.Scheme != "http" && targetURL.Scheme != "https") { + return proxy.Mapping{}, fmt.Errorf("invalid HTTP target URL for location %q", path) } - pt := &proxy.PathTarget{URL: targetURL} + pt := &proxy.PathTarget{URL: targetURL, AccessAction: actions[i]} if opts := pathMapping.GetOptions(); opts != nil { pt.SkipTLSVerify = opts.GetSkipTlsVerify() pt.PathRewrite = protoToPathRewrite(opts.GetPathRewrite()) @@ -2370,7 +2394,7 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping pt.DisableAccessLog = opts.GetDisableAccessLog() } pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout) - paths[pathMapping.GetPath()] = pt + paths[path] = pt } m := proxy.Mapping{ ID: types.ServiceID(mapping.GetId()), @@ -2383,7 +2407,7 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping for _, ha := range mapping.GetAuth().GetHeaderAuths() { m.StripAuthHeaders = append(m.StripAuthHeaders, ha.GetHeader()) } - return m + return m, nil } func protoToPathRewrite(mode proto.PathRewriteMode) proxy.PathRewriteMode { diff --git a/proxy/target_access_control.go b/proxy/target_access_control.go new file mode 100644 index 00000000000..9460375c99c --- /dev/null +++ b/proxy/target_access_control.go @@ -0,0 +1,40 @@ +package proxy + +import ( + "fmt" + + "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func parseTargetAccessActions(pathMappings []*proto.PathMapping) ([]proxy.AccessAction, bool, error) { + actions := make([]proxy.AccessAction, len(pathMappings)) + strictPaths := false + for i, pathMapping := range pathMappings { + if pathMapping == nil { + return nil, false, fmt.Errorf("nil target mapping") + } + action, err := targetAccessActionFromProto(pathMapping.GetAccessAction()) + if err != nil { + return nil, false, err + } + actions[i] = action + if action != proxy.AccessActionInherit { + strictPaths = true + } + } + return actions, strictPaths, nil +} + +func targetAccessActionFromProto(action proto.TargetAccessAction) (proxy.AccessAction, error) { + switch action { + case proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT: + return proxy.AccessActionInherit, nil + case proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS: + return proxy.AccessActionBypass, nil + case proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK: + return proxy.AccessActionBlock, nil + default: + return "", fmt.Errorf("unknown target access action %d", action) + } +} diff --git a/proxy/target_access_control_integration_test.go b/proxy/target_access_control_integration_test.go new file mode 100644 index 00000000000..5b470659ff3 --- /dev/null +++ b/proxy/target_access_control_integration_test.go @@ -0,0 +1,427 @@ +package proxy + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "crypto/tls" + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/metric/noop" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + + nbproxy "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/proxy" + "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/proxy/internal/auth" + proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics" + internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp" + proxytypes "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/hash/argon2id" + "github.com/netbirdio/netbird/shared/management/proto" +) + +const ( + targetAccessAccountID = "test-account-1" + targetAccessCluster = "test.proxy.io" + targetAccessDomain = "target-access.test.proxy.io" + targetAccessHeader = "X-Test-Key" + targetAccessSecret = "valid-secret" +) + +type targetAccessRequest struct { + path string + headers http.Header +} + +type targetAccessUpstream struct { + mu sync.Mutex + requests []targetAccessRequest +} + +func (u *targetAccessUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) { + u.mu.Lock() + u.requests = append(u.requests, targetAccessRequest{ + path: r.URL.Path, + headers: r.Header.Clone(), + }) + u.mu.Unlock() + w.WriteHeader(http.StatusOK) +} + +func (u *targetAccessUpstream) snapshot() []targetAccessRequest { + u.mu.Lock() + defer u.mu.Unlock() + return append([]targetAccessRequest(nil), u.requests...) +} + +func targetAccessService(t *testing.T, upstreamURL string) *service.Service { + t.Helper() + + u, err := url.Parse(upstreamURL) + require.NoError(t, err) + host, portText, err := net.SplitHostPort(u.Host) + require.NoError(t, err) + port, err := strconv.ParseUint(portText, 10, 16) + require.NoError(t, err) + + headerHash, err := argon2id.Hash(targetAccessSecret) + require.NoError(t, err) + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + + newTarget := func(path string, action service.TargetAccessAction) *service.Target { + return &service.Target{ + Path: strPtr(path), + Host: host, + Port: uint16(port), + Protocol: u.Scheme, + TargetId: "target-access-upstream", + TargetType: service.TargetTypeCluster, + Enabled: true, + AccessAction: action, + Options: service.TargetOptions{ + DirectUpstream: true, + }, + } + } + + return &service.Service{ + ID: "target-access-service", + AccountID: targetAccessAccountID, + Name: "Target Access Control", + Domain: targetAccessDomain, + ProxyCluster: targetAccessCluster, + Enabled: true, + SessionPrivateKey: base64.StdEncoding.EncodeToString(privateKey), + SessionPublicKey: base64.StdEncoding.EncodeToString(publicKey), + Targets: []*service.Target{ + newTarget("/", service.TargetAccessActionInherit), + newTarget("/public", service.TargetAccessActionBypass), + newTarget("/blocked", service.TargetAccessActionBlock), + }, + Auth: service.AuthConfig{HeaderAuths: []*service.HeaderAuthConfig{{ + Enabled: true, + Header: targetAccessHeader, + Value: headerHash, + }}}, + Restrictions: service.AccessRestrictions{ + BlockedCIDRs: []string{"203.0.113.0/24"}, + }, + } +} + +func privateServiceCapabilities() *proto.ProxyCapabilities { + supported := true + return &proto.ProxyCapabilities{ + SupportsPrivateService: &supported, + } +} + +func targetAccessMappingStream(t *testing.T, setup *integrationTestSetup, proxyID string, caps *proto.ProxyCapabilities) proto.ProxyService_GetMappingUpdateClient { + t.Helper() + + conn, err := grpc.NewClient(setup.grpcAddr, grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + t.Cleanup(cancel) + stream, err := proto.NewProxyServiceClient(conn).GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ + ProxyId: proxyID, + Version: "target-access-test", + Address: targetAccessCluster, + Capabilities: caps, + }) + require.NoError(t, err) + return stream +} + +func receiveTargetAccessSnapshot(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient) map[string]*proto.ProxyMapping { + t.Helper() + + mappings := make(map[string]*proto.ProxyMapping) + for { + msg, err := stream.Recv() + require.NoError(t, err) + for _, mapping := range msg.GetMapping() { + mappings[mapping.GetId()] = mapping + } + if msg.GetInitialSyncComplete() { + return mappings + } + } +} + +func receiveTargetAccessUpdate(t *testing.T, stream proto.ProxyService_GetMappingUpdateClient, serviceID string) *proto.ProxyMapping { + t.Helper() + + for { + msg, err := stream.Recv() + require.NoError(t, err) + for _, mapping := range msg.GetMapping() { + if mapping.GetId() == serviceID { + return mapping + } + } + } +} + +func newTargetAccessRuntime(t *testing.T) (*Server, http.Handler) { + t.Helper() + + logger := quietLifecycleLogger() + meter, err := proxymetrics.New(t.Context(), noop.Meter{}) + require.NoError(t, err) + + reverseProxy := internalproxy.NewReverseProxy(http.DefaultTransport, "auto", nil, logger) + authMiddleware := auth.NewMiddleware(logger, nil, nil) + srv := &Server{ + ctx: t.Context(), + Logger: logger, + proxy: reverseProxy, + auth: authMiddleware, + meter: meter, + mainRouter: nbtcp.NewRouter(logger, nil, &net.TCPAddr{}), + portRouters: make(map[uint16]*portRouter), + svcPorts: make(map[proxytypes.ServiceID][]uint16), + lastMappings: make(map[proxytypes.ServiceID]*proto.ProxyMapping), + crowdsecServices: make(map[proxytypes.ServiceID]bool), + removePeer: func(context.Context, proxytypes.AccountID, roundtrip.ServiceKey) error { + return nil + }, + } + + return srv, authMiddleware.Protect(reverseProxy) +} + +func applyTargetAccessMapping(t *testing.T, srv *Server, mapping *proto.ProxyMapping) { + t.Helper() + require.NoError(t, srv.updateMapping(t.Context(), mapping)) + srv.storeMapping(mapping) +} + +func targetAccessRequestTo(handler http.Handler, path, remoteAddr string, headers http.Header) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, "http://"+targetAccessDomain+path, nil) + req.Host = targetAccessDomain + req.RemoteAddr = remoteAddr + for name, values := range headers { + for _, value := range values { + req.Header.Add(name, value) + } + } + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + return recorder +} + +func TestModifyHTTPMappingFromEmptyPathsRegistersRoute(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-Test-Upstream", "reached") + w.WriteHeader(http.StatusNoContent) + })) + t.Cleanup(upstream.Close) + + runtime, handler := newTargetAccessRuntime(t) + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = listener.Close() }) + + router := nbtcp.NewRouter(runtime.Logger, func(proxytypes.AccountID) (proxytypes.DialContextFunc, error) { + return func(context.Context, string, string) (net.Conn, error) { + return nil, net.ErrClosed + }, nil + }, listener.Addr()) + router.SetFallback(nbtcp.Route{ + Type: nbtcp.RouteTCP, + AccountID: "fallback-account", + ServiceID: "fallback-service", + Target: "unreachable.test:443", + }) + runtime.mainRouter = router + + httpServer := &http.Server{ + Handler: handler, + TLSConfig: selfSignedTLSConfig(t), + ReadHeaderTimeout: time.Second, + } + t.Cleanup(func() { _ = httpServer.Close() }) + go func() { _ = httpServer.ServeTLS(router.HTTPListener(), "", "") }() + go feedRouterFromListener(t.Context(), listener, router, runtime.Logger, proxytypes.AccountID(targetAccessAccountID)) + + previous := &proto.ProxyMapping{ + Id: "empty-to-routable", + AccountId: targetAccessAccountID, + Domain: targetAccessDomain, + } + runtime.storeMapping(previous) + replacement := &proto.ProxyMapping{ + Id: previous.GetId(), + AccountId: previous.GetAccountId(), + Domain: previous.GetDomain(), + Path: []*proto.PathMapping{{ + Path: "/", + Target: upstream.URL, + }}, + } + require.NoError(t, runtime.modifyMapping(t.Context(), replacement)) + + transport := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, //nolint:gosec + DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, network, listener.Addr().String()) + }, + } + t.Cleanup(transport.CloseIdleConnections) + client := &http.Client{Transport: transport, Timeout: 3 * time.Second} + response, err := client.Get("https://" + targetAccessDomain + "/ready") + require.NoError(t, err, "first target must register the domain with the SNI router") + t.Cleanup(func() { _ = response.Body.Close() }) + assert.Equal(t, http.StatusNoContent, response.StatusCode) + assert.Equal(t, "reached", response.Header.Get("X-Test-Upstream")) +} + +func TestIntegration_TargetAccessControl_ManagementToUpstream(t *testing.T) { + setup := setupIntegrationTest(t) + t.Cleanup(setup.cleanup) + + upstreamRecorder := &targetAccessUpstream{} + upstream := httptest.NewServer(upstreamRecorder) + t.Cleanup(upstream.Close) + + svc := targetAccessService(t, upstream.URL) + require.NoError(t, setup.store.CreateService(t.Context(), svc)) + + stream := targetAccessMappingStream(t, setup, "target-access-proxy", nil) + snapshot := receiveTargetAccessSnapshot(t, stream) + mapping := snapshot[svc.ID] + require.NotNil(t, mapping, "proxy must receive the target-access service") + + actions := make(map[string]proto.TargetAccessAction) + for _, pathMapping := range mapping.GetPath() { + actions[pathMapping.GetPath()] = pathMapping.GetAccessAction() + } + assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, actions["/"]) + assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, actions["/public"]) + assert.Equal(t, proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, actions["/blocked"]) + + runtime, handler := newTargetAccessRuntime(t) + applyTargetAccessMapping(t, runtime, mapping) + + before := len(upstreamRecorder.snapshot()) + res := targetAccessRequestTo(handler, "/private", "198.51.100.10:12000", nil) + assert.Equal(t, http.StatusUnauthorized, res.Code, "inherit must retain service authentication") + assert.Len(t, upstreamRecorder.snapshot(), before, "unauthenticated inherited request must not reach upstream") + + validHeader := http.Header{targetAccessHeader: []string{targetAccessSecret}} + res = targetAccessRequestTo(handler, "/private", "198.51.100.10:12000", validHeader) + assert.Equal(t, http.StatusOK, res.Code, "valid service authentication must pass an inherited target") + inheritedRequests := upstreamRecorder.snapshot() + require.Len(t, inheritedRequests, before+1) + assert.Empty(t, inheritedRequests[len(inheritedRequests)-1].headers.Get(targetAccessHeader), + "proxy authentication credential must not be forwarded upstream") + + bypassHeaders := http.Header{ + targetAccessHeader: []string{"spoofed-auth"}, + "X-NetBird-User": []string{"spoofed@example.com"}, + "X-NetBird-Groups": []string{"administrators"}, + } + res = targetAccessRequestTo(handler, "/public/ready", "198.51.100.10:12000", bypassHeaders) + assert.Equal(t, http.StatusOK, res.Code, "bypass target must reach upstream without authenticating") + bypassRequests := upstreamRecorder.snapshot() + require.Len(t, bypassRequests, before+2) + bypassed := bypassRequests[len(bypassRequests)-1] + assert.Equal(t, "/ready", bypassed.path, "routing must retain the existing literal-prefix rewrite") + assert.Empty(t, bypassed.headers.Get(targetAccessHeader), "configured auth header must be stripped on bypass") + assert.Empty(t, bypassed.headers.Get("X-NetBird-User"), "bypass must not permit spoofed identity") + assert.Empty(t, bypassed.headers.Get("X-NetBird-Groups"), "bypass must not permit spoofed groups") + + before = len(bypassRequests) + res = targetAccessRequestTo(handler, "/blocked/secret", "198.51.100.10:12000", validHeader) + assert.Equal(t, http.StatusForbidden, res.Code, "block must win even when service authentication is valid") + assert.Len(t, upstreamRecorder.snapshot(), before, "blocked request must not reach upstream") + + res = targetAccessRequestTo(handler, "/public/ready", "203.0.113.8:12000", nil) + assert.Equal(t, http.StatusForbidden, res.Code, "connection restrictions must run before bypass") + assert.Len(t, upstreamRecorder.snapshot(), before, "restricted bypass request must not reach upstream") + + for _, target := range svc.Targets { + switch *target.Path { + case "/public": + target.AccessAction = service.TargetAccessActionBlock + case "/blocked": + target.AccessAction = service.TargetAccessActionBypass + } + } + require.NoError(t, setup.store.UpdateService(t.Context(), svc)) + persisted, err := setup.store.GetServiceByID(t.Context(), store.LockingStrengthNone, targetAccessAccountID, svc.ID) + require.NoError(t, err) + update := persisted.ToProtoMapping(service.Update, "", nbproxy.OIDCValidationConfig{}) + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{update}}) + receivedUpdate := receiveTargetAccessUpdate(t, stream, svc.ID) + applyTargetAccessMapping(t, runtime, receivedUpdate) + + res = targetAccessRequestTo(handler, "/public/ready", "198.51.100.10:12000", validHeader) + assert.Equal(t, http.StatusForbidden, res.Code, "modified action must replace bypass with block") + assert.Len(t, upstreamRecorder.snapshot(), before, "newly blocked request must not reach upstream") + + res = targetAccessRequestTo(handler, "/blocked/ready", "198.51.100.10:12000", nil) + assert.Equal(t, http.StatusOK, res.Code, "modified action must replace block with bypass") + assert.Len(t, upstreamRecorder.snapshot(), before+1, "newly bypassed request must reach upstream") + + require.NoError(t, setup.store.DeleteService(t.Context(), targetAccessAccountID, svc.ID)) + removed := persisted.ToProtoMapping(service.Delete, "", nbproxy.OIDCValidationConfig{}) + setup.proxyService.SendServiceUpdate(&proto.GetMappingUpdateResponse{Mapping: []*proto.ProxyMapping{removed}}) + receivedRemoval := receiveTargetAccessUpdate(t, stream, svc.ID) + runtime.removeMapping(t.Context(), receivedRemoval) + + res = targetAccessRequestTo(handler, "/blocked/ready", "198.51.100.10:12000", nil) + assert.Equal(t, http.StatusNotFound, res.Code, "removed mapping must no longer route") + assert.Len(t, upstreamRecorder.snapshot(), before+1, "request after removal must not reach upstream") +} + +func TestIntegration_TargetAccessControl_PrivateServiceRejectsBypass(t *testing.T) { + setup := setupIntegrationTest(t) + t.Cleanup(setup.cleanup) + + upstreamRecorder := &targetAccessUpstream{} + upstream := httptest.NewServer(upstreamRecorder) + t.Cleanup(upstream.Close) + + svc := targetAccessService(t, upstream.URL) + svc.ID = "target-access-private" + svc.Domain = "target-access-private.test.proxy.io" + svc.Private = true + svc.Targets = svc.Targets[1:2] + require.NoError(t, setup.store.CreateService(t.Context(), svc)) + + stream := targetAccessMappingStream(t, setup, "target-access-private", privateServiceCapabilities()) + mapping := receiveTargetAccessSnapshot(t, stream)[svc.ID] + require.NotNil(t, mapping, "proxy supporting private services must receive private target-access mapping") + + runtime, handler := newTargetAccessRuntime(t) + err := runtime.updateMapping(t.Context(), mapping) + require.Error(t, err, "private service with a bypass target must be rejected") + + req := httptest.NewRequest(http.MethodGet, "http://"+svc.Domain+"/public/ready", nil) + req.Host = svc.Domain + req.RemoteAddr = "100.64.0.20:12000" + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + + assert.Equal(t, http.StatusNotFound, recorder.Code, "rejected private mapping must not be published") + assert.Empty(t, upstreamRecorder.snapshot(), "rejected private mapping must not reach upstream") +} diff --git a/proxy/target_access_control_test.go b/proxy/target_access_control_test.go new file mode 100644 index 00000000000..a1be3a83b3c --- /dev/null +++ b/proxy/target_access_control_test.go @@ -0,0 +1,151 @@ +package proxy + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + + internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy" + managementproto "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestTargetAccessActionFromProto(t *testing.T) { + tests := []struct { + name string + wire managementproto.TargetAccessAction + want internalproxy.AccessAction + }{ + {"inherit", managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, internalproxy.AccessActionInherit}, + {"bypass", managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, internalproxy.AccessActionBypass}, + {"block", managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, internalproxy.AccessActionBlock}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := targetAccessActionFromProto(tt.wire) + require.NoError(t, err) + assert.Equal(t, tt.want, got, "wire action must retain its enforcement meaning") + }) + } + _, err := targetAccessActionFromProto(managementproto.TargetAccessAction(99)) + require.Error(t, err, "an unknown wire action must reject the mapping") +} + +func TestProtoToMappingPreservesLegacyDuplicateLocations(t *testing.T) { + runtime, _ := newTargetAccessRuntime(t) + mapping, err := runtime.protoToMapping(t.Context(), &managementproto.ProxyMapping{ + Id: "legacy-service", AccountId: "account", Domain: targetAccessDomain, + Path: []*managementproto.PathMapping{ + {Path: "/", Target: "http://first.internal"}, + {Path: "/", Target: "https://second.internal"}, + {Path: "", Target: "http://empty.internal"}, + }, + }) + require.NoError(t, err) + require.Len(t, mapping.Paths, 2, "legacy empty and slash locations must remain distinct") + assert.Equal(t, "second.internal", mapping.Paths["/"].URL.Host, + "legacy duplicate locations must retain last-entry-wins behavior") + assert.Equal(t, "empty.internal", mapping.Paths[""].URL.Host) +} + +func TestProtoToMappingRejectsDuplicateLocationsWhenAccessControlIsActive(t *testing.T) { + tests := []struct { + name string + paths []*managementproto.PathMapping + }{ + { + name: "duplicate action location", + paths: []*managementproto.PathMapping{ + {Path: "/", Target: "http://first.internal"}, + {Path: "/", Target: "http://second.internal", AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK}, + }, + }, + { + name: "root aliases", + paths: []*managementproto.PathMapping{ + {Path: "", Target: "http://first.internal"}, + {Path: "/", Target: "http://second.internal", AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS}, + }, + }, + { + name: "action elsewhere", + paths: []*managementproto.PathMapping{ + {Path: "/", Target: "http://first.internal"}, + {Path: "/", Target: "http://second.internal"}, + {Path: "/public", Target: "http://public.internal", AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime, _ := newTargetAccessRuntime(t) + _, err := runtime.protoToMapping(t.Context(), &managementproto.ProxyMapping{ + Id: "guarded-service", AccountId: "account", Domain: targetAccessDomain, Path: tt.paths, + }) + assert.ErrorContains(t, err, `duplicate target location "/"`) + }) + } +} + +func TestModifyHTTPMappingRejectsInvalidReplacement(t *testing.T) { + tests := []struct { + name string + modify func(*managementproto.ProxyMapping) + }{ + {"unknown action", func(m *managementproto.ProxyMapping) { + m.Path[0].AccessAction = managementproto.TargetAccessAction(99) + }}, + {"duplicate locations", func(m *managementproto.ProxyMapping) { + m.Path = append(m.Path, &managementproto.PathMapping{Path: "/", Target: "http://127.0.0.1:1"}) + }}, + {"duplicate root aliases", func(m *managementproto.ProxyMapping) { + m.Path = append(m.Path, &managementproto.PathMapping{Target: "http://127.0.0.1:1"}) + }}, + {"nil target", func(m *managementproto.ProxyMapping) { + m.Path = append(m.Path, nil) + }}, + {"malformed URL", func(m *managementproto.ProxyMapping) { + m.Path[0].Target = "http://[" + }}, + {"missing host", func(m *managementproto.ProxyMapping) { + m.Path[0].Target = "http:/backend" + }}, + {"unsupported protocol", func(m *managementproto.ProxyMapping) { + m.Path[0].Target = "ftp://backend" + }}, + {"private bypass", func(m *managementproto.ProxyMapping) { + m.Private = true + m.Path[0].AccessAction = managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS + }}, + {"agent network bypass", func(m *managementproto.ProxyMapping) { + m.Path[0].AccessAction = managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS + m.Path[0].Options = &managementproto.PathTargetOptions{AgentNetwork: true} + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runtime, handler := newTargetAccessRuntime(t) + original := &managementproto.ProxyMapping{ + Id: "guarded-service", AccountId: "account", Domain: targetAccessDomain, + Path: []*managementproto.PathMapping{{ + Path: "/", Target: "http://127.0.0.1:1", + AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, + }}, + } + applyTargetAccessMapping(t, runtime, original) + replacement := proto.Clone(original).(*managementproto.ProxyMapping) + tt.modify(replacement) + require.Error(t, runtime.modifyMapping(t.Context(), replacement)) + response := targetAccessRequestTo(handler, "/protected", "192.0.2.1:1234", nil) + assert.Equal(t, http.StatusNotFound, response.Code, + "invalid replacement must withdraw the route instead of retaining a stale policy") + require.NoError(t, runtime.modifyMapping(t.Context(), original), + "a valid update must reinstall a previously withdrawn route") + recovered := targetAccessRequestTo(handler, "/protected", "192.0.2.1:1234", nil) + assert.Equal(t, http.StatusForbidden, recovered.Code, "the reinstalled block must be enforced") + }) + } +} diff --git a/proxy/target_middleware_revision_test.go b/proxy/target_middleware_revision_test.go new file mode 100644 index 00000000000..7dd5453a2de --- /dev/null +++ b/proxy/target_middleware_revision_test.go @@ -0,0 +1,115 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + protobuf "google.golang.org/protobuf/proto" + + "github.com/netbirdio/netbird/proxy/internal/middleware" + guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail" + internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy" + "github.com/netbirdio/netbird/shared/management/proto" +) + +func TestUpdateMappingBindsMiddlewareRevisionAcrossPolicyUpdates(t *testing.T) { + oldUpstream := &targetAccessUpstream{} + oldBackend := httptest.NewServer(oldUpstream) + t.Cleanup(oldBackend.Close) + newUpstream := &targetAccessUpstream{} + newBackend := httptest.NewServer(newUpstream) + t.Cleanup(newBackend.Close) + + runtime, _ := newTargetAccessRuntime(t) + registry := middleware.NewRegistry() + require.NoError(t, registry.Register(guardrail.Factory{})) + manager := middleware.NewManager(0, nil, runtime.Logger) + manager.SetResolver(middleware.NewResolver(registry)) + manager.SetLiveServiceCheck(runtime.isLiveService) + t.Cleanup(manager.InvalidateAll) + runtime.middlewareRegistry = registry + runtime.middlewareManager = manager + runtime.proxy = internalproxy.NewReverseProxy(http.DefaultTransport, "auto", nil, runtime.Logger, + internalproxy.WithMiddlewareManager(manager)) + handler := runtime.auth.Protect(runtime.proxy) + + mapping := &proto.ProxyMapping{ + Id: "middleware-revision-service", AccountId: targetAccessAccountID, Domain: targetAccessDomain, + Path: []*proto.PathMapping{{ + Path: "/", Target: oldBackend.URL, + Options: &proto.PathTargetOptions{ + AgentNetwork: true, + DirectUpstream: true, + Middlewares: []*proto.MiddlewareConfig{{ + Id: guardrail.ID, + Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST, + Enabled: true, + ConfigJson: []byte(`{"provider_allowlists":{"provider-1":["allowed-model"]}}`), + }}, + }, + }}, + } + applyTargetAccessMapping(t, runtime, mapping) + initial := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil) + require.Equal(t, http.StatusForbidden, initial.Code, + "the original guardrail must reject a request with no known model") + require.Empty(t, oldUpstream.snapshot(), "the original policy must prevent upstream forwarding") + + // Hold the request at the auth/proxy boundary so updateMapping runs after + // authorization has pinned the route but before its middleware is selected. + captureAuthorizedRequest := func() *http.Request { + var pinned *http.Request + capture := runtime.auth.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + pinned = r + w.WriteHeader(http.StatusNoContent) + })) + response := targetAccessRequestTo(capture, "/v1/chat/completions", "192.0.2.1:1234", nil) + require.Equal(t, http.StatusNoContent, response.Code, "auth must pass the resolved request to the proxy") + require.NotNil(t, pinned, "the request must be captured after auth target resolution") + return pinned + } + staleRequest := captureAuthorizedRequest() + oldChain := manager.ChainFor(mapping.GetId(), "/") + require.NotNil(t, oldChain, "updateMapping must install the original guardrail chain") + + replacement := protobuf.Clone(mapping).(*proto.ProxyMapping) + replacement.Path[0].Target = newBackend.URL + replacement.Path[0].Options.Middlewares[0].ConfigJson = []byte(`{}`) + applyTargetAccessMapping(t, runtime, replacement) + newChain := manager.ChainFor(mapping.GetId(), "/") + require.NotNil(t, newChain, "updateMapping must install the replacement guardrail chain") + require.NotSame(t, oldChain, newChain, "a policy update must replace the middleware chain") + + staleResponse := httptest.NewRecorder() + runtime.proxy.ServeHTTP(staleResponse, staleRequest) + assert.Equal(t, http.StatusServiceUnavailable, staleResponse.Code, + "a request authorized against the old target must not execute the replacement policy") + assert.Equal(t, "no-store", staleResponse.Header().Get("Cache-Control"), "stale policy failures must not be cached") + assert.Empty(t, oldUpstream.snapshot(), "a replacement policy must never authorize the old upstream") + assert.Empty(t, newUpstream.snapshot(), "a stale request must not forward to the replacement upstream") + + fresh := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil) + require.Equal(t, http.StatusOK, fresh.Code, "fresh auth resolution must use the replacement middleware revision") + require.Len(t, newUpstream.snapshot(), 1, "the replacement target must receive the fresh request") + + // Provider changes rebuild middleware instances without replacing the + // service mapping, so requests pinned to its current revision stay valid. + currentRequest := captureAuthorizedRequest() + manager.InvalidateMiddleware(guardrail.ID) + refreshedChain := manager.ChainFor(mapping.GetId(), "/") + require.NotNil(t, refreshedChain, "a live service must retain its chain after provider refresh") + require.NotSame(t, newChain, refreshedChain, "provider refresh must rebuild the concrete guardrail chain") + + currentResponse := httptest.NewRecorder() + runtime.proxy.ServeHTTP(currentResponse, currentRequest) + assert.Equal(t, http.StatusOK, currentResponse.Code, "provider refresh must preserve the current request's revision") + assert.Len(t, newUpstream.snapshot(), 2, "the current request must reach the replacement target after refresh") + + afterRefresh := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil) + assert.Equal(t, http.StatusOK, afterRefresh.Code, "new requests must continue after provider refresh") + assert.Len(t, newUpstream.snapshot(), 3, "fresh requests must use the rebuilt chain and current target") + assert.Empty(t, oldUpstream.snapshot(), "no request may reach the retired target") +} diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index a8e0a7a5677..38c45f5ef6d 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -3090,6 +3090,12 @@ components: type: string description: URL path prefix for this target (HTTP only) example: "/" + access_action: + type: string + description: Access action applied after this target's HTTP path prefix is selected. "inherit" uses the service authentication configuration, "bypass" skips service authentication, and "block" denies access. HTTP services only. + enum: [inherit, bypass, block] + default: inherit + example: "inherit" protocol: type: string description: Protocol to use when connecting to the backend diff --git a/shared/management/http/api/types.gen.go b/shared/management/http/api/types.gen.go index 9be676a9132..ef875e864b4 100644 --- a/shared/management/http/api/types.gen.go +++ b/shared/management/http/api/types.gen.go @@ -1037,6 +1037,27 @@ func (e ServiceRequestMode) Valid() bool { } } +// Defines values for ServiceTargetAccessAction. +const ( + ServiceTargetAccessActionBlock ServiceTargetAccessAction = "block" + ServiceTargetAccessActionBypass ServiceTargetAccessAction = "bypass" + ServiceTargetAccessActionInherit ServiceTargetAccessAction = "inherit" +) + +// Valid indicates whether the value is a known member of the ServiceTargetAccessAction enum. +func (e ServiceTargetAccessAction) Valid() bool { + switch e { + case ServiceTargetAccessActionBlock: + return true + case ServiceTargetAccessActionBypass: + return true + case ServiceTargetAccessActionInherit: + return true + default: + return false + } +} + // Defines values for ServiceTargetProtocol. const ( ServiceTargetProtocolHttp ServiceTargetProtocol = "http" @@ -5104,6 +5125,9 @@ type ServiceRequestMode string // ServiceTarget defines model for ServiceTarget. type ServiceTarget struct { + // AccessAction Access action applied after this target's HTTP path prefix is selected. "inherit" uses the service authentication configuration, "bypass" skips service authentication, and "block" denies access. HTTP services only. + AccessAction *ServiceTargetAccessAction `json:"access_action,omitempty"` + // Enabled Whether this target is enabled Enabled bool `json:"enabled"` @@ -5127,6 +5151,9 @@ type ServiceTarget struct { TargetType ServiceTargetTargetType `json:"target_type"` } +// ServiceTargetAccessAction Access action applied after this target's HTTP path prefix is selected. "inherit" uses the service authentication configuration, "bypass" skips service authentication, and "block" denies access. HTTP services only. +type ServiceTargetAccessAction string + // ServiceTargetProtocol Protocol to use when connecting to the backend type ServiceTargetProtocol string diff --git a/shared/management/proto/proxy_service.pb.go b/shared/management/proto/proxy_service.pb.go index 09dff0d3643..fe5cc012596 100644 --- a/shared/management/proto/proxy_service.pb.go +++ b/shared/management/proto/proxy_service.pb.go @@ -117,6 +117,55 @@ func (PathRewriteMode) EnumDescriptor() ([]byte, []int) { return file_proxy_service_proto_rawDescGZIP(), []int{1} } +type TargetAccessAction int32 + +const ( + TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT TargetAccessAction = 0 + TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS TargetAccessAction = 1 + TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK TargetAccessAction = 2 +) + +// Enum value maps for TargetAccessAction. +var ( + TargetAccessAction_name = map[int32]string{ + 0: "TARGET_ACCESS_ACTION_INHERIT", + 1: "TARGET_ACCESS_ACTION_BYPASS", + 2: "TARGET_ACCESS_ACTION_BLOCK", + } + TargetAccessAction_value = map[string]int32{ + "TARGET_ACCESS_ACTION_INHERIT": 0, + "TARGET_ACCESS_ACTION_BYPASS": 1, + "TARGET_ACCESS_ACTION_BLOCK": 2, + } +) + +func (x TargetAccessAction) Enum() *TargetAccessAction { + p := new(TargetAccessAction) + *p = x + return p +} + +func (x TargetAccessAction) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TargetAccessAction) Descriptor() protoreflect.EnumDescriptor { + return file_proxy_service_proto_enumTypes[2].Descriptor() +} + +func (TargetAccessAction) Type() protoreflect.EnumType { + return &file_proxy_service_proto_enumTypes[2] +} + +func (x TargetAccessAction) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TargetAccessAction.Descriptor instead. +func (TargetAccessAction) EnumDescriptor() ([]byte, []int) { + return file_proxy_service_proto_rawDescGZIP(), []int{2} +} + // MiddlewareSlot identifies where in the request lifecycle a middleware // runs. Mirrors proxy/internal/middleware.Slot. type MiddlewareSlot int32 @@ -155,11 +204,11 @@ func (x MiddlewareSlot) String() string { } func (MiddlewareSlot) Descriptor() protoreflect.EnumDescriptor { - return file_proxy_service_proto_enumTypes[2].Descriptor() + return file_proxy_service_proto_enumTypes[3].Descriptor() } func (MiddlewareSlot) Type() protoreflect.EnumType { - return &file_proxy_service_proto_enumTypes[2] + return &file_proxy_service_proto_enumTypes[3] } func (x MiddlewareSlot) Number() protoreflect.EnumNumber { @@ -168,7 +217,7 @@ func (x MiddlewareSlot) Number() protoreflect.EnumNumber { // Deprecated: Use MiddlewareSlot.Descriptor instead. func (MiddlewareSlot) EnumDescriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{2} + return file_proxy_service_proto_rawDescGZIP(), []int{3} } type ProxyStatus int32 @@ -213,11 +262,11 @@ func (x ProxyStatus) String() string { } func (ProxyStatus) Descriptor() protoreflect.EnumDescriptor { - return file_proxy_service_proto_enumTypes[3].Descriptor() + return file_proxy_service_proto_enumTypes[4].Descriptor() } func (ProxyStatus) Type() protoreflect.EnumType { - return &file_proxy_service_proto_enumTypes[3] + return &file_proxy_service_proto_enumTypes[4] } func (x ProxyStatus) Number() protoreflect.EnumNumber { @@ -226,7 +275,7 @@ func (x ProxyStatus) Number() protoreflect.EnumNumber { // Deprecated: Use ProxyStatus.Descriptor instead. func (ProxyStatus) EnumDescriptor() ([]byte, []int) { - return file_proxy_service_proto_rawDescGZIP(), []int{3} + return file_proxy_service_proto_rawDescGZIP(), []int{4} } type MiddlewareConfig_FailMode int32 @@ -259,11 +308,11 @@ func (x MiddlewareConfig_FailMode) String() string { } func (MiddlewareConfig_FailMode) Descriptor() protoreflect.EnumDescriptor { - return file_proxy_service_proto_enumTypes[4].Descriptor() + return file_proxy_service_proto_enumTypes[5].Descriptor() } func (MiddlewareConfig_FailMode) Type() protoreflect.EnumType { - return &file_proxy_service_proto_enumTypes[4] + return &file_proxy_service_proto_enumTypes[5] } func (x MiddlewareConfig_FailMode) Number() protoreflect.EnumNumber { @@ -777,9 +826,10 @@ type PathMapping struct { sizeCache protoimpl.SizeCache unknownFields protoimpl.UnknownFields - Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` - Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` - Options *PathTargetOptions `protobuf:"bytes,3,opt,name=options,proto3" json:"options,omitempty"` + Path string `protobuf:"bytes,1,opt,name=path,proto3" json:"path,omitempty"` + Target string `protobuf:"bytes,2,opt,name=target,proto3" json:"target,omitempty"` + Options *PathTargetOptions `protobuf:"bytes,3,opt,name=options,proto3" json:"options,omitempty"` + AccessAction TargetAccessAction `protobuf:"varint,4,opt,name=access_action,json=accessAction,proto3,enum=management.TargetAccessAction" json:"access_action,omitempty"` } func (x *PathMapping) Reset() { @@ -835,6 +885,13 @@ func (x *PathMapping) GetOptions() *PathTargetOptions { return nil } +func (x *PathMapping) GetAccessAction() TargetAccessAction { + if x != nil { + return x.AccessAction + } + return TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT +} + type HeaderAuth struct { state protoimpl.MessageState sizeCache protoimpl.SizeCache @@ -3325,442 +3382,454 @@ var file_proxy_service_proto_rawDesc = []byte{ 0x18, 0x07, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x63, 0x61, 0x6e, 0x4d, 0x75, 0x74, 0x61, 0x74, 0x65, 0x22, 0x2a, 0x0a, 0x08, 0x46, 0x61, 0x69, 0x6c, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x0d, 0x0a, 0x09, 0x46, 0x41, 0x49, 0x4c, 0x5f, 0x4f, 0x50, 0x45, 0x4e, 0x10, 0x00, 0x12, 0x0f, 0x0a, 0x0b, - 0x46, 0x41, 0x49, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x44, 0x10, 0x01, 0x22, 0x72, 0x0a, - 0x0b, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, - 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x6f, 0x70, 0x74, 0x69, - 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, - 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, 0x65, - 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, 0x6e, - 0x73, 0x22, 0x47, 0x0a, 0x0a, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x12, - 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x61, 0x73, 0x68, 0x65, - 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, - 0x61, 0x73, 0x68, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x22, 0x91, 0x02, 0x0a, 0x0e, 0x41, - 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, - 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x35, - 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x67, - 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, - 0x14, 0x6d, 0x61, 0x78, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x41, 0x67, 0x65, 0x53, 0x65, - 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, - 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, - 0x70, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x12, 0x39, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, - 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, - 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, - 0x68, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0f, 0x61, - 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0xdd, - 0x01, 0x0a, 0x12, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, - 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, - 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x61, 0x6c, - 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x6c, - 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, - 0x2b, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, - 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x61, 0x6c, 0x6c, 0x6f, - 0x77, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, - 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, - 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, - 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x63, 0x72, 0x6f, - 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x4d, 0x6f, 0x64, 0x65, 0x22, 0x80, - 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, - 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, - 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, - 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, - 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2b, - 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2e, 0x0a, 0x04, 0x61, 0x75, - 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, - 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x61, - 0x73, 0x73, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x08, - 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, 0x48, 0x6f, 0x73, 0x74, 0x48, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, - 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x10, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, - 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, - 0x70, 0x6f, 0x72, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, - 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x4f, 0x0a, 0x13, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x5f, 0x72, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x0c, 0x20, - 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, - 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, - 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, - 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, - 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, - 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, - 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, - 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xa9, 0x05, 0x0a, 0x09, - 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, - 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 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, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, - 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, - 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, - 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, - 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, - 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, - 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, - 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, - 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, - 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, - 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, - 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, - 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, - 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, - 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, - 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, - 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, - 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, - 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, - 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, - 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, - 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, - 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x61, 0x67, - 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, - 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, - 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, - 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, - 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, - 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, - 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, - 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, - 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, - 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, - 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, - 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, - 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, - 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, - 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, - 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, - 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, - 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, - 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, - 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, - 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, - 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, - 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, - 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, - 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 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, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, - 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, - 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, - 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, - 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, - 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, - 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, - 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, - 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, - 0x72, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, - 0x65, 0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, - 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, - 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, - 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, - 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, - 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, - 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, - 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, - 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, - 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, - 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, + 0x46, 0x41, 0x49, 0x4c, 0x5f, 0x43, 0x4c, 0x4f, 0x53, 0x45, 0x44, 0x10, 0x01, 0x22, 0xb7, 0x01, + 0x0a, 0x0b, 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x12, 0x0a, + 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x70, 0x61, 0x74, + 0x68, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x74, 0x61, 0x72, 0x67, 0x65, 0x74, 0x12, 0x37, 0x0a, 0x07, 0x6f, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, + 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x74, 0x68, 0x54, 0x61, 0x72, 0x67, + 0x65, 0x74, 0x4f, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x07, 0x6f, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x73, 0x12, 0x43, 0x0a, 0x0d, 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x61, 0x63, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x54, 0x61, 0x72, 0x67, 0x65, 0x74, 0x41, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0c, 0x61, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x47, 0x0a, 0x0a, 0x48, 0x65, 0x61, 0x64, 0x65, + 0x72, 0x41, 0x75, 0x74, 0x68, 0x12, 0x16, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x21, 0x0a, + 0x0c, 0x68, 0x61, 0x73, 0x68, 0x65, 0x64, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x61, 0x73, 0x68, 0x65, 0x64, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x22, 0x91, 0x02, 0x0a, 0x0e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x6b, + 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x4b, 0x65, 0x79, 0x12, 0x35, 0x0a, 0x17, 0x6d, 0x61, 0x78, 0x5f, 0x73, 0x65, 0x73, 0x73, + 0x69, 0x6f, 0x6e, 0x5f, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x03, 0x52, 0x14, 0x6d, 0x61, 0x78, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x41, 0x67, 0x65, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1a, 0x0a, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x70, + 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, 0x10, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x08, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x12, 0x0a, 0x04, 0x6f, 0x69, 0x64, + 0x63, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x04, 0x6f, 0x69, 0x64, 0x63, 0x12, 0x39, 0x0a, + 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x73, 0x18, 0x06, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x0b, 0x68, 0x65, 0x61, + 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x73, 0x12, 0x2a, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, + 0x77, 0x65, 0x64, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x07, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0f, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x49, 0x64, 0x73, 0x22, 0xdd, 0x01, 0x0a, 0x12, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, + 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x61, + 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x0c, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x69, 0x64, 0x72, 0x73, + 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x69, 0x64, 0x72, + 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, + 0x43, 0x69, 0x64, 0x72, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, + 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, + 0x52, 0x10, 0x61, 0x6c, 0x6c, 0x6f, 0x77, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, + 0x65, 0x73, 0x12, 0x2b, 0x0a, 0x11, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x5f, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x09, 0x52, 0x10, 0x62, + 0x6c, 0x6f, 0x63, 0x6b, 0x65, 0x64, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x72, 0x69, 0x65, 0x73, 0x12, + 0x23, 0x0a, 0x0d, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, 0x5f, 0x6d, 0x6f, 0x64, 0x65, + 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x72, 0x6f, 0x77, 0x64, 0x73, 0x65, 0x63, + 0x4d, 0x6f, 0x64, 0x65, 0x22, 0x80, 0x04, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, + 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x36, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x0e, 0x32, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x0e, 0x0a, + 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, + 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, + 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, + 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x2b, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x05, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x50, 0x61, 0x74, 0x68, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x04, 0x70, 0x61, 0x74, + 0x68, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x75, 0x74, 0x68, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x2e, 0x0a, 0x04, 0x61, 0x75, 0x74, 0x68, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, + 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, 0x74, 0x68, + 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x04, 0x61, 0x75, 0x74, 0x68, + 0x12, 0x28, 0x0a, 0x10, 0x70, 0x61, 0x73, 0x73, 0x5f, 0x68, 0x6f, 0x73, 0x74, 0x5f, 0x68, 0x65, + 0x61, 0x64, 0x65, 0x72, 0x18, 0x08, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0e, 0x70, 0x61, 0x73, 0x73, + 0x48, 0x6f, 0x73, 0x74, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x2b, 0x0a, 0x11, 0x72, 0x65, + 0x77, 0x72, 0x69, 0x74, 0x65, 0x5f, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x18, + 0x09, 0x20, 0x01, 0x28, 0x08, 0x52, 0x10, 0x72, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x52, 0x65, + 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x73, 0x12, 0x12, 0x0a, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x18, + 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6d, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6c, + 0x69, 0x73, 0x74, 0x65, 0x6e, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x05, + 0x52, 0x0a, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x4f, 0x0a, 0x13, + 0x61, 0x63, 0x63, 0x65, 0x73, 0x73, 0x5f, 0x72, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x52, 0x65, 0x73, + 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x52, 0x12, 0x61, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x52, 0x65, 0x73, 0x74, 0x72, 0x69, 0x63, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x18, 0x0a, + 0x07, 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, + 0x70, 0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x22, 0x3f, 0x0a, 0x14, 0x53, 0x65, 0x6e, 0x64, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x27, 0x0a, 0x03, 0x6c, 0x6f, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x4c, 0x6f, 0x67, 0x52, 0x03, 0x6c, 0x6f, 0x67, 0x22, 0x17, 0x0a, 0x15, 0x53, 0x65, 0x6e, 0x64, + 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0xa9, 0x05, 0x0a, 0x09, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, + 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x01, 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, 0x09, + 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x12, 0x15, 0x0a, 0x06, 0x6c, 0x6f, 0x67, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6c, 0x6f, 0x67, 0x49, 0x64, + 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x03, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, + 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x12, + 0x0a, 0x04, 0x68, 0x6f, 0x73, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x68, 0x6f, + 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x70, 0x61, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x04, 0x70, 0x61, 0x74, 0x68, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x5f, 0x6d, 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0a, 0x64, 0x75, 0x72, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x4d, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, + 0x64, 0x18, 0x08, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x6d, 0x65, 0x74, 0x68, 0x6f, 0x64, 0x12, + 0x23, 0x0a, 0x0d, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x5f, 0x63, 0x6f, 0x64, 0x65, + 0x18, 0x09, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0c, 0x72, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1b, 0x0a, 0x09, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x5f, 0x69, + 0x70, 0x18, 0x0a, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x49, + 0x70, 0x12, 0x25, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x6d, 0x65, 0x63, 0x68, 0x61, 0x6e, + 0x69, 0x73, 0x6d, 0x18, 0x0b, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x61, 0x75, 0x74, 0x68, 0x4d, + 0x65, 0x63, 0x68, 0x61, 0x6e, 0x69, 0x73, 0x6d, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, + 0x5f, 0x69, 0x64, 0x18, 0x0c, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, + 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x75, 0x63, 0x63, 0x65, 0x73, + 0x73, 0x18, 0x0d, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0b, 0x61, 0x75, 0x74, 0x68, 0x53, 0x75, 0x63, + 0x63, 0x65, 0x73, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x62, 0x79, 0x74, 0x65, 0x73, 0x5f, 0x75, 0x70, + 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0e, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x62, 0x79, 0x74, 0x65, + 0x73, 0x55, 0x70, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x62, 0x79, 0x74, 0x65, 0x73, + 0x5f, 0x64, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x18, 0x0f, 0x20, 0x01, 0x28, 0x03, 0x52, + 0x0d, 0x62, 0x79, 0x74, 0x65, 0x73, 0x44, 0x6f, 0x77, 0x6e, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1a, + 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x18, 0x10, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x12, 0x3f, 0x0a, 0x08, 0x6d, 0x65, + 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x11, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, + 0x4c, 0x6f, 0x67, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a, 0x0d, 0x61, + 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x12, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x0c, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x4e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, + 0x1a, 0x3b, 0x0a, 0x0d, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, + 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, + 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf8, 0x01, + 0x0a, 0x13, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, - 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, - 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, - 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, - 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, - 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, - 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, - 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 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, 0x28, 0x0a, 0x0d, 0x65, - 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, - 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, - 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, - 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, - 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, - 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, - 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x7c, 0x0a, 0x16, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, - 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, - 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x27, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, - 0x02, 0x18, 0x01, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, - 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x43, 0x6f, 0x64, 0x65, 0x22, 0x81, 0x02, 0x0a, 0x17, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, - 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, - 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, - 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, - 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, - 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, - 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, - 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, - 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, - 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, - 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, - 0x6d, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, - 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, - 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x50, 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, - 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, - 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, - 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, - 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, - 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, + 0x6e, 0x74, 0x49, 0x64, 0x12, 0x39, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x12, + 0x2a, 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x48, 0x00, 0x52, 0x03, 0x70, 0x69, 0x6e, 0x12, 0x40, 0x0a, 0x0b, 0x68, + 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x48, 0x65, + 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x48, + 0x00, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x42, 0x09, 0x0a, + 0x07, 0x72, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x57, 0x0a, 0x11, 0x48, 0x65, 0x61, 0x64, + 0x65, 0x72, 0x41, 0x75, 0x74, 0x68, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x21, 0x0a, + 0x0c, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x56, 0x61, 0x6c, 0x75, 0x65, + 0x12, 0x1f, 0x0a, 0x0b, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x4e, 0x61, 0x6d, + 0x65, 0x22, 0x2d, 0x0a, 0x0f, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, + 0x22, 0x1e, 0x0a, 0x0a, 0x50, 0x69, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, + 0x0a, 0x03, 0x70, 0x69, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x70, 0x69, 0x6e, + 0x22, 0x55, 0x0a, 0x14, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, + 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, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0xda, 0x02, 0x0a, 0x17, 0x53, 0x65, 0x6e, 0x64, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, + 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, + 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x0e, 0x32, 0x17, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, + 0x72, 0x6f, 0x78, 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x12, 0x2d, 0x0a, 0x12, 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x65, 0x5f, 0x69, 0x73, 0x73, 0x75, 0x65, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, + 0x63, 0x65, 0x72, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x65, 0x49, 0x73, 0x73, 0x75, 0x65, + 0x64, 0x12, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x10, 0x69, + 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x18, + 0x32, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x4c, + 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x48, 0x01, 0x52, 0x0f, 0x69, 0x6e, 0x62, 0x6f, 0x75, + 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, + 0x0e, 0x5f, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, + 0x13, 0x0a, 0x11, 0x5f, 0x69, 0x6e, 0x62, 0x6f, 0x75, 0x6e, 0x64, 0x5f, 0x6c, 0x69, 0x73, 0x74, + 0x65, 0x6e, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x14, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x6e, 0x62, + 0x6f, 0x75, 0x6e, 0x64, 0x4c, 0x69, 0x73, 0x74, 0x65, 0x6e, 0x65, 0x72, 0x12, 0x1b, 0x0a, 0x09, + 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x09, 0x68, + 0x74, 0x74, 0x70, 0x73, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x68, 0x74, 0x74, 0x70, + 0x5f, 0x70, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x08, 0x68, 0x74, 0x74, + 0x70, 0x50, 0x6f, 0x72, 0x74, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0xb8, 0x01, 0x0a, 0x16, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, + 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x30, 0x0a, 0x14, 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x5f, 0x70, 0x75, + 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, + 0x77, 0x69, 0x72, 0x65, 0x67, 0x75, 0x61, 0x72, 0x64, 0x50, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, + 0x65, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x07, 0x63, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x22, 0x6f, 0x0a, 0x17, + 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 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, 0x28, 0x0a, 0x0d, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, + 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x0c, 0x65, 0x72, 0x72, 0x6f, + 0x72, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x88, 0x01, 0x01, 0x42, 0x10, 0x0a, 0x0e, 0x5f, + 0x65, 0x72, 0x72, 0x6f, 0x72, 0x5f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x22, 0x65, 0x0a, + 0x11, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, + 0x69, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, + 0x64, 0x12, 0x21, 0x0a, 0x0c, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, + 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x72, 0x65, 0x64, 0x69, 0x72, 0x65, 0x63, + 0x74, 0x55, 0x72, 0x6c, 0x22, 0x26, 0x0a, 0x12, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, + 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x7c, 0x0a, 0x16, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, 0x12, 0x27, + 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x02, 0x18, 0x01, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x21, 0x0a, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x73, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x22, 0x81, 0x02, 0x0a, 0x17, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, + 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, + 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, + 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, + 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, + 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, + 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, + 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x05, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, + 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, + 0x6d, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, + 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, + 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x50, + 0x0a, 0x19, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1b, 0x0a, 0x09, 0x74, + 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x5f, 0x69, 0x70, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x74, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x49, 0x70, 0x12, 0x16, 0x0a, 0x06, 0x64, 0x6f, 0x6d, 0x61, + 0x69, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x64, 0x6f, 0x6d, 0x61, 0x69, 0x6e, + 0x22, 0x84, 0x02, 0x0a, 0x1a, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, + 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x05, + 0x76, 0x61, 0x6c, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, + 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, + 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, + 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, + 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, + 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, + 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, + 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, + 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, + 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, + 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, + 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, + 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, + 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, + 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, + 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, + 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, + 0x5f, 0x61, 0x74, 0x18, 0x03, 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, 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, + 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, + 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, + 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, + 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, + 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, + 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, + 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, + 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, + 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, + 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, + 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, - 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x75, 0x73, 0x65, 0x72, - 0x5f, 0x65, 0x6d, 0x61, 0x69, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x75, 0x73, - 0x65, 0x72, 0x45, 0x6d, 0x61, 0x69, 0x6c, 0x12, 0x23, 0x0a, 0x0d, 0x64, 0x65, 0x6e, 0x69, 0x65, - 0x64, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, - 0x64, 0x65, 0x6e, 0x69, 0x65, 0x64, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x12, 0x23, 0x0a, 0x0d, - 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, - 0x01, 0x28, 0x09, 0x52, 0x0c, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x54, 0x6f, 0x6b, 0x65, - 0x6e, 0x12, 0x24, 0x0a, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, - 0x69, 0x64, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c, 0x70, 0x65, 0x65, 0x72, 0x47, - 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x28, 0x0a, 0x10, 0x70, 0x65, 0x65, 0x72, 0x5f, - 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, - 0x09, 0x52, 0x0e, 0x70, 0x65, 0x65, 0x72, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x4e, 0x61, 0x6d, 0x65, - 0x73, 0x22, 0x81, 0x01, 0x0a, 0x13, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x32, 0x0a, 0x04, 0x69, 0x6e, 0x69, - 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, - 0x73, 0x49, 0x6e, 0x69, 0x74, 0x48, 0x00, 0x52, 0x04, 0x69, 0x6e, 0x69, 0x74, 0x12, 0x2f, 0x0a, - 0x03, 0x61, 0x63, 0x6b, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x48, 0x00, 0x52, 0x03, 0x61, 0x63, 0x6b, 0x42, 0x05, - 0x0a, 0x03, 0x6d, 0x73, 0x67, 0x22, 0xdf, 0x01, 0x0a, 0x10, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, - 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x49, 0x6e, 0x69, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x70, 0x72, - 0x6f, 0x78, 0x79, 0x49, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, - 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x39, 0x0a, 0x0a, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x03, 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, - 0x09, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x61, 0x64, - 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64, 0x64, - 0x72, 0x65, 0x73, 0x73, 0x12, 0x41, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, - 0x74, 0x69, 0x65, 0x73, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x43, 0x61, 0x70, - 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, - 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x22, 0x11, 0x0a, 0x0f, 0x53, 0x79, 0x6e, 0x63, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x41, 0x63, 0x6b, 0x22, 0x7e, 0x0a, 0x14, 0x53, 0x79, - 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x32, 0x0a, 0x07, 0x6d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, - 0x03, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x52, 0x07, 0x6d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x12, 0x32, 0x0a, 0x15, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, - 0x6c, 0x5f, 0x73, 0x79, 0x6e, 0x63, 0x5f, 0x63, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x18, - 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x13, 0x69, 0x6e, 0x69, 0x74, 0x69, 0x61, 0x6c, 0x53, 0x79, - 0x6e, 0x63, 0x43, 0x6f, 0x6d, 0x70, 0x6c, 0x65, 0x74, 0x65, 0x22, 0xa9, 0x01, 0x0a, 0x1b, 0x43, - 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, - 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, - 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, - 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, - 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, - 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, - 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, - 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, - 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, - 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, - 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, - 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, - 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, - 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, - 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, - 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, - 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, - 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, - 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, - 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, - 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, - 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, - 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, - 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, - 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, - 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, - 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, - 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, - 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, - 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, - 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, - 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, - 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, - 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, - 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, - 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, - 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, - 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, - 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, - 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, - 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, - 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, - 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, - 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, - 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, - 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, - 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, - 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, - 0x56, 0x45, 0x10, 0x01, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, - 0x61, 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, - 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, - 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, - 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, - 0x45, 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, - 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, - 0x45, 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, - 0x44, 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, - 0x4d, 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, - 0x79, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, - 0x00, 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, - 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, - 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, - 0x4c, 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, - 0x24, 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, - 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, - 0x49, 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, - 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, - 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, - 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, - 0x10, 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, - 0x69, 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, - 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, - 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, - 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, - 0x69, 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, + 0x70, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, + 0x75, 0x70, 0x49, 0x64, 0x73, 0x12, 0x1f, 0x0a, 0x0b, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, + 0x72, 0x5f, 0x69, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x70, 0x72, 0x6f, 0x76, + 0x69, 0x64, 0x65, 0x72, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x18, + 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x6d, 0x6f, 0x64, 0x65, 0x6c, 0x22, 0xff, 0x01, 0x0a, + 0x1c, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, + 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1a, 0x0a, + 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x08, 0x64, 0x65, 0x63, 0x69, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2c, 0x0a, 0x12, 0x73, 0x65, 0x6c, + 0x65, 0x63, 0x74, 0x65, 0x64, 0x5f, 0x70, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x10, 0x73, 0x65, 0x6c, 0x65, 0x63, 0x74, 0x65, 0x64, 0x50, + 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x49, 0x64, 0x12, 0x30, 0x0a, 0x14, 0x61, 0x74, 0x74, 0x72, 0x69, + 0x62, 0x75, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x12, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x69, + 0x6f, 0x6e, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, 0x69, 0x6e, + 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, + 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, + 0x12, 0x1b, 0x0a, 0x09, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x05, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x08, 0x64, 0x65, 0x6e, 0x79, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x1f, 0x0a, + 0x0b, 0x64, 0x65, 0x6e, 0x79, 0x5f, 0x72, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x64, 0x65, 0x6e, 0x79, 0x52, 0x65, 0x61, 0x73, 0x6f, 0x6e, 0x22, 0x91, + 0x02, 0x0a, 0x15, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, + 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x63, 0x63, 0x6f, + 0x75, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x63, + 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x75, 0x73, 0x65, 0x72, 0x5f, + 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x75, 0x73, 0x65, 0x72, 0x49, 0x64, + 0x12, 0x19, 0x0a, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x07, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, 0x64, 0x12, 0x25, 0x0a, 0x0e, 0x77, + 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x5f, 0x73, 0x65, 0x63, 0x6f, 0x6e, 0x64, 0x73, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x03, 0x52, 0x0d, 0x77, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x63, 0x6f, 0x6e, + 0x64, 0x73, 0x12, 0x21, 0x0a, 0x0c, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, 0x69, 0x6e, 0x70, + 0x75, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0b, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, + 0x49, 0x6e, 0x70, 0x75, 0x74, 0x12, 0x23, 0x0a, 0x0d, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x73, 0x5f, + 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x18, 0x06, 0x20, 0x01, 0x28, 0x03, 0x52, 0x0c, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x73, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x63, 0x6f, + 0x73, 0x74, 0x5f, 0x75, 0x73, 0x64, 0x18, 0x07, 0x20, 0x01, 0x28, 0x01, 0x52, 0x07, 0x63, 0x6f, + 0x73, 0x74, 0x55, 0x73, 0x64, 0x12, 0x1b, 0x0a, 0x09, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x5f, 0x69, + 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x09, 0x52, 0x08, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x49, + 0x64, 0x73, 0x22, 0x18, 0x0a, 0x16, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, + 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x64, 0x0a, 0x16, + 0x50, 0x72, 0x6f, 0x78, 0x79, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x54, 0x79, 0x70, 0x65, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, + 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x00, 0x12, + 0x18, 0x0a, 0x14, 0x55, 0x50, 0x44, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x4d, + 0x4f, 0x44, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x55, 0x50, 0x44, + 0x41, 0x54, 0x45, 0x5f, 0x54, 0x59, 0x50, 0x45, 0x5f, 0x52, 0x45, 0x4d, 0x4f, 0x56, 0x45, 0x44, + 0x10, 0x02, 0x2a, 0x46, 0x0a, 0x0f, 0x50, 0x61, 0x74, 0x68, 0x52, 0x65, 0x77, 0x72, 0x69, 0x74, + 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, + 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x00, 0x12, + 0x19, 0x0a, 0x15, 0x50, 0x41, 0x54, 0x48, 0x5f, 0x52, 0x45, 0x57, 0x52, 0x49, 0x54, 0x45, 0x5f, + 0x50, 0x52, 0x45, 0x53, 0x45, 0x52, 0x56, 0x45, 0x10, 0x01, 0x2a, 0x77, 0x0a, 0x12, 0x54, 0x61, + 0x72, 0x67, 0x65, 0x74, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x41, 0x63, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x20, 0x0a, 0x1c, 0x54, 0x41, 0x52, 0x47, 0x45, 0x54, 0x5f, 0x41, 0x43, 0x43, 0x45, 0x53, + 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x49, 0x4e, 0x48, 0x45, 0x52, 0x49, 0x54, + 0x10, 0x00, 0x12, 0x1f, 0x0a, 0x1b, 0x54, 0x41, 0x52, 0x47, 0x45, 0x54, 0x5f, 0x41, 0x43, 0x43, + 0x45, 0x53, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x42, 0x59, 0x50, 0x41, 0x53, + 0x53, 0x10, 0x01, 0x12, 0x1e, 0x0a, 0x1a, 0x54, 0x41, 0x52, 0x47, 0x45, 0x54, 0x5f, 0x41, 0x43, + 0x43, 0x45, 0x53, 0x53, 0x5f, 0x41, 0x43, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x42, 0x4c, 0x4f, 0x43, + 0x4b, 0x10, 0x02, 0x2a, 0x90, 0x01, 0x0a, 0x0e, 0x4d, 0x69, 0x64, 0x64, 0x6c, 0x65, 0x77, 0x61, + 0x72, 0x65, 0x53, 0x6c, 0x6f, 0x74, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, 0x45, + 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, + 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x4d, 0x49, 0x44, 0x44, 0x4c, + 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, + 0x51, 0x55, 0x45, 0x53, 0x54, 0x10, 0x01, 0x12, 0x1f, 0x0a, 0x1b, 0x4d, 0x49, 0x44, 0x44, 0x4c, + 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x4f, 0x4e, 0x5f, 0x52, 0x45, + 0x53, 0x50, 0x4f, 0x4e, 0x53, 0x45, 0x10, 0x02, 0x12, 0x1c, 0x0a, 0x18, 0x4d, 0x49, 0x44, 0x44, + 0x4c, 0x45, 0x57, 0x41, 0x52, 0x45, 0x5f, 0x53, 0x4c, 0x4f, 0x54, 0x5f, 0x54, 0x45, 0x52, 0x4d, + 0x49, 0x4e, 0x41, 0x4c, 0x10, 0x03, 0x2a, 0xc8, 0x01, 0x0a, 0x0b, 0x50, 0x72, 0x6f, 0x78, 0x79, + 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x18, 0x0a, 0x14, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, + 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x00, + 0x12, 0x17, 0x0a, 0x13, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, + 0x5f, 0x41, 0x43, 0x54, 0x49, 0x56, 0x45, 0x10, 0x01, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, + 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x54, 0x55, 0x4e, 0x4e, 0x45, 0x4c, + 0x5f, 0x4e, 0x4f, 0x54, 0x5f, 0x43, 0x52, 0x45, 0x41, 0x54, 0x45, 0x44, 0x10, 0x02, 0x12, 0x24, + 0x0a, 0x20, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, + 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, + 0x4e, 0x47, 0x10, 0x03, 0x12, 0x23, 0x0a, 0x1f, 0x50, 0x52, 0x4f, 0x58, 0x59, 0x5f, 0x53, 0x54, + 0x41, 0x54, 0x55, 0x53, 0x5f, 0x43, 0x45, 0x52, 0x54, 0x49, 0x46, 0x49, 0x43, 0x41, 0x54, 0x45, + 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x16, 0x0a, 0x12, 0x50, 0x52, 0x4f, + 0x58, 0x59, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x55, 0x53, 0x5f, 0x45, 0x52, 0x52, 0x4f, 0x52, 0x10, + 0x05, 0x32, 0xfc, 0x07, 0x0a, 0x0c, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, + 0x63, 0x65, 0x12, 0x5f, 0x0a, 0x10, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, + 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, + 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4d, 0x61, 0x70, 0x70, + 0x69, 0x6e, 0x67, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x30, 0x01, 0x12, 0x55, 0x0a, 0x0c, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, + 0x6e, 0x67, 0x73, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, + 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x53, 0x79, 0x6e, 0x63, 0x4d, 0x61, 0x70, 0x70, 0x69, 0x6e, 0x67, 0x73, 0x52, - 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, - 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, - 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, - 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, - 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, - 0x65, 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, - 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, - 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, - 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, - 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, - 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, - 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, - 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, - 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, - 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, - 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, - 0x6f, 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, - 0x4b, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, - 0x44, 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x28, 0x01, 0x30, 0x01, 0x12, 0x54, 0x0a, 0x0d, 0x53, 0x65, + 0x6e, 0x64, 0x41, 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x12, 0x20, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, 0x63, 0x63, + 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x41, + 0x63, 0x63, 0x65, 0x73, 0x73, 0x4c, 0x6f, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x51, 0x0a, 0x0c, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, + 0x12, 0x1f, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, 0x75, + 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x20, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x5d, 0x0a, 0x10, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, + 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x53, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, + 0x79, 0x50, 0x65, 0x65, 0x72, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, 0x78, 0x79, 0x50, 0x65, + 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x50, 0x72, 0x6f, + 0x78, 0x79, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4b, + 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, 0x55, 0x52, 0x4c, 0x12, 0x1d, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, - 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, - 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, - 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, - 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, - 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, + 0x43, 0x55, 0x52, 0x4c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6d, 0x61, + 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x47, 0x65, 0x74, 0x4f, 0x49, 0x44, 0x43, + 0x55, 0x52, 0x4c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x5a, 0x0a, 0x0f, 0x56, + 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x22, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, - 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, - 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, - 0x14, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, - 0x69, 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, - 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, - 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, - 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, - 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, - 0x72, 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, - 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, - 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, - 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, - 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, - 0x65, 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x33, + 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, + 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x63, 0x0a, 0x12, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x12, 0x25, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, + 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x26, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x65, 0x54, 0x75, 0x6e, 0x6e, 0x65, 0x6c, + 0x50, 0x65, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x69, 0x0a, 0x14, + 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, + 0x6d, 0x69, 0x74, 0x73, 0x12, 0x27, 0x2e, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, + 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, + 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x43, 0x68, 0x65, 0x63, 0x6b, + 0x4c, 0x4c, 0x4d, 0x50, 0x6f, 0x6c, 0x69, 0x63, 0x79, 0x4c, 0x69, 0x6d, 0x69, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x57, 0x0a, 0x0e, 0x52, 0x65, 0x63, 0x6f, 0x72, + 0x64, 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x12, 0x21, 0x2e, 0x6d, 0x61, 0x6e, 0x61, + 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, 0x4c, 0x4c, 0x4d, + 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x6d, + 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x72, 0x64, + 0x4c, 0x4c, 0x4d, 0x55, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x42, 0x08, 0x5a, 0x06, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x33, } var ( @@ -3775,114 +3844,116 @@ func file_proxy_service_proto_rawDescGZIP() []byte { return file_proxy_service_proto_rawDescData } -var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_proxy_service_proto_enumTypes = make([]protoimpl.EnumInfo, 6) var file_proxy_service_proto_msgTypes = make([]protoimpl.MessageInfo, 39) var file_proxy_service_proto_goTypes = []interface{}{ (ProxyMappingUpdateType)(0), // 0: management.ProxyMappingUpdateType (PathRewriteMode)(0), // 1: management.PathRewriteMode - (MiddlewareSlot)(0), // 2: management.MiddlewareSlot - (ProxyStatus)(0), // 3: management.ProxyStatus - (MiddlewareConfig_FailMode)(0), // 4: management.MiddlewareConfig.FailMode - (*ProxyCapabilities)(nil), // 5: management.ProxyCapabilities - (*GetMappingUpdateRequest)(nil), // 6: management.GetMappingUpdateRequest - (*GetMappingUpdateResponse)(nil), // 7: management.GetMappingUpdateResponse - (*PathTargetOptions)(nil), // 8: management.PathTargetOptions - (*MiddlewareConfig)(nil), // 9: management.MiddlewareConfig - (*PathMapping)(nil), // 10: management.PathMapping - (*HeaderAuth)(nil), // 11: management.HeaderAuth - (*Authentication)(nil), // 12: management.Authentication - (*AccessRestrictions)(nil), // 13: management.AccessRestrictions - (*ProxyMapping)(nil), // 14: management.ProxyMapping - (*SendAccessLogRequest)(nil), // 15: management.SendAccessLogRequest - (*SendAccessLogResponse)(nil), // 16: management.SendAccessLogResponse - (*AccessLog)(nil), // 17: management.AccessLog - (*AuthenticateRequest)(nil), // 18: management.AuthenticateRequest - (*HeaderAuthRequest)(nil), // 19: management.HeaderAuthRequest - (*PasswordRequest)(nil), // 20: management.PasswordRequest - (*PinRequest)(nil), // 21: management.PinRequest - (*AuthenticateResponse)(nil), // 22: management.AuthenticateResponse - (*SendStatusUpdateRequest)(nil), // 23: management.SendStatusUpdateRequest - (*ProxyInboundListener)(nil), // 24: management.ProxyInboundListener - (*SendStatusUpdateResponse)(nil), // 25: management.SendStatusUpdateResponse - (*CreateProxyPeerRequest)(nil), // 26: management.CreateProxyPeerRequest - (*CreateProxyPeerResponse)(nil), // 27: management.CreateProxyPeerResponse - (*GetOIDCURLRequest)(nil), // 28: management.GetOIDCURLRequest - (*GetOIDCURLResponse)(nil), // 29: management.GetOIDCURLResponse - (*ValidateSessionRequest)(nil), // 30: management.ValidateSessionRequest - (*ValidateSessionResponse)(nil), // 31: management.ValidateSessionResponse - (*ValidateTunnelPeerRequest)(nil), // 32: management.ValidateTunnelPeerRequest - (*ValidateTunnelPeerResponse)(nil), // 33: management.ValidateTunnelPeerResponse - (*SyncMappingsRequest)(nil), // 34: management.SyncMappingsRequest - (*SyncMappingsInit)(nil), // 35: management.SyncMappingsInit - (*SyncMappingsAck)(nil), // 36: management.SyncMappingsAck - (*SyncMappingsResponse)(nil), // 37: management.SyncMappingsResponse - (*CheckLLMPolicyLimitsRequest)(nil), // 38: management.CheckLLMPolicyLimitsRequest - (*CheckLLMPolicyLimitsResponse)(nil), // 39: management.CheckLLMPolicyLimitsResponse - (*RecordLLMUsageRequest)(nil), // 40: management.RecordLLMUsageRequest - (*RecordLLMUsageResponse)(nil), // 41: management.RecordLLMUsageResponse - nil, // 42: management.PathTargetOptions.CustomHeadersEntry - nil, // 43: management.AccessLog.MetadataEntry - (*timestamppb.Timestamp)(nil), // 44: google.protobuf.Timestamp - (*durationpb.Duration)(nil), // 45: google.protobuf.Duration + (TargetAccessAction)(0), // 2: management.TargetAccessAction + (MiddlewareSlot)(0), // 3: management.MiddlewareSlot + (ProxyStatus)(0), // 4: management.ProxyStatus + (MiddlewareConfig_FailMode)(0), // 5: management.MiddlewareConfig.FailMode + (*ProxyCapabilities)(nil), // 6: management.ProxyCapabilities + (*GetMappingUpdateRequest)(nil), // 7: management.GetMappingUpdateRequest + (*GetMappingUpdateResponse)(nil), // 8: management.GetMappingUpdateResponse + (*PathTargetOptions)(nil), // 9: management.PathTargetOptions + (*MiddlewareConfig)(nil), // 10: management.MiddlewareConfig + (*PathMapping)(nil), // 11: management.PathMapping + (*HeaderAuth)(nil), // 12: management.HeaderAuth + (*Authentication)(nil), // 13: management.Authentication + (*AccessRestrictions)(nil), // 14: management.AccessRestrictions + (*ProxyMapping)(nil), // 15: management.ProxyMapping + (*SendAccessLogRequest)(nil), // 16: management.SendAccessLogRequest + (*SendAccessLogResponse)(nil), // 17: management.SendAccessLogResponse + (*AccessLog)(nil), // 18: management.AccessLog + (*AuthenticateRequest)(nil), // 19: management.AuthenticateRequest + (*HeaderAuthRequest)(nil), // 20: management.HeaderAuthRequest + (*PasswordRequest)(nil), // 21: management.PasswordRequest + (*PinRequest)(nil), // 22: management.PinRequest + (*AuthenticateResponse)(nil), // 23: management.AuthenticateResponse + (*SendStatusUpdateRequest)(nil), // 24: management.SendStatusUpdateRequest + (*ProxyInboundListener)(nil), // 25: management.ProxyInboundListener + (*SendStatusUpdateResponse)(nil), // 26: management.SendStatusUpdateResponse + (*CreateProxyPeerRequest)(nil), // 27: management.CreateProxyPeerRequest + (*CreateProxyPeerResponse)(nil), // 28: management.CreateProxyPeerResponse + (*GetOIDCURLRequest)(nil), // 29: management.GetOIDCURLRequest + (*GetOIDCURLResponse)(nil), // 30: management.GetOIDCURLResponse + (*ValidateSessionRequest)(nil), // 31: management.ValidateSessionRequest + (*ValidateSessionResponse)(nil), // 32: management.ValidateSessionResponse + (*ValidateTunnelPeerRequest)(nil), // 33: management.ValidateTunnelPeerRequest + (*ValidateTunnelPeerResponse)(nil), // 34: management.ValidateTunnelPeerResponse + (*SyncMappingsRequest)(nil), // 35: management.SyncMappingsRequest + (*SyncMappingsInit)(nil), // 36: management.SyncMappingsInit + (*SyncMappingsAck)(nil), // 37: management.SyncMappingsAck + (*SyncMappingsResponse)(nil), // 38: management.SyncMappingsResponse + (*CheckLLMPolicyLimitsRequest)(nil), // 39: management.CheckLLMPolicyLimitsRequest + (*CheckLLMPolicyLimitsResponse)(nil), // 40: management.CheckLLMPolicyLimitsResponse + (*RecordLLMUsageRequest)(nil), // 41: management.RecordLLMUsageRequest + (*RecordLLMUsageResponse)(nil), // 42: management.RecordLLMUsageResponse + nil, // 43: management.PathTargetOptions.CustomHeadersEntry + nil, // 44: management.AccessLog.MetadataEntry + (*timestamppb.Timestamp)(nil), // 45: google.protobuf.Timestamp + (*durationpb.Duration)(nil), // 46: google.protobuf.Duration } var file_proxy_service_proto_depIdxs = []int32{ - 44, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp - 5, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities - 14, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping - 45, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration + 45, // 0: management.GetMappingUpdateRequest.started_at:type_name -> google.protobuf.Timestamp + 6, // 1: management.GetMappingUpdateRequest.capabilities:type_name -> management.ProxyCapabilities + 15, // 2: management.GetMappingUpdateResponse.mapping:type_name -> management.ProxyMapping + 46, // 3: management.PathTargetOptions.request_timeout:type_name -> google.protobuf.Duration 1, // 4: management.PathTargetOptions.path_rewrite:type_name -> management.PathRewriteMode - 42, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry - 45, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration - 9, // 7: management.PathTargetOptions.middlewares:type_name -> management.MiddlewareConfig - 2, // 8: management.MiddlewareConfig.slot:type_name -> management.MiddlewareSlot - 4, // 9: management.MiddlewareConfig.fail_mode:type_name -> management.MiddlewareConfig.FailMode - 45, // 10: management.MiddlewareConfig.timeout:type_name -> google.protobuf.Duration - 8, // 11: management.PathMapping.options:type_name -> management.PathTargetOptions - 11, // 12: management.Authentication.header_auths:type_name -> management.HeaderAuth - 0, // 13: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType - 10, // 14: management.ProxyMapping.path:type_name -> management.PathMapping - 12, // 15: management.ProxyMapping.auth:type_name -> management.Authentication - 13, // 16: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions - 17, // 17: management.SendAccessLogRequest.log:type_name -> management.AccessLog - 44, // 18: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp - 43, // 19: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry - 20, // 20: management.AuthenticateRequest.password:type_name -> management.PasswordRequest - 21, // 21: management.AuthenticateRequest.pin:type_name -> management.PinRequest - 19, // 22: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest - 3, // 23: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus - 24, // 24: management.SendStatusUpdateRequest.inbound_listener:type_name -> management.ProxyInboundListener - 35, // 25: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit - 36, // 26: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck - 44, // 27: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp - 5, // 28: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities - 14, // 29: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping - 6, // 30: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest - 34, // 31: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest - 15, // 32: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest - 18, // 33: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest - 23, // 34: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest - 26, // 35: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest - 28, // 36: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest - 30, // 37: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest - 32, // 38: management.ProxyService.ValidateTunnelPeer:input_type -> management.ValidateTunnelPeerRequest - 38, // 39: management.ProxyService.CheckLLMPolicyLimits:input_type -> management.CheckLLMPolicyLimitsRequest - 40, // 40: management.ProxyService.RecordLLMUsage:input_type -> management.RecordLLMUsageRequest - 7, // 41: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse - 37, // 42: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse - 16, // 43: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse - 22, // 44: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse - 25, // 45: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse - 27, // 46: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse - 29, // 47: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse - 31, // 48: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse - 33, // 49: management.ProxyService.ValidateTunnelPeer:output_type -> management.ValidateTunnelPeerResponse - 39, // 50: management.ProxyService.CheckLLMPolicyLimits:output_type -> management.CheckLLMPolicyLimitsResponse - 41, // 51: management.ProxyService.RecordLLMUsage:output_type -> management.RecordLLMUsageResponse - 41, // [41:52] is the sub-list for method output_type - 30, // [30:41] is the sub-list for method input_type - 30, // [30:30] is the sub-list for extension type_name - 30, // [30:30] is the sub-list for extension extendee - 0, // [0:30] is the sub-list for field type_name + 43, // 5: management.PathTargetOptions.custom_headers:type_name -> management.PathTargetOptions.CustomHeadersEntry + 46, // 6: management.PathTargetOptions.session_idle_timeout:type_name -> google.protobuf.Duration + 10, // 7: management.PathTargetOptions.middlewares:type_name -> management.MiddlewareConfig + 3, // 8: management.MiddlewareConfig.slot:type_name -> management.MiddlewareSlot + 5, // 9: management.MiddlewareConfig.fail_mode:type_name -> management.MiddlewareConfig.FailMode + 46, // 10: management.MiddlewareConfig.timeout:type_name -> google.protobuf.Duration + 9, // 11: management.PathMapping.options:type_name -> management.PathTargetOptions + 2, // 12: management.PathMapping.access_action:type_name -> management.TargetAccessAction + 12, // 13: management.Authentication.header_auths:type_name -> management.HeaderAuth + 0, // 14: management.ProxyMapping.type:type_name -> management.ProxyMappingUpdateType + 11, // 15: management.ProxyMapping.path:type_name -> management.PathMapping + 13, // 16: management.ProxyMapping.auth:type_name -> management.Authentication + 14, // 17: management.ProxyMapping.access_restrictions:type_name -> management.AccessRestrictions + 18, // 18: management.SendAccessLogRequest.log:type_name -> management.AccessLog + 45, // 19: management.AccessLog.timestamp:type_name -> google.protobuf.Timestamp + 44, // 20: management.AccessLog.metadata:type_name -> management.AccessLog.MetadataEntry + 21, // 21: management.AuthenticateRequest.password:type_name -> management.PasswordRequest + 22, // 22: management.AuthenticateRequest.pin:type_name -> management.PinRequest + 20, // 23: management.AuthenticateRequest.header_auth:type_name -> management.HeaderAuthRequest + 4, // 24: management.SendStatusUpdateRequest.status:type_name -> management.ProxyStatus + 25, // 25: management.SendStatusUpdateRequest.inbound_listener:type_name -> management.ProxyInboundListener + 36, // 26: management.SyncMappingsRequest.init:type_name -> management.SyncMappingsInit + 37, // 27: management.SyncMappingsRequest.ack:type_name -> management.SyncMappingsAck + 45, // 28: management.SyncMappingsInit.started_at:type_name -> google.protobuf.Timestamp + 6, // 29: management.SyncMappingsInit.capabilities:type_name -> management.ProxyCapabilities + 15, // 30: management.SyncMappingsResponse.mapping:type_name -> management.ProxyMapping + 7, // 31: management.ProxyService.GetMappingUpdate:input_type -> management.GetMappingUpdateRequest + 35, // 32: management.ProxyService.SyncMappings:input_type -> management.SyncMappingsRequest + 16, // 33: management.ProxyService.SendAccessLog:input_type -> management.SendAccessLogRequest + 19, // 34: management.ProxyService.Authenticate:input_type -> management.AuthenticateRequest + 24, // 35: management.ProxyService.SendStatusUpdate:input_type -> management.SendStatusUpdateRequest + 27, // 36: management.ProxyService.CreateProxyPeer:input_type -> management.CreateProxyPeerRequest + 29, // 37: management.ProxyService.GetOIDCURL:input_type -> management.GetOIDCURLRequest + 31, // 38: management.ProxyService.ValidateSession:input_type -> management.ValidateSessionRequest + 33, // 39: management.ProxyService.ValidateTunnelPeer:input_type -> management.ValidateTunnelPeerRequest + 39, // 40: management.ProxyService.CheckLLMPolicyLimits:input_type -> management.CheckLLMPolicyLimitsRequest + 41, // 41: management.ProxyService.RecordLLMUsage:input_type -> management.RecordLLMUsageRequest + 8, // 42: management.ProxyService.GetMappingUpdate:output_type -> management.GetMappingUpdateResponse + 38, // 43: management.ProxyService.SyncMappings:output_type -> management.SyncMappingsResponse + 17, // 44: management.ProxyService.SendAccessLog:output_type -> management.SendAccessLogResponse + 23, // 45: management.ProxyService.Authenticate:output_type -> management.AuthenticateResponse + 26, // 46: management.ProxyService.SendStatusUpdate:output_type -> management.SendStatusUpdateResponse + 28, // 47: management.ProxyService.CreateProxyPeer:output_type -> management.CreateProxyPeerResponse + 30, // 48: management.ProxyService.GetOIDCURL:output_type -> management.GetOIDCURLResponse + 32, // 49: management.ProxyService.ValidateSession:output_type -> management.ValidateSessionResponse + 34, // 50: management.ProxyService.ValidateTunnelPeer:output_type -> management.ValidateTunnelPeerResponse + 40, // 51: management.ProxyService.CheckLLMPolicyLimits:output_type -> management.CheckLLMPolicyLimitsResponse + 42, // 52: management.ProxyService.RecordLLMUsage:output_type -> management.RecordLLMUsageResponse + 42, // [42:53] is the sub-list for method output_type + 31, // [31:42] is the sub-list for method input_type + 31, // [31:31] is the sub-list for extension type_name + 31, // [31:31] is the sub-list for extension extendee + 0, // [0:31] is the sub-list for field type_name } func init() { file_proxy_service_proto_init() } @@ -4353,7 +4424,7 @@ func file_proxy_service_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_proxy_service_proto_rawDesc, - NumEnums: 5, + NumEnums: 6, NumMessages: 39, NumExtensions: 0, NumServices: 1, diff --git a/shared/management/proto/proxy_service.proto b/shared/management/proto/proxy_service.proto index 7c5fb08ebe3..d988f876721 100644 --- a/shared/management/proto/proxy_service.proto +++ b/shared/management/proto/proxy_service.proto @@ -105,6 +105,12 @@ enum PathRewriteMode { PATH_REWRITE_PRESERVE = 1; } +enum TargetAccessAction { + TARGET_ACCESS_ACTION_INHERIT = 0; + TARGET_ACCESS_ACTION_BYPASS = 1; + TARGET_ACCESS_ACTION_BLOCK = 2; +} + message PathTargetOptions { bool skip_tls_verify = 1; google.protobuf.Duration request_timeout = 2; @@ -178,6 +184,7 @@ message PathMapping { string path = 1; string target = 2; PathTargetOptions options = 3; + TargetAccessAction access_action = 4; } message HeaderAuth {